diff --git a/rfq/manager.go b/rfq/manager.go index 73fd336f2..2b67a0bbb 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -15,6 +15,7 @@ import ( "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightninglabs/taproot-assets/rfqmsg" lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnutils" @@ -1012,6 +1013,12 @@ func (m *Manager) AssetMatchesSpecifier(ctx context.Context, } } +// GetPriceDeviationPpm returns the configured price deviation in ppm that is +// used in rfq negotiations. +func (m *Manager) GetPriceDeviationPpm() uint64 { + return m.cfg.AcceptPriceDeviationPpm +} + // ChannelCompatible checks a channel's assets against an asset specifier. If // the specifier is an asset ID, then all assets must be of that specific ID, // if the specifier is a group key, then all assets in the channel must belong @@ -1056,6 +1063,33 @@ func (m *Manager) publishSubscriberEvent(event fn.Event) { ) } +// EstimateAssetUnits is a helper function that queries our price oracle to find +// out how many units of an asset are needed to evaluate to the provided amount +// in milli satoshi. +func EstimateAssetUnits(ctx context.Context, oracle PriceOracle, + specifier asset.Specifier, + amtMsat lnwire.MilliSatoshi) (uint64, error) { + + oracleRes, err := oracle.QueryBidPrice( + ctx, specifier, fn.None[uint64](), fn.Some(amtMsat), + fn.None[rfqmsg.AssetRate](), + ) + if err != nil { + return 0, err + } + + if oracleRes.Err != nil { + return 0, fmt.Errorf("cannot query oracle: %v", + oracleRes.Err.Error()) + } + + assetUnits := rfqmath.MilliSatoshiToUnits( + amtMsat, oracleRes.AssetRate.Rate, + ) + + return assetUnits.ScaleTo(0).ToUint64(), nil +} + // PeerAcceptedBuyQuoteEvent is an event that is broadcast when the RFQ manager // receives an accept quote message from a peer. This is a quote which was // requested by our node and has been accepted by a peer. diff --git a/rfqmath/fixed_point.go b/rfqmath/fixed_point.go index 18d018712..9ca4af886 100644 --- a/rfqmath/fixed_point.go +++ b/rfqmath/fixed_point.go @@ -186,6 +186,23 @@ func (f FixedPoint[T]) WithinTolerance( return result, nil } +// AddTolerance applies the given tolerance expressed in parts per million (ppm) +// to the provided amount. +func AddTolerance(value, tolerancePpm BigInt) BigInt { + // A placeholder variable for ppm value denominator (1 million). + ppmBase := NewBigIntFromUint64(1_000_000) + + // Convert the tolerancePpm value to the actual units that express this + // margin. + toleranceUnits := value.Mul(tolerancePpm).Div(ppmBase) + + res := value.Add(toleranceUnits) + + // We now add the tolerance margin to the original value and return the + // result. + return res +} + // FixedPointFromUint64 creates a new FixedPoint from the given integer and // scale. Note that the input here should be *unscaled*. func FixedPointFromUint64[N Int[N]](value uint64, scale uint8) FixedPoint[N] { diff --git a/rfqmath/fixed_point_test.go b/rfqmath/fixed_point_test.go index 64d8f1495..5b8b76f30 100644 --- a/rfqmath/fixed_point_test.go +++ b/rfqmath/fixed_point_test.go @@ -396,6 +396,44 @@ func testWithinToleranceZeroTolerance(t *rapid.T) { require.True(t, result) } +// testAddToleranceProp is a property-based test which tests that the +// AddTolerance helper correctly applies the provided tolerance margin to any +// given value. +func testAddToleranceProp(t *rapid.T) { + value := NewBigIntFromUint64(rapid.Uint64Min(1).Draw(t, "value")) + tolerancePpm := NewBigIntFromUint64( + rapid.Uint64Range(0, 1_000_000).Draw(t, "tolerance_ppm"), + ) + + result := AddTolerance(value, tolerancePpm) + + if tolerancePpm.ToUint64() == 0 { + require.True(t, result.Equals(value)) + return + } + + // First off, let's just check that the result is at all greater than + // the input. + require.True(t, result.Gte(value)) + + // Let's now convert the values to a fixed point type in order to use + // the WithinTolerance method. + valueFixed := BigIntFixedPoint{ + Coefficient: value, + Scale: 0, + } + resultFixed := BigIntFixedPoint{ + Coefficient: result, + Scale: 0, + } + + // The value with the applied tolerance and the original value should be + // within tolerance. + res, err := resultFixed.WithinTolerance(valueFixed, tolerancePpm) + require.NoError(t, err) + require.True(t, res) +} + // testWithinToleranceSymmetric is a property-based test which ensures that the // WithinTolerance method is symmetric (swapping the order of the fixed-point // values does not change the result). @@ -600,6 +638,11 @@ func testWithinTolerance(t *testing.T) { "within_tolerance_float_reproduce", rapid.MakeCheck(testWithinToleranceFloatReproduce), ) + + t.Run( + "add_tolerance_property", + rapid.MakeCheck(testAddToleranceProp), + ) } // TestFixedPoint runs a series of property-based tests on the FixedPoint type diff --git a/rpcserver.go b/rpcserver.go index 9ad146d67..24624809b 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -7746,11 +7746,24 @@ func (r *rpcServer) AddInvoice(ctx context.Context, time.Duration(expirySeconds) * time.Second, ) + // We now want to calculate the upper bound of the RFQ order, which + // either is the asset amount specified by the user, or the converted + // satoshi amount of the invoice, expressed in asset units, using the + // local price oracle's conversion rate. + maxUnits, err := calculateAssetMaxAmount( + ctx, r.cfg.PriceOracle, specifier, req.AssetAmount, iReq, + r.cfg.RfqManager.GetPriceDeviationPpm(), + ) + if err != nil { + return nil, fmt.Errorf("error calculating asset max "+ + "amount: %w", err) + } + rpcSpecifier := marshalAssetSpecifier(specifier) resp, err := r.AddAssetBuyOrder(ctx, &rfqrpc.AddAssetBuyOrderRequest{ AssetSpecifier: &rpcSpecifier, - AssetMaxAmt: req.AssetAmount, + AssetMaxAmt: maxUnits, Expiry: uint64(expiryTimestamp.Unix()), PeerPubKey: peerPubKey[:], TimeoutSeconds: uint32( @@ -7780,35 +7793,17 @@ func (r *rpcServer) AddInvoice(ctx context.Context, return nil, fmt.Errorf("unexpected response type: %T", r) } - // If the invoice is for an asset unit amount smaller than the minimal - // transportable amount, we'll return an error, as it wouldn't be - // payable by the network. - if acceptedQuote.MinTransportableUnits > req.AssetAmount { - return nil, fmt.Errorf("cannot create invoice over %d asset "+ - "units, as the minimal transportable amount is %d "+ - "units with the current rate of %v units/BTC", - req.AssetAmount, acceptedQuote.MinTransportableUnits, - acceptedQuote.AskAssetRate) - } - - // Now that we have the accepted quote, we know the amount in Satoshi - // that we need to pay. We can now update the invoice with this amount. - // - // First, un-marshall the ask asset rate from the accepted quote. - askAssetRate, err := rfqrpc.UnmarshalFixedPoint( - acceptedQuote.AskAssetRate, + // Now that we have the accepted quote, we know the amount in (milli) + // Satoshi that we need to pay. We can now update the invoice with this + // amount. + invoiceAmtMsat, err := validateInvoiceAmount( + acceptedQuote, req.AssetAmount, iReq, ) if err != nil { - return nil, fmt.Errorf("error unmarshalling ask asset rate: %w", + return nil, fmt.Errorf("error validating invoice amount: %w", err) } - - // Convert the asset amount into a fixed-point. - assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0) - - // Calculate the invoice amount in msat. - valMsat := rfqmath.UnitsToMilliSatoshi(assetAmount, *askAssetRate) - iReq.ValueMsat = int64(valMsat) + iReq.ValueMsat = int64(invoiceAmtMsat) // The last step is to create a hop hint that includes the fake SCID of // the quote, alongside the channel's routing policy. We need to choose @@ -7911,6 +7906,147 @@ func (r *rpcServer) AddInvoice(ctx context.Context, }, nil } +// calculateAssetMaxAmount calculates the max units to be placed in the invoice +// RFQ quote order. When adding invoices based on asset units, that value is +// directly returned. If using the value/value_msat fields of the invoice then +// a price oracle query will take place to calculate the max units of the quote. +func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle, + specifier asset.Specifier, requestAssetAmount uint64, + inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) { + + // Let's unmarshall the satoshi related fields to see if an amount was + // set based on those. + amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat) + if err != nil { + return 0, err + } + + // Let's make sure that only one type of amount is set, in order to + // avoid ambiguous behavior. This field dictates the actual value of the + // invoice so let's be strict and only allow one possible value to be + // set. + if requestAssetAmount > 0 && amtMsat != 0 { + return 0, fmt.Errorf("cannot set both asset amount and sats " + + "amount") + } + + // If the invoice is being added based on asset units, there's nothing + // to do so return the amount directly. + if amtMsat == 0 { + return requestAssetAmount, nil + } + + // If the invoice defines the desired amount in satoshis, we need to + // query our oracle first to get an estimation on the asset rate. This + // will help us establish a quote with the correct amount of asset + // units. + maxUnits, err := rfq.EstimateAssetUnits( + ctx, priceOracle, specifier, amtMsat, + ) + if err != nil { + return 0, err + } + + maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits) + + // Since we used a different oracle price query above calculate the max + // amount of units, we want to add some breathing room to account for + // price fluctuations caused by the small-time delay, plus the fact that + // the agreed upon quote may be different. If we don't add this safety + // window the peer may allow a routable amount that evaluates to less + // than what we ask for. + // Apply the tolerance margin twice. Once due to the ask/bid price + // deviation that may occur during rfq negotiation, and once for the + // price movement that may occur between querying the oracle and + // acquiring the quote. We don't really care about this margin being too + // big, this only affects the max units our peer agrees to route. + tolerance := rfqmath.NewBigIntFromUint64(deviationPPM) + + maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance) + maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance) + + return maxMathUnits.ToUint64(), nil +} + +// validateInvoiceAmount validates the quote against the invoice we're trying to +// add. It returns the value in msat that should be included in the invoice. +func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote, + requestAssetAmount uint64, inv *lnrpc.Invoice) (lnwire.MilliSatoshi, + error) { + + invoiceAmtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat) + if err != nil { + return 0, err + } + + // Now that we have the accepted quote, we know the amount in Satoshi + // that we need to pay. We can now update the invoice with this amount. + // + // First, un-marshall the ask asset rate from the accepted quote. + askAssetRate, err := rfqrpc.UnmarshalFixedPoint( + acceptedQuote.AskAssetRate, + ) + if err != nil { + return 0, fmt.Errorf("error unmarshalling ask asset rate: %w", + err) + } + + // We either have a requested amount in milli satoshi that we want to + // validate against the quote's max amount (in which case we overwrite + // the invoiceUnits), or we have a requested amount in asset units that + // we want to convert into milli satoshis (and overwrite + // newInvoiceAmtMsat). + var ( + newInvoiceAmtMsat = invoiceAmtMsat + invoiceUnits = requestAssetAmount + ) + switch { + case invoiceAmtMsat != 0: + // If the invoice was created with a satoshi amount, we need to + // calculate the units. + invoiceUnits = rfqmath.MilliSatoshiToUnits( + invoiceAmtMsat, *askAssetRate, + ).ScaleTo(0).ToUint64() + + // Now let's see if the negotiated quote can actually route the + // amount we need in msat. + maxFixedUnits := rfqmath.NewBigIntFixedPoint( + acceptedQuote.AssetMaxAmount, 0, + ) + maxRoutableMsat := rfqmath.UnitsToMilliSatoshi( + maxFixedUnits, *askAssetRate, + ) + + if maxRoutableMsat <= invoiceAmtMsat { + return 0, fmt.Errorf("cannot create invoice for %v "+ + "msat, max routable amount is %v msat", + invoiceAmtMsat, maxRoutableMsat) + } + + default: + // Convert the asset amount into a fixed-point. + assetAmount := rfqmath.NewBigIntFixedPoint(invoiceUnits, 0) + + // Calculate the invoice amount in msat. + newInvoiceAmtMsat = rfqmath.UnitsToMilliSatoshi( + assetAmount, *askAssetRate, + ) + } + + // If the invoice is for an asset unit amount smaller than the minimal + // transportable amount, we'll return an error, as it wouldn't be + // payable by the network. + if acceptedQuote.MinTransportableUnits > invoiceUnits { + return 0, fmt.Errorf("cannot create invoice for %d asset "+ + "units, as the minimal transportable amount is %d "+ + "units with the current rate of %v units/BTC", + invoiceUnits, acceptedQuote.MinTransportableUnits, + acceptedQuote.AskAssetRate) + } + + return newInvoiceAmtMsat, nil +} + // DeclareScriptKey declares a new script key to the wallet. This is useful // when the script key contains scripts, which would mean it wouldn't be // recognized by the wallet automatically. Declaring a script key will make any diff --git a/taprpc/tapchannelrpc/tapchannel.pb.go b/taprpc/tapchannelrpc/tapchannel.pb.go index 4c0d2450e..809eec8f2 100644 --- a/taprpc/tapchannelrpc/tapchannel.pb.go +++ b/taprpc/tapchannelrpc/tapchannel.pb.go @@ -630,13 +630,19 @@ type AddInvoiceRequest struct { // assets and converting them from satoshis. This must be specified if // there are multiple channels with the given asset ID. PeerPubkey []byte `protobuf:"bytes,3,opt,name=peer_pubkey,json=peerPubkey,proto3" json:"peer_pubkey,omitempty"` - // The full lnd invoice request to send. All fields (except for the value - // and the route hints) behave the same way as they do for lnd's - // lnrpc.AddInvoice RPC method (see the API docs at + // The full lnd invoice request to send. All fields behave the same way as + // they do for lnd's lnrpc.AddInvoice RPC method (see the API docs at // https://lightning.engineering/api-docs/api/lnd/lightning/add-invoice - // for more details). The value/value_msat fields will be overwritten by the - // satoshi (or milli-satoshi) equivalent of the asset amount, after - // negotiating a quote with a peer that supports the given asset ID. + // for more details). + // + // Only one of the asset_amount/value/value_msat may be set to dictate the + // value of the invoice. When using asset_amount, the value/value_msat + // fields will be overwritten by the satoshi (or milli-satoshi) equivalent + // of the asset amount, after negotiating a quote with a peer that supports + // the given asset ID. + // + // If the value/value_msat are used, we still receive assets, but they will + // exactly evaluate to the defined amount in sats/msats. InvoiceRequest *lnrpc.Invoice `protobuf:"bytes,4,opt,name=invoice_request,json=invoiceRequest,proto3" json:"invoice_request,omitempty"` // If set, then this will make the invoice created a hodl invoice, which // won't be settled automatically. Instead, users will need to use the diff --git a/taprpc/tapchannelrpc/tapchannel.proto b/taprpc/tapchannelrpc/tapchannel.proto index 9c6f55e79..210488059 100644 --- a/taprpc/tapchannelrpc/tapchannel.proto +++ b/taprpc/tapchannelrpc/tapchannel.proto @@ -184,13 +184,19 @@ message AddInvoiceRequest { // there are multiple channels with the given asset ID. bytes peer_pubkey = 3; - // The full lnd invoice request to send. All fields (except for the value - // and the route hints) behave the same way as they do for lnd's - // lnrpc.AddInvoice RPC method (see the API docs at + // The full lnd invoice request to send. All fields behave the same way as + // they do for lnd's lnrpc.AddInvoice RPC method (see the API docs at // https://lightning.engineering/api-docs/api/lnd/lightning/add-invoice - // for more details). The value/value_msat fields will be overwritten by the - // satoshi (or milli-satoshi) equivalent of the asset amount, after - // negotiating a quote with a peer that supports the given asset ID. + // for more details). + // + // Only one of the asset_amount/value/value_msat may be set to dictate the + // value of the invoice. When using asset_amount, the value/value_msat + // fields will be overwritten by the satoshi (or milli-satoshi) equivalent + // of the asset amount, after negotiating a quote with a peer that supports + // the given asset ID. + // + // If the value/value_msat are used, we still receive assets, but they will + // exactly evaluate to the defined amount in sats/msats. lnrpc.Invoice invoice_request = 4; // If set, then this will make the invoice created a hodl invoice, which diff --git a/taprpc/tapchannelrpc/tapchannel.swagger.json b/taprpc/tapchannelrpc/tapchannel.swagger.json index e61bfd2ed..405385e01 100644 --- a/taprpc/tapchannelrpc/tapchannel.swagger.json +++ b/taprpc/tapchannelrpc/tapchannel.swagger.json @@ -1507,7 +1507,7 @@ }, "invoice_request": { "$ref": "#/definitions/lnrpcInvoice", - "description": "The full lnd invoice request to send. All fields (except for the value\nand the route hints) behave the same way as they do for lnd's\nlnrpc.AddInvoice RPC method (see the API docs at\nhttps://lightning.engineering/api-docs/api/lnd/lightning/add-invoice\nfor more details). The value/value_msat fields will be overwritten by the\nsatoshi (or milli-satoshi) equivalent of the asset amount, after\nnegotiating a quote with a peer that supports the given asset ID." + "description": "The full lnd invoice request to send. All fields behave the same way as\nthey do for lnd's lnrpc.AddInvoice RPC method (see the API docs at\nhttps://lightning.engineering/api-docs/api/lnd/lightning/add-invoice\nfor more details).\n\nOnly one of the asset_amount/value/value_msat may be set to dictate the\nvalue of the invoice. When using asset_amount, the value/value_msat\nfields will be overwritten by the satoshi (or milli-satoshi) equivalent\nof the asset amount, after negotiating a quote with a peer that supports\nthe given asset ID.\n\nIf the value/value_msat are used, we still receive assets, but they will\nexactly evaluate to the defined amount in sats/msats." }, "hodl_invoice": { "$ref": "#/definitions/tapchannelrpcHodlInvoice",