Skip to content

Commit 25ff294

Browse files
authored
Merge pull request #1448 from lightninglabs/addinvoice-sats-amt
Allow setting sats/msats to `taprpc.AddInvoice`
2 parents 1d32c37 + cb13676 commit 25ff294

File tree

7 files changed

+281
-39
lines changed

7 files changed

+281
-39
lines changed

rfq/manager.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/lightninglabs/taproot-assets/address"
1616
"github.com/lightninglabs/taproot-assets/asset"
1717
"github.com/lightninglabs/taproot-assets/fn"
18+
"github.com/lightninglabs/taproot-assets/rfqmath"
1819
"github.com/lightninglabs/taproot-assets/rfqmsg"
1920
lfn "github.com/lightningnetwork/lnd/fn/v2"
2021
"github.com/lightningnetwork/lnd/lnutils"
@@ -1012,6 +1013,12 @@ func (m *Manager) AssetMatchesSpecifier(ctx context.Context,
10121013
}
10131014
}
10141015

1016+
// GetPriceDeviationPpm returns the configured price deviation in ppm that is
1017+
// used in rfq negotiations.
1018+
func (m *Manager) GetPriceDeviationPpm() uint64 {
1019+
return m.cfg.AcceptPriceDeviationPpm
1020+
}
1021+
10151022
// ChannelCompatible checks a channel's assets against an asset specifier. If
10161023
// the specifier is an asset ID, then all assets must be of that specific ID,
10171024
// 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) {
10561063
)
10571064
}
10581065

1066+
// EstimateAssetUnits is a helper function that queries our price oracle to find
1067+
// out how many units of an asset are needed to evaluate to the provided amount
1068+
// in milli satoshi.
1069+
func EstimateAssetUnits(ctx context.Context, oracle PriceOracle,
1070+
specifier asset.Specifier,
1071+
amtMsat lnwire.MilliSatoshi) (uint64, error) {
1072+
1073+
oracleRes, err := oracle.QueryBidPrice(
1074+
ctx, specifier, fn.None[uint64](), fn.Some(amtMsat),
1075+
fn.None[rfqmsg.AssetRate](),
1076+
)
1077+
if err != nil {
1078+
return 0, err
1079+
}
1080+
1081+
if oracleRes.Err != nil {
1082+
return 0, fmt.Errorf("cannot query oracle: %v",
1083+
oracleRes.Err.Error())
1084+
}
1085+
1086+
assetUnits := rfqmath.MilliSatoshiToUnits(
1087+
amtMsat, oracleRes.AssetRate.Rate,
1088+
)
1089+
1090+
return assetUnits.ScaleTo(0).ToUint64(), nil
1091+
}
1092+
10591093
// PeerAcceptedBuyQuoteEvent is an event that is broadcast when the RFQ manager
10601094
// receives an accept quote message from a peer. This is a quote which was
10611095
// requested by our node and has been accepted by a peer.

rfqmath/fixed_point.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,23 @@ func (f FixedPoint[T]) WithinTolerance(
186186
return result, nil
187187
}
188188

