Skip to content

Allow setting sats/msats to taprpc.AddInvoice #1448

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 7 commits into from
Apr 24, 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
34 changes: 34 additions & 0 deletions rfq/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions rfqmath/fixed_point.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
43 changes: 43 additions & 0 deletions rfqmath/fixed_point_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
188 changes: 162 additions & 26 deletions rpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions taprpc/tapchannelrpc/tapchannel.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading