Skip to content

price oracle: add intent, optional peer_id and metadata to QueryAssetRates RPC #1677

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ type Config struct {

PriceOracle rfq.PriceOracle

PriceOracleSendPeerID bool

UniverseStats universe.Telemetry

AuxLeafSigner *tapchannel.AuxLeafSigner
Expand Down
9 changes: 9 additions & 0 deletions docs/release-notes/release-notes-0.7.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@
tag and also needs to be toggled on via the `channel.noop-htlcs` configuration
option.

- [Two new configuration values were added to improve privacy when using public
or untrusted third-party price
oracles](https://github.com/lightninglabs/taproot-assets/pull/1677):
`experimental.rfq.sendpricehint` controls whether a price hint is queried
from the local price oracle and sent to the peer when requesting a price
quote (opt-in, default `false`). `experimental.rfq.priceoraclesendpeerid`
controls whether the peer's identity public key is sent to the local price
oracle when querying asset price rates.

## RPC Additions

- The [price oracle RPC calls now have an intent, optional peer ID and metadata
Expand Down
73 changes: 73 additions & 0 deletions docs/rfq-and-decimal-display.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,42 @@ convert from mSAT to asset units as follows:
`price_in_asset`
* `Y` is the number of asset units per BTC, specified by `price_out_asset`

### Price oracle interaction

```mermaid
sequenceDiagram
actor User
box Seller (user)
participant NodeA as Node A
participant OracleA as Price Oracle A
end
box Buyer (edge node)
participant NodeB as Node B
participant OracleB as Price Oracle B
end

User->>+NodeA: SendPayment

NodeA->>+NodeA: AddAssetSellOrder

NodeA->>+OracleA: Get price rate hint<br/>(QueryAssetRate[type=SALE,intent=PAY_INVOICE_HINT,peer=NodeB])
OracleA-->>-NodeA: Price rate hint

NodeA->>+NodeB: Send sell request with price<br/>rate hint over p2p

NodeB->>+OracleB: Determine actual price<br/>rate using suggested price<br/>(QueryAssetRate[type=PURCHASE,intent=PAY_INVOICE,peer=NodeA])
OracleB-->>-NodeB: Actual price rate

NodeB-->>-NodeA: Return actual price rate

NodeA->>+OracleA: Validate actual price rate<br/>(QueryAssetRate[type=SALE,intent=PAY_INVOICE_QUALIFY,peer=NodeB])
OracleA-->>-NodeA: Approve actual price rate

NodeA->>-NodeA: Send payment over LN using approved actual price rate

NodeA->>-User: Payment result
```

## Buy Order (Receiving via an Invoice)

The buy order covers the second user story: The user wants to get paid, they
Expand Down Expand Up @@ -245,6 +281,43 @@ node as:
* `M` is the number of mSAT in a BTC (100,000,000,000), specified by
`price_in_asset`

### Price oracle interaction

```mermaid
sequenceDiagram
actor User
box Buyer (user)
participant NodeA as Node A
participant OracleA as Price Oracle A
end
box Seller (edge node)
participant NodeB as Node B
participant OracleB as Price Oracle B
end

User->>+NodeA: AddInvoice

NodeA->>+NodeA: AddAssetBuyOrder

NodeA->>+OracleA: Get price rate hint<br/>(QueryAssetRate[type=PURCHASE,intent=RECV_PAYMENT_HINT,peer=NodeB])
OracleA-->>-NodeA: Price rate hint

NodeA->>+NodeB: Send buy request with price<br/>rate hint over p2p

NodeB->>+OracleB: Determine actual price<br/>rate using suggested price<br/>(QueryAssetRate[type=SALE,intent=RECV_PAYMENT,peer=NodeA])
OracleB-->>-NodeB: Actual price rate

NodeB-->>-NodeA: Return actual price rate

NodeA->>+OracleA: Validate actual price rate<br/>(QueryAssetRate[type=PURCHASE,intent=RECV_PAYMENT_QUALIFY,peer=NodeB])
OracleA-->>-NodeA: Approve actual price rate

NodeA->>-NodeA: Create invoice using actual price rate

NodeA->>-User: Invoice
```


## Examples

See `TestFindDecimalDisplayBoundaries` and `TestUsdToJpy` in
Expand Down
64 changes: 47 additions & 17 deletions itest/oracle_harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"net"
"slices"
"testing"
"time"

Expand All @@ -16,6 +17,7 @@ import (
"github.com/lightninglabs/taproot-assets/rpcutils"
oraclerpc "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc"
"github.com/lightningnetwork/lnd/cert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
Expand All @@ -30,35 +32,43 @@ type oracleHarness struct {
grpcListener net.Listener
grpcServer *grpc.Server

// bidPrices is a map used internally by the oracle harness to store bid
// Mock is a mock object that can optionally be used to track calls to
// the oracle harness. If no call expectations are set, the prices from
// the maps below will be used.
// NOTE: When setting up the call expectations, we need to use the
// actual fields of the `QueryAssetRatesRequest` message, since
// otherwise it is much harder to match the calls nicely.
mock.Mock

// buyPrices is a map used internally by the oracle harness to store buy
// prices for certain assets. We use the asset specifier string as a
// unique identifier, since it will either contain an asset ID or a
// group key.
bidPrices map[string]rfqmath.BigIntFixedPoint
buyPrices map[string]rfqmath.BigIntFixedPoint

// askPrices is a map used internally by the oracle harness to store ask
// prices for certain assets. We use the asset specifier string as a
// unique identifier, since it will either contain an asset ID or a
// sellPrices is a map used internally by the oracle harness to store
// sell prices for certain assets. We use the asset specifier string as
// a unique identifier, since it will either contain an asset ID or a
// group key.
askPrices map[string]rfqmath.BigIntFixedPoint
sellPrices map[string]rfqmath.BigIntFixedPoint
}

// newOracleHarness returns a new oracle harness instance that is set to listen
// on the provided address.
func newOracleHarness(listenAddr string) *oracleHarness {
return &oracleHarness{
listenAddr: listenAddr,
bidPrices: make(map[string]rfqmath.BigIntFixedPoint),
askPrices: make(map[string]rfqmath.BigIntFixedPoint),
buyPrices: make(map[string]rfqmath.BigIntFixedPoint),
sellPrices: make(map[string]rfqmath.BigIntFixedPoint),
}
}

// setPrice sets the target bid and ask price for the provided specifier.
func (o *oracleHarness) setPrice(specifier asset.Specifier, bidPrice,
askPrice rfqmath.BigIntFixedPoint) {
// setPrice sets the target buy and sell price for the provided specifier.
func (o *oracleHarness) setPrice(specifier asset.Specifier, buyPrice,
sellPrice rfqmath.BigIntFixedPoint) {

o.bidPrices[specifier.String()] = bidPrice
o.askPrices[specifier.String()] = askPrice
o.buyPrices[specifier.String()] = buyPrice
o.sellPrices[specifier.String()] = sellPrice
}

// start runs the oracle harness.
Expand Down Expand Up @@ -113,14 +123,14 @@ func (o *oracleHarness) getAssetRates(specifier asset.Specifier,
// Determine the rate based on the transaction type.
var subjectAssetRate rfqmath.BigIntFixedPoint
if transactionType == oraclerpc.TransactionType_PURCHASE {
rate, ok := o.bidPrices[specifier.String()]
rate, ok := o.buyPrices[specifier.String()]
if !ok {
return oraclerpc.AssetRates{}, fmt.Errorf("purchase "+
"price not found for %s", specifier.String())
}
subjectAssetRate = rate
} else {
rate, ok := o.askPrices[specifier.String()]
rate, ok := o.sellPrices[specifier.String()]
if !ok {
return oraclerpc.AssetRates{}, fmt.Errorf("sale "+
"price not found for %s", specifier.String())
Expand Down Expand Up @@ -181,6 +191,18 @@ func (o *oracleHarness) QueryAssetRates(_ context.Context,
req *oraclerpc.QueryAssetRatesRequest) (
*oraclerpc.QueryAssetRatesResponse, error) {

// Return early with the mocked value if call expectations are set up.
if hasExpectedCall(o.ExpectedCalls, "QueryAssetRates") {
args := o.Called(
req.TransactionType, req.SubjectAsset,
req.SubjectAssetMaxAmount, req.PaymentAsset,
req.PaymentAssetMaxAmount, req.AssetRatesHint,
req.Intent, req.CounterpartyId, req.Metadata,
)
resp, _ := args.Get(0).(*oraclerpc.QueryAssetRatesResponse)
return resp, args.Error(1)
}

// Ensure that the payment asset is BTC. We only support BTC as the
// payment asset in this example.
if !rpcutils.IsAssetBtc(req.PaymentAsset) {
Expand All @@ -203,8 +225,8 @@ func (o *oracleHarness) QueryAssetRates(_ context.Context,
return nil, fmt.Errorf("error parsing subject asset: %w", err)
}

_, hasPurchase := o.bidPrices[specifier.String()]
_, hasSale := o.askPrices[specifier.String()]
_, hasPurchase := o.buyPrices[specifier.String()]
_, hasSale := o.sellPrices[specifier.String()]

log.Infof("Have for %s, purchase=%v, sale=%v", specifier.String(),
hasPurchase, hasSale)
Expand Down Expand Up @@ -324,3 +346,11 @@ func generateSelfSignedCert() (tls.Certificate, error) {

return tlsCert, nil
}

// hasExpectedCall checks if the method call has been registered as an expected
// call with the mock object.
func hasExpectedCall(expectedCalls []*mock.Call, method string) bool {
return slices.ContainsFunc(expectedCalls, func(call *mock.Call) bool {
return call.Method == method
})
}
Loading