189+
// AddTolerance applies the given tolerance expressed in parts per million (ppm)
190+
// to the provided amount.
191+
func AddTolerance(value, tolerancePpm BigInt) BigInt {
192+
// A placeholder variable for ppm value denominator (1 million).
193+
ppmBase := NewBigIntFromUint64(1_000_000)
194+
195+
// Convert the tolerancePpm value to the actual units that express this
196+
// margin.
197+
toleranceUnits := value.Mul(tolerancePpm).Div(ppmBase)
198+
199+
res := value.Add(toleranceUnits)
200+
201+
// We now add the tolerance margin to the original value and return the
202+
// result.
203+
return res
204+
}
205+
189206
// FixedPointFromUint64 creates a new FixedPoint from the given integer and
190207
// scale. Note that the input here should be *unscaled*.
191208
func FixedPointFromUint64[N Int[N]](value uint64, scale uint8) FixedPoint[N] {

rfqmath/fixed_point_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,44 @@ func testWithinToleranceZeroTolerance(t *rapid.T) {
396396
require.True(t, result)
397397
}
398398

399+
// testAddToleranceProp is a property-based test which tests that the
400+
// AddTolerance helper correctly applies the provided tolerance margin to any
401+
// given value.
402+
func testAddToleranceProp(t *rapid.T) {
403+
value := NewBigIntFromUint64(rapid.Uint64Min(1).Draw(t, "value"))
404+
tolerancePpm := NewBigIntFromUint64(
405+
rapid.Uint64Range(0, 1_000_000).Draw(t, "tolerance_ppm"),
406+
)
407+
408+
result := AddTolerance(value, tolerancePpm)
409+
410+
if tolerancePpm.ToUint64() == 0 {
411+
require.True(t, result.Equals(value))
412+
return
413+
}
414+
415+
// First off, let's just check that the result is at all greater than
416+
// the input.
417+
require.True(t, result.Gte(value))
418+
419+
// Let's now convert the values to a fixed point type in order to use
420+
// the WithinTolerance method.
421+
valueFixed := BigIntFixedPoint{
422+
Coefficient: value,
423+
Scale: 0,
424+
}
425+
resultFixed := BigIntFixedPoint{
426+
Coefficient: result,
427+
Scale: 0,
428+
}
429+
430+
// The value with the applied tolerance and the original value should be
431+
// within tolerance.
432+
res, err := resultFixed.WithinTolerance(valueFixed, tolerancePpm)
433+
require.NoError(t, err)
434+
require.True(t, res)
435+
}
436+
399437
// testWithinToleranceSymmetric is a property-based test which ensures that the
400438
// WithinTolerance method is symmetric (swapping the order of the fixed-point
401439
// values does not change the result).
@@ -600,6 +638,11 @@ func testWithinTolerance(t *testing.T) {
600638
"within_tolerance_float_reproduce",
601639
rapid.MakeCheck(testWithinToleranceFloatReproduce),
602640
)
641+
642+
t.Run(
643+
"add_tolerance_property",
644+
rapid.MakeCheck(testAddToleranceProp),
645+
)
603646
}
604647

605648
// TestFixedPoint runs a series of property-based tests on the FixedPoint type

rpcserver.go

Lines changed: 162 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7819,11 +7819,24 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
78197819
time.Duration(expirySeconds) * time.Second,
78207820
)
78217821

7822+
// We now want to calculate the upper bound of the RFQ order, which
7823+
// either is the asset amount specified by the user, or the converted
7824+
// satoshi amount of the invoice, expressed in asset units, using the
7825+
// local price oracle's conversion rate.
7826+
maxUnits, err := calculateAssetMaxAmount(
7827+
ctx, r.cfg.PriceOracle, specifier, req.AssetAmount, iReq,
7828+
r.cfg.RfqManager.GetPriceDeviationPpm(),
7829+
)
7830+
if err != nil {
7831+
return nil, fmt.Errorf("error calculating asset max "+
7832+
"amount: %w", err)
7833+
}
7834+
78227835
rpcSpecifier := marshalAssetSpecifier(specifier)
78237836

78247837
resp, err := r.AddAssetBuyOrder(ctx, &rfqrpc.AddAssetBuyOrderRequest{
78257838
AssetSpecifier: &rpcSpecifier,
7826-
AssetMaxAmt: req.AssetAmount,
7839+
AssetMaxAmt: maxUnits,
78277840
Expiry: uint64(expiryTimestamp.Unix()),
78287841
PeerPubKey: peerPubKey[:],
78297842
TimeoutSeconds: uint32(
@@ -7853,35 +7866,17 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
78537866
return nil, fmt.Errorf("unexpected response type: %T", r)
78547867
}
78557868

7856-
// If the invoice is for an asset unit amount smaller than the minimal
7857-
// transportable amount, we'll return an error, as it wouldn't be
7858-
// payable by the network.
7859-
if acceptedQuote.MinTransportableUnits > req.AssetAmount {
7860-
return nil, fmt.Errorf("cannot create invoice over %d asset "+
7861-
"units, as the minimal transportable amount is %d "+
7862-
"units with the current rate of %v units/BTC",
7863-
req.AssetAmount, acceptedQuote.MinTransportableUnits,
7864-
acceptedQuote.AskAssetRate)
7865-
}
7866-
7867-
// Now that we have the accepted quote, we know the amount in Satoshi
7868-
// that we need to pay. We can now update the invoice with this amount.
7869-
//
7870-
// First, un-marshall the ask asset rate from the accepted quote.
7871-
askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
7872-
acceptedQuote.AskAssetRate,
7869+
// Now that we have the accepted quote, we know the amount in (milli)
7870+
// Satoshi that we need to pay. We can now update the invoice with this
7871+
// amount.
7872+
invoiceAmtMsat, err := validateInvoiceAmount(
7873+
acceptedQuote, req.AssetAmount, iReq,
78737874
)
78747875
if err != nil {
7875-
return nil, fmt.Errorf("error unmarshalling ask asset rate: %w",
7876+
return nil, fmt.Errorf("error validating invoice amount: %w",
78767877
err)
78777878
}
7878-
7879-
// Convert the asset amount into a fixed-point.
7880-
assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
7881-
7882-
// Calculate the invoice amount in msat.
7883-
valMsat := rfqmath.UnitsToMilliSatoshi(assetAmount, *askAssetRate)
7884-
iReq.ValueMsat = int64(valMsat)
7879+
iReq.ValueMsat = int64(invoiceAmtMsat)
78857880

78867881
// The last step is to create a hop hint that includes the fake SCID of
78877882
// the quote, alongside the channel's routing policy. We need to choose
@@ -7984,6 +7979,147 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
79847979
}, nil
79857980
}
79867981

7982+
// calculateAssetMaxAmount calculates the max units to be placed in the invoice
7983+
// RFQ quote order. When adding invoices based on asset units, that value is
7984+
// directly returned. If using the value/value_msat fields of the invoice then
7985+
// a price oracle query will take place to calculate the max units of the quote.
7986+
func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
7987+
specifier asset.Specifier, requestAssetAmount uint64,
7988+
inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
7989+
7990+
// Let's unmarshall the satoshi related fields to see if an amount was
7991+
// set based on those.
7992+
amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
7993+
if err != nil {
7994+
return 0, err
7995+
}
7996+
7997+
// Let's make sure that only one type of amount is set, in order to
7998+
// avoid ambiguous behavior. This field dictates the actual value of the
7999+
// invoice so let's be strict and only allow one possible value to be
8000+
// set.
8001+
if requestAssetAmount > 0 && amtMsat != 0 {
8002+
return 0, fmt.Errorf("cannot set both asset amount and sats " +
8003+
"amount")
8004+
}
8005+
8006+
// If the invoice is being added based on asset units, there's nothing
8007+
// to do so return the amount directly.
8008+
if amtMsat == 0 {
8009+
return requestAssetAmount, nil
8010+
}
8011+
8012+
// If the invoice defines the desired amount in satoshis, we need to
8013+
// query our oracle first to get an estimation on the asset rate. This
8014+
// will help us establish a quote with the correct amount of asset
8015+
// units.
8016+
maxUnits, err := rfq.EstimateAssetUnits(
8017+
ctx, priceOracle, specifier, amtMsat,
8018+
)
8019+
if err != nil {
8020+
return 0, err
8021+
}
8022+
8023+
maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
8024+
8025+
// Since we used a different oracle price query above calculate the max
8026+
// amount of units, we want to add some breathing room to account for
8027+
// price fluctuations caused by the small-time delay, plus the fact that
8028+
// the agreed upon quote may be different. If we don't add this safety
8029+
// window the peer may allow a routable amount that evaluates to less
8030+
// than what we ask for.
8031+
// Apply the tolerance margin twice. Once due to the ask/bid price
8032+
// deviation that may occur during rfq negotiation, and once for the
8033+
// price movement that may occur between querying the oracle and
8034+
// acquiring the quote. We don't really care about this margin being too
8035+
// big, this only affects the max units our peer agrees to route.
8036+
tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
8037+
8038+
maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
8039+
maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
8040+
8041+
return maxMathUnits.ToUint64(), nil
8042+
}
8043+
8044+
// validateInvoiceAmount validates the quote against the invoice we're trying to
8045+
// add. It returns the value in msat that should be included in the invoice.
8046+
func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
8047+
requestAssetAmount uint64, inv *lnrpc.Invoice) (lnwire.MilliSatoshi,
8048+
error) {
8049+
8050+
invoiceAmtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
8051+
if err != nil {
8052+
return 0, err
8053+
}
8054+
8055+
// Now that we have the accepted quote, we know the amount in Satoshi
8056+
// that we need to pay. We can now update the invoice with this amount.
8057+
//
8058+
// First, un-marshall the ask asset rate from the accepted quote.
8059+
askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
8060+
acceptedQuote.AskAssetRate,
8061+
)
8062+
if err != nil {
8063+
return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
8064+
err)
8065+
}
8066+
8067+
// We either have a requested amount in milli satoshi that we want to
8068+
// validate against the quote's max amount (in which case we overwrite
8069+
// the invoiceUnits), or we have a requested amount in asset units that
8070+
// we want to convert into milli satoshis (and overwrite
8071+
// newInvoiceAmtMsat).
8072+
var (
8073+
newInvoiceAmtMsat = invoiceAmtMsat
8074+
invoiceUnits = requestAssetAmount
8075+
)
8076+
switch {
8077+
case invoiceAmtMsat != 0:
8078+
// If the invoice was created with a satoshi amount, we need to
8079+
// calculate the units.
8080+
invoiceUnits = rfqmath.MilliSatoshiToUnits(
8081+
invoiceAmtMsat, *askAssetRate,
8082+
).ScaleTo(0).ToUint64()
8083+
8084+
// Now let's see if the negotiated quote can actually route the
8085+
// amount we need in msat.
8086+
maxFixedUnits := rfqmath.NewBigIntFixedPoint(
8087+
acceptedQuote.AssetMaxAmount, 0,
8088+
)
8089+
maxRoutableMsat := rfqmath.UnitsToMilliSatoshi(
8090+
maxFixedUnits, *askAssetRate,
8091+
)
8092+
8093+
if maxRoutableMsat <= invoiceAmtMsat {
8094+
return 0, fmt.Errorf("cannot create invoice for %v "+
8095+
"msat, max routable amount is %v msat",
8096+
invoiceAmtMsat, maxRoutableMsat)
8097+
}
8098+
8099+
default:
8100+
// Convert the asset amount into a fixed-point.
8101+
assetAmount := rfqmath.NewBigIntFixedPoint(invoiceUnits, 0)
8102+
8103+
// Calculate the invoice amount in msat.
8104+
newInvoiceAmtMsat = rfqmath.UnitsToMilliSatoshi(
8105+
assetAmount, *askAssetRate,
8106+
)
8107+
}
8108+
8109+
// If the invoice is for an asset unit amount smaller than the minimal
8110+
// transportable amount, we'll return an error, as it wouldn't be
8111+
// payable by the network.
8112+
if acceptedQuote.MinTransportableUnits > invoiceUnits {
8113+
return 0, fmt.Errorf("cannot create invoice for %d asset "+
8114+
"units, as the minimal transportable amount is %d "+
8115+
"units with the current rate of %v units/BTC",
8116+
invoiceUnits, acceptedQuote.MinTransportableUnits,
8117+
acceptedQuote.AskAssetRate)
8118+
}
8119+
8120+
return newInvoiceAmtMsat, nil
8121+
}
8122+
79878123
// DeclareScriptKey declares a new script key to the wallet. This is useful
79888124
// when the script key contains scripts, which would mean it wouldn't be
79898125
// recognized by the wallet automatically. Declaring a script key will make any

taprpc/tapchannelrpc/tapchannel.pb.go

Lines changed: 12 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)