From 1baece1c3f6f94247ac798e3052d79b55fbab502 Mon Sep 17 00:00:00 2001 From: ffranr Date: Sun, 10 Nov 2024 14:30:17 +0000 Subject: [PATCH 01/15] rfq: replace asset ID and group key with asset specifier in SellOrder Refactor SellOrder by replacing the fields "asset ID" and "asset group key" with a single "asset specifier" field for simplicity and consistency. --- rfq/manager.go | 7 ++----- rfq/negotiator.go | 19 ++++--------------- rfqmsg/sell_request.go | 12 ++---------- rpcserver.go | 16 ++++++++++++---- 4 files changed, 20 insertions(+), 34 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index a185c3f02..956bd7db0 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -747,11 +747,8 @@ func (m *Manager) UpsertAssetBuyOrder(order BuyOrder) error { // SellOrder is a struct that represents an asset sell order. type SellOrder struct { - // AssetID is the ID of the asset to sell. - AssetID *asset.ID - - // AssetGroupKey is the public key of the asset group to sell. - AssetGroupKey *btcec.PublicKey + // AssetSpecifier is the asset that the seller is interested in. + AssetSpecifier asset.Specifier // PaymentMaxAmt is the maximum msat amount that the responding peer // must agree to pay. diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 5eaef48d8..4fd086235 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -472,21 +472,10 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { // skip this step. var assetRateHint fn.Option[rfqmsg.AssetRate] - // Construct an asset specifier from the order. - // TODO(ffranr): The order should have an asset specifier. - assetSpecifier, err := asset.NewSpecifier( - order.AssetID, order.AssetGroupKey, nil, - true, - ) - if err != nil { - log.Warnf("failed to construct asset "+ - "specifier from buy order: %v", err) - } - - if n.cfg.PriceOracle != nil && assetSpecifier.IsSome() { + if n.cfg.PriceOracle != nil && order.AssetSpecifier.IsSome() { // Query the price oracle for an asking price. assetRate, err := n.queryAskFromPriceOracle( - order.Peer, assetSpecifier, + order.Peer, order.AssetSpecifier, fn.None[uint64](), fn.Some(order.PaymentMaxAmt), fn.None[rfqmsg.AssetRate](), @@ -502,8 +491,8 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { } request, err := rfqmsg.NewSellRequest( - *order.Peer, order.AssetID, order.AssetGroupKey, - order.PaymentMaxAmt, assetRateHint, + *order.Peer, order.AssetSpecifier, order.PaymentMaxAmt, + assetRateHint, ) if err != nil { err := fmt.Errorf("unable to create sell request "+ diff --git a/rfqmsg/sell_request.go b/rfqmsg/sell_request.go index fa7bfc31c..b84693f05 100644 --- a/rfqmsg/sell_request.go +++ b/rfqmsg/sell_request.go @@ -45,8 +45,8 @@ type SellRequest struct { } // NewSellRequest creates a new asset sell quote request. -func NewSellRequest(peer route.Vertex, assetID *asset.ID, - assetGroupKey *btcec.PublicKey, paymentMaxAmt lnwire.MilliSatoshi, +func NewSellRequest(peer route.Vertex, assetSpecifier asset.Specifier, + paymentMaxAmt lnwire.MilliSatoshi, assetRateHint fn.Option[AssetRate]) (*SellRequest, error) { id, err := NewID() @@ -54,14 +54,6 @@ func NewSellRequest(peer route.Vertex, assetID *asset.ID, return nil, fmt.Errorf("unable to generate random id: %w", err) } - assetSpecifier, err := asset.NewSpecifier( - assetID, assetGroupKey, nil, true, - ) - if err != nil { - return nil, fmt.Errorf("unable to create asset specifier: %w", - err) - } - return &SellRequest{ Peer: peer, Version: latestSellRequestVersion, diff --git a/rpcserver.go b/rpcserver.go index 092b48bf9..31d0229b7 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6414,11 +6414,19 @@ func unmarshalAssetSellOrder( peer = &pv } + // Construct an asset specifier from the asset ID and/or group key. + assetSpecifier, err := asset.NewSpecifier( + assetId, assetGroupKey, nil, true, + ) + if err != nil { + return nil, fmt.Errorf("error creating asset specifier: %w", + err) + } + return &rfq.SellOrder{ - AssetID: assetId, - AssetGroupKey: assetGroupKey, - PaymentMaxAmt: lnwire.MilliSatoshi(req.PaymentMaxAmt), - Peer: peer, + AssetSpecifier: assetSpecifier, + PaymentMaxAmt: lnwire.MilliSatoshi(req.PaymentMaxAmt), + Peer: peer, }, nil } From 223b5ea63238a736cba043bc4a99a933f3945662 Mon Sep 17 00:00:00 2001 From: ffranr Date: Thu, 14 Nov 2024 22:30:54 +0000 Subject: [PATCH 02/15] rfq: make SellOrder.Peer field an Option type Currently, the `SellOrder.Peer` field must be specified, but in the future, the negotiator should be able to select the optimal peer automatically. This commit updates the interface to support this future functionality by making `SellOrder.Peer` an Option type. Note that this field was already a pointer. --- rfq/manager.go | 4 ++-- rfq/negotiator.go | 13 +++++++++++-- rpcserver.go | 17 +++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index 956bd7db0..f150e5e01 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -763,7 +763,7 @@ type SellOrder struct { // Peer is the peer that the buy order is intended for. This field is // optional. - Peer *route.Vertex + Peer fn.Option[route.Vertex] } // UpsertAssetSellOrder upserts an asset sell order for management. @@ -772,7 +772,7 @@ func (m *Manager) UpsertAssetSellOrder(order SellOrder) error { // // TODO(ffranr): Add support for peerless sell orders. The negotiator // should be able to determine the optimal peer. - if order.Peer == nil { + if order.Peer.IsNone() { return fmt.Errorf("sell order peer must be specified") } diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 4fd086235..669b633d3 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -467,6 +467,15 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { go func() { defer n.Wg.Done() + // Unwrap the peer from the order. For now, we can assume that + // the peer is always specified. + peer, err := order.Peer.UnwrapOrErr( + fmt.Errorf("buy order peer must be specified"), + ) + if err != nil { + n.cfg.ErrChan <- err + } + // We calculate a proposed ask price for our peer's // consideration. If a price oracle is not specified we will // skip this step. @@ -475,7 +484,7 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { if n.cfg.PriceOracle != nil && order.AssetSpecifier.IsSome() { // Query the price oracle for an asking price. assetRate, err := n.queryAskFromPriceOracle( - order.Peer, order.AssetSpecifier, + &peer, order.AssetSpecifier, fn.None[uint64](), fn.Some(order.PaymentMaxAmt), fn.None[rfqmsg.AssetRate](), @@ -491,7 +500,7 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { } request, err := rfqmsg.NewSellRequest( - *order.Peer, order.AssetSpecifier, order.PaymentMaxAmt, + peer, order.AssetSpecifier, order.PaymentMaxAmt, assetRateHint, ) if err != nil { diff --git a/rpcserver.go b/rpcserver.go index 31d0229b7..709937991 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6403,7 +6403,7 @@ func unmarshalAssetSellOrder( } // Unmarshal the peer if specified. - var peer *route.Vertex + var peer fn.Option[route.Vertex] if len(req.PeerPubKey) > 0 { pv, err := route.NewVertexFromBytes(req.PeerPubKey) if err != nil { @@ -6411,7 +6411,7 @@ func unmarshalAssetSellOrder( "route vertex: %w", err) } - peer = &pv + peer = fn.Some(pv) } // Construct an asset specifier from the asset ID and/or group key. @@ -6447,12 +6447,13 @@ func (r *rpcServer) AddAssetSellOrder(_ context.Context, err) } - var peer string - if sellOrder.Peer != nil { - peer = sellOrder.Peer.String() - } + // Extract peer identifier as a string for logging. + peerStr := fn.MapOptionZ(sellOrder.Peer, func(p route.Vertex) string { + return p.String() + }) + rpcsLog.Debugf("[AddAssetSellOrder]: upserting sell order "+ - "(dest_peer=%s)", peer) + "(dest_peer=%s)", peerStr) // Register an event listener before actually inserting the order, so we // definitely don't miss any responses. @@ -6494,7 +6495,7 @@ func (r *rpcServer) AddAssetSellOrder(_ context.Context, case <-timeout: return nil, fmt.Errorf("timeout waiting for response "+ - "from peer %x", sellOrder.Peer[:]) + "from peer %s", peerStr) } } } From 2f47d5ead8bc06f0d9785b5107f77f036a875fd8 Mon Sep 17 00:00:00 2001 From: ffranr Date: Thu, 14 Nov 2024 23:25:03 +0000 Subject: [PATCH 03/15] rfq: remove unused arg from methods query*FromPriceOracle This commit removes the `peer` argument from the methods queryBidFromPriceOracle and queryAskFromPriceOracle --- rfq/negotiator.go | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 669b633d3..d45cceae5 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -12,7 +12,6 @@ import ( "github.com/lightninglabs/taproot-assets/rfqmsg" "github.com/lightningnetwork/lnd/lnutils" "github.com/lightningnetwork/lnd/lnwire" - "github.com/lightningnetwork/lnd/routing/route" ) const ( @@ -105,8 +104,8 @@ func NewNegotiator(cfg NegotiatorCfg) (*Negotiator, error) { // queryBidFromPriceOracle queries the price oracle for a bid price. It returns // an appropriate outgoing response message which should be sent to the peer. -func (n *Negotiator) queryBidFromPriceOracle(peer route.Vertex, - assetSpecifier asset.Specifier, assetMaxAmt fn.Option[uint64], +func (n *Negotiator) queryBidFromPriceOracle(assetSpecifier asset.Specifier, + assetMaxAmt fn.Option[uint64], paymentMaxAmt fn.Option[lnwire.MilliSatoshi], assetRateHint fn.Option[rfqmsg.AssetRate]) (*rfqmsg.AssetRate, error) { @@ -178,7 +177,7 @@ func (n *Negotiator) HandleOutgoingBuyOrder(buyOrder BuyOrder) error { // Query the price oracle for a bid price. assetRate, err := n.queryBidFromPriceOracle( - peer, buyOrder.AssetSpecifier, + buyOrder.AssetSpecifier, fn.Some(buyOrder.AssetMaxAmt), fn.None[lnwire.MilliSatoshi](), fn.None[rfqmsg.AssetRate](), @@ -227,8 +226,8 @@ func (n *Negotiator) HandleOutgoingBuyOrder(buyOrder BuyOrder) error { // queryAskFromPriceOracle queries the price oracle for an asking price. It // returns an appropriate outgoing response message which should be sent to the // peer. -func (n *Negotiator) queryAskFromPriceOracle(peer *route.Vertex, - assetSpecifier asset.Specifier, assetMaxAmt fn.Option[uint64], +func (n *Negotiator) queryAskFromPriceOracle(assetSpecifier asset.Specifier, + assetMaxAmt fn.Option[uint64], paymentMaxAmt fn.Option[lnwire.MilliSatoshi], assetRateHint fn.Option[rfqmsg.AssetRate]) (*rfqmsg.AssetRate, error) { @@ -326,7 +325,7 @@ func (n *Negotiator) HandleIncomingBuyRequest( // Query the price oracle for an asking price. assetRate, err := n.queryAskFromPriceOracle( - nil, request.AssetSpecifier, + request.AssetSpecifier, fn.Some(request.AssetMaxAmt), fn.None[lnwire.MilliSatoshi](), request.AssetRateHint, @@ -426,9 +425,8 @@ func (n *Negotiator) HandleIncomingSellRequest( // are willing to pay for the asset that our peer is trying to // sell to us. assetRate, err := n.queryBidFromPriceOracle( - request.Peer, request.AssetSpecifier, - fn.None[uint64](), fn.Some(request.PaymentMaxAmt), - request.AssetRateHint, + request.AssetSpecifier, fn.None[uint64](), + fn.Some(request.PaymentMaxAmt), request.AssetRateHint, ) if err != nil { // Send a reject message to the peer. @@ -484,7 +482,7 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { if n.cfg.PriceOracle != nil && order.AssetSpecifier.IsSome() { // Query the price oracle for an asking price. assetRate, err := n.queryAskFromPriceOracle( - &peer, order.AssetSpecifier, + order.AssetSpecifier, fn.None[uint64](), fn.Some(order.PaymentMaxAmt), fn.None[rfqmsg.AssetRate](), @@ -599,7 +597,7 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, // for an ask price. We will then compare the ask price returned // by the price oracle with the ask price provided by the peer. assetRate, err := n.queryAskFromPriceOracle( - &msg.Peer, msg.Request.AssetSpecifier, + msg.Request.AssetSpecifier, fn.Some(msg.Request.AssetMaxAmt), fn.None[lnwire.MilliSatoshi](), fn.None[rfqmsg.AssetRate](), @@ -725,8 +723,7 @@ func (n *Negotiator) HandleIncomingSellAccept(msg rfqmsg.SellAccept, // for a bid price. We will then compare the bid price returned // by the price oracle with the bid price provided by the peer. assetRate, err := n.queryBidFromPriceOracle( - msg.Peer, msg.Request.AssetSpecifier, - fn.None[uint64](), + msg.Request.AssetSpecifier, fn.None[uint64](), fn.Some(msg.Request.PaymentMaxAmt), msg.Request.AssetRateHint, ) From 987b1c9283fe86de7c02d49d6b637f510d56e68f Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 13:21:43 +0000 Subject: [PATCH 04/15] rpc+rfq: populate expiry field in SellOder Populate the expiry timestamp field in SellOrder and use type `time.Time`. --- rfq/manager.go | 8 ++------ rfq/negotiator.go | 8 +++++--- rpcserver.go | 9 +++++++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index f150e5e01..cdc7c8548 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -754,12 +754,8 @@ type SellOrder struct { // must agree to pay. PaymentMaxAmt lnwire.MilliSatoshi - // Expiry is the unix timestamp at which the order expires. - // - // TODO(ffranr): This is the invoice expiry unix timestamp in seconds. - // We should make use of this field to ensure quotes are valid for the - // duration of the invoice. - Expiry uint64 + // Expiry is the time at which the order expires. + Expiry time.Time // Peer is the peer that the buy order is intended for. This field is // optional. diff --git a/rfq/negotiator.go b/rfq/negotiator.go index d45cceae5..a3891dd1d 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -481,9 +481,11 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { if n.cfg.PriceOracle != nil && order.AssetSpecifier.IsSome() { // Query the price oracle for an asking price. + // + // TODO(ffranr): Pass the SellOrder expiry to the + // price oracle at this point. assetRate, err := n.queryAskFromPriceOracle( - order.AssetSpecifier, - fn.None[uint64](), + order.AssetSpecifier, fn.None[uint64](), fn.Some(order.PaymentMaxAmt), fn.None[rfqmsg.AssetRate](), ) @@ -494,7 +496,7 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { return } - assetRateHint = fn.Some[rfqmsg.AssetRate](*assetRate) + assetRateHint = fn.MaybeSome(assetRate) } request, err := rfqmsg.NewSellRequest( diff --git a/rpcserver.go b/rpcserver.go index 709937991..815893a7d 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "strings" "sync" @@ -6423,9 +6424,17 @@ func unmarshalAssetSellOrder( err) } + // Convert expiry unix timestamp in seconds to time.Time. + if req.Expiry > math.MaxInt64 { + return nil, fmt.Errorf("expiry must be less than or equal to "+ + "math.MaxInt64 (expiry=%d)", req.Expiry) + } + expiry := time.Unix(int64(req.Expiry), 0).UTC() + return &rfq.SellOrder{ AssetSpecifier: assetSpecifier, PaymentMaxAmt: lnwire.MilliSatoshi(req.PaymentMaxAmt), + Expiry: expiry, Peer: peer, }, nil } From 519e4999c965d68657190570b234070c0bca225e Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 00:41:33 +0000 Subject: [PATCH 05/15] multi: refactor asset rate fields in BuyAccept Refactor BuyAccept by replacing multiple fields with a single asset rate field using the new AssetRate type. --- rfq/manager.go | 4 ++-- rfq/negotiator.go | 19 +++++++++-------- rfq/order.go | 4 ++-- rfqmsg/accept.go | 7 +++++-- rfqmsg/buy_accept.go | 23 +++++++++------------ rpcserver.go | 6 +++--- tapchannel/aux_invoice_manager.go | 2 +- tapchannel/aux_invoice_manager_test.go | 28 +++++++++++++++++--------- taprpc/marshal.go | 6 +++--- 9 files changed, 52 insertions(+), 47 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index cdc7c8548..def0ad0dd 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -788,7 +788,7 @@ func (m *Manager) PeerAcceptedBuyQuotes() BuyAcceptMap { buyQuotesCopy := make(map[SerialisedScid]rfqmsg.BuyAccept) m.peerAcceptedBuyQuotes.ForEach( func(scid SerialisedScid, accept rfqmsg.BuyAccept) error { - if time.Now().Unix() > int64(accept.Expiry) { + if time.Now().After(accept.AssetRate.Expiry) { m.peerAcceptedBuyQuotes.Delete(scid) return nil } @@ -832,7 +832,7 @@ func (m *Manager) LocalAcceptedBuyQuotes() BuyAcceptMap { buyQuotesCopy := make(map[SerialisedScid]rfqmsg.BuyAccept) m.localAcceptedBuyQuotes.ForEach( func(scid SerialisedScid, accept rfqmsg.BuyAccept) error { - if time.Now().Unix() > int64(accept.Expiry) { + if time.Now().After(accept.AssetRate.Expiry) { m.localAcceptedBuyQuotes.Delete(scid) return nil } diff --git a/rfq/negotiator.go b/rfq/negotiator.go index a3891dd1d..54ca45c54 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -346,10 +346,7 @@ func (n *Negotiator) HandleIncomingBuyRequest( } // Construct and send a buy accept message. - expiry := uint64(assetRate.Expiry.Unix()) - msg := rfqmsg.NewBuyAcceptFromRequest( - request, assetRate.Rate, expiry, - ) + msg := rfqmsg.NewBuyAcceptFromRequest(request, *assetRate) sendOutgoingMsg(msg) }() @@ -549,10 +546,12 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, // TODO(ffranr): Sanity check the buy accept quote expiry // timestamp given the expiry timestamp provided by the price // oracle. - if !expiryWithinBounds(msg.Expiry, minAssetRatesExpiryLifetime) { + expiry := uint64(msg.AssetRate.Expiry.Unix()) + if !expiryWithinBounds(expiry, minAssetRatesExpiryLifetime) { // The expiry time is not within the acceptable bounds. log.Debugf("Buy accept quote expiry time is not within "+ - "acceptable bounds (expiry=%d)", msg.Expiry) + "acceptable bounds (asset_rate=%s)", + msg.AssetRate.String()) // Construct an invalid quote response event so that we can // inform the peer that the quote response has not validated @@ -633,7 +632,7 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, tolerance := rfqmath.NewBigIntFromUint64( n.cfg.AcceptPriceDeviationPpm, ) - acceptablePrice := msg.AssetRate.WithinTolerance( + acceptablePrice := msg.AssetRate.Rate.WithinTolerance( assetRate.Rate, tolerance, ) if !acceptablePrice { @@ -641,9 +640,9 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, // We will return without calling the quote accept // callback. log.Debugf("Buy accept price is not within "+ - "acceptable bounds (ask_asset_rate=%v, "+ - "oracle_asset_rate=%v)", msg.AssetRate, - assetRate) + "acceptable bounds (peer_asset_rate=%s, "+ + "oracle_asset_rate=%s)", msg.AssetRate.String(), + assetRate.String()) // Construct an invalid quote response event so that we // can inform the peer that the quote response has not diff --git a/rfq/order.go b/rfq/order.go index 481fa0efd..9d3fef130 100644 --- a/rfq/order.go +++ b/rfq/order.go @@ -108,8 +108,8 @@ func NewAssetSalePolicy(quote rfqmsg.BuyAccept) *AssetSalePolicy { AssetSpecifier: quote.Request.AssetSpecifier, AcceptedQuoteId: quote.ID, MaxOutboundAssetAmount: quote.Request.AssetMaxAmt, - AskAssetRate: quote.AssetRate, - expiry: quote.Expiry, + AskAssetRate: quote.AssetRate.Rate, + expiry: uint64(quote.AssetRate.Expiry.Unix()), } } diff --git a/rfqmsg/accept.go b/rfqmsg/accept.go index e607991f9..b17c31798 100644 --- a/rfqmsg/accept.go +++ b/rfqmsg/accept.go @@ -43,12 +43,15 @@ type acceptWireMsgData struct { func newAcceptWireMsgDataFromBuy(q BuyAccept) (acceptWireMsgData, error) { version := tlv.NewPrimitiveRecord[tlv.TlvType0](q.Version) id := tlv.NewRecordT[tlv.TlvType2](q.ID) - expiry := tlv.NewPrimitiveRecord[tlv.TlvType4](q.Expiry) + + expiryUnix := q.AssetRate.Expiry.Unix() + expiry := tlv.NewPrimitiveRecord[tlv.TlvType4](uint64(expiryUnix)) + sig := tlv.NewPrimitiveRecord[tlv.TlvType6](q.sig) // The rate provided in the buy acceptance message represents the // exchange rate from the incoming asset to BTC. - rate := NewTlvFixedPointFromBigInt(q.AssetRate) + rate := NewTlvFixedPointFromBigInt(q.AssetRate.Rate) inAssetRate := tlv.NewRecordT[tlv.TlvType8](rate) // Currently, only BTC is supported as the outgoing asset in buy diff --git a/rfqmsg/buy_accept.go b/rfqmsg/buy_accept.go index 8ea39f1da..c37317b10 100644 --- a/rfqmsg/buy_accept.go +++ b/rfqmsg/buy_accept.go @@ -2,8 +2,8 @@ package rfqmsg import ( "fmt" + "time" - "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightningnetwork/lnd/routing/route" ) @@ -30,10 +30,7 @@ type BuyAccept struct { ID ID // AssetRate is the accepted asset to BTC rate. - AssetRate rfqmath.BigIntFixedPoint - - // Expiry is the asking price expiry lifetime unix timestamp. - Expiry uint64 + AssetRate AssetRate // sig is a signature over the serialized contents of the message. sig [64]byte @@ -41,10 +38,8 @@ type BuyAccept struct { // NewBuyAcceptFromRequest creates a new instance of a quote accept message // given a quote request message. -// -// TODO(ffranr): Use new AssetRate type for assetRate arg. func NewBuyAcceptFromRequest(request BuyRequest, - assetRate rfqmath.BigIntFixedPoint, expiry uint64) *BuyAccept { + assetRate AssetRate) *BuyAccept { return &BuyAccept{ Peer: request.Peer, @@ -52,7 +47,6 @@ func NewBuyAcceptFromRequest(request BuyRequest, Version: latestBuyAcceptVersion, ID: request.ID, AssetRate: assetRate, - Expiry: expiry, } } @@ -70,14 +64,16 @@ func newBuyAcceptFromWireMsg(wireMsg WireMessage, // currently assume that the out-asset is BTC. assetRate := msgData.InAssetRate.Val.IntoBigIntFixedPoint() + // Convert the unix timestamp in seconds to a time.Time. + expiry := time.Unix(int64(msgData.Expiry.Val), 0) + return &BuyAccept{ Peer: wireMsg.Peer, Request: request, Version: msgData.Version.Val, ID: msgData.ID.Val, - Expiry: msgData.Expiry.Val, + AssetRate: NewAssetRate(assetRate, expiry), sig: msgData.Sig.Val, - AssetRate: assetRate, }, nil } @@ -128,9 +124,8 @@ func (q *BuyAccept) MsgID() ID { // String returns a human-readable string representation of the message. func (q *BuyAccept) String() string { - return fmt.Sprintf("BuyAccept(peer=%x, id=%x, ask_price=%d, "+ - "expiry=%d, scid=%d)", - q.Peer[:], q.ID[:], q.AssetRate, q.Expiry, q.ShortChannelId()) + return fmt.Sprintf("BuyAccept(peer=%x, id=%x, asset_rate=%s, scid=%d)", + q.Peer[:], q.ID[:], q.AssetRate.String(), q.ShortChannelId()) } // Ensure that the message type implements the OutgoingMsg interface. diff --git a/rpcserver.go b/rpcserver.go index 815893a7d..6ed4ae5a4 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6591,10 +6591,10 @@ func marshalPeerAcceptedBuyQuotes( []*rfqrpc.PeerAcceptedBuyQuote, 0, len(quotes), ) for scid, quote := range quotes { - coefficient := quote.AssetRate.Coefficient.String() + coefficient := quote.AssetRate.Rate.Coefficient.String() rpcAskAssetRate := &rfqrpc.FixedPoint{ Coefficient: coefficient, - Scale: uint32(quote.AssetRate.Scale), + Scale: uint32(quote.AssetRate.Rate.Scale), } rpcQuote := &rfqrpc.PeerAcceptedBuyQuote{ @@ -6603,7 +6603,7 @@ func marshalPeerAcceptedBuyQuotes( Scid: uint64(scid), AssetAmount: quote.Request.AssetMaxAmt, AskAssetRate: rpcAskAssetRate, - Expiry: quote.Expiry, + Expiry: uint64(quote.AssetRate.Expiry.Unix()), } rpcQuotes = append(rpcQuotes, rpcQuote) } diff --git a/tapchannel/aux_invoice_manager.go b/tapchannel/aux_invoice_manager.go index 37009e718..f0630b620 100644 --- a/tapchannel/aux_invoice_manager.go +++ b/tapchannel/aux_invoice_manager.go @@ -266,7 +266,7 @@ func (s *AuxInvoiceManager) priceFromQuote(rfqID rfqmsg.ID) ( log.Debugf("Found buy quote for ID %x / SCID %d: %#v", rfqID[:], rfqID.Scid(), buyQuote) - return &buyQuote.AssetRate, nil + return &buyQuote.AssetRate.Rate, nil // This is a direct peer payment, so we expect to find a sell quote. case isSell: diff --git a/tapchannel/aux_invoice_manager_test.go b/tapchannel/aux_invoice_manager_test.go index fbb3cb461..d40d0b580 100644 --- a/tapchannel/aux_invoice_manager_test.go +++ b/tapchannel/aux_invoice_manager_test.go @@ -209,7 +209,9 @@ func (m *mockHtlcModifierProperty) HtlcModifier(ctx context.Context, m.t.Errorf("no rfq quote found") } - assetRate := lnwire.MilliSatoshi(quote.AssetRate.ToUint64()) + assetRate := lnwire.MilliSatoshi( + quote.AssetRate.Rate.ToUint64(), + ) msatPerBtc := float64(btcutil.SatoshiPerBitcoin * 1000) unitValue := msatPerBtc / float64(assetRate) assetUnits := lnwire.MilliSatoshi(htlc.Amounts.Val.Sum()) @@ -344,8 +346,10 @@ func TestAuxInvoiceManager(t *testing.T) { }, buyQuotes: rfq.BuyAcceptMap{ fn.Ptr(dummyRfqID(31)).Scid(): { - Peer: testNodeID, - AssetRate: testAssetRate, + Peer: testNodeID, + AssetRate: rfqmsg.NewAssetRate( + testAssetRate, time.Now(), + ), }, }, }, @@ -376,8 +380,10 @@ func TestAuxInvoiceManager(t *testing.T) { }, buyQuotes: rfq.BuyAcceptMap{ fn.Ptr(dummyRfqID(31)).Scid(): { - Peer: testNodeID, - AssetRate: testAssetRate, + Peer: testNodeID, + AssetRate: rfqmsg.NewAssetRate( + testAssetRate, time.Now(), + ), }, }, }, @@ -664,12 +670,14 @@ func genBuyQuotes(t *rapid.T, rfqMap rfq.BuyAcceptMap, units, amtMsat uint64, ) } + rateFp := rfqmath.FixedPoint[rfqmath.BigInt]{ + Coefficient: rfqmath.NewBigInt(assetRate), + Scale: 0, + } + rfqMap[rfqScid.Scid()] = rfqmsg.BuyAccept{ - Peer: peer, - AssetRate: rfqmath.FixedPoint[rfqmath.BigInt]{ - Coefficient: rfqmath.NewBigInt(assetRate), - Scale: 0, - }, + Peer: peer, + AssetRate: rfqmsg.NewAssetRate(rateFp, time.Now()), } } diff --git a/taprpc/marshal.go b/taprpc/marshal.go index af540f234..d2a2ac3f7 100644 --- a/taprpc/marshal.go +++ b/taprpc/marshal.go @@ -594,10 +594,10 @@ func MarshalAcceptedBuyQuoteEvent( Scid: uint64(event.ShortChannelId()), AssetAmount: event.Request.AssetMaxAmt, AskAssetRate: &rfqrpc.FixedPoint{ - Coefficient: event.AssetRate.Coefficient.String(), - Scale: uint32(event.AssetRate.Scale), + Coefficient: event.AssetRate.Rate.Coefficient.String(), + Scale: uint32(event.AssetRate.Rate.Scale), }, - Expiry: event.Expiry, + Expiry: uint64(event.AssetRate.Expiry.Unix()), }, nil } From d8abe5d43c8a27b9b8772fe32d1002c3015b0148 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 01:01:48 +0000 Subject: [PATCH 06/15] rfq: pass accepted price as a hint to the oracle during price check When handling a quote request accept message, the peer-provided price must be validated against our price oracle. This commit passes the peer-provided price as a hint to the oracle, offering additional context to improve decision-making. --- rfq/negotiator.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 54ca45c54..f69c90e30 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -544,8 +544,9 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, // Ensure that the quote expiry time is within acceptable bounds. // // TODO(ffranr): Sanity check the buy accept quote expiry - // timestamp given the expiry timestamp provided by the price - // oracle. + // timestamp given the expiry timestamp in our outgoing buy request. + // The expiry timestamp in the outgoing request relates to the lifetime + // of the lightning invoice. expiry := uint64(msg.AssetRate.Expiry.Unix()) if !expiryWithinBounds(expiry, minAssetRatesExpiryLifetime) { // The expiry time is not within the acceptable bounds. @@ -600,8 +601,7 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, assetRate, err := n.queryAskFromPriceOracle( msg.Request.AssetSpecifier, fn.Some(msg.Request.AssetMaxAmt), - fn.None[lnwire.MilliSatoshi](), - fn.None[rfqmsg.AssetRate](), + fn.None[lnwire.MilliSatoshi](), fn.Some(msg.AssetRate), ) if err != nil { // The price oracle returned an error. We will return From 5309183cd5821812edf82226f876b4b3d55b1601 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 13:54:46 +0000 Subject: [PATCH 07/15] rfq: improve SellOrder and SellRequest doc Improve doc by relating these types to the wider lightning payment flow context. --- rfq/manager.go | 18 +++++++++++++++++- rfqmsg/sell_request.go | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index def0ad0dd..69eef0ea6 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -745,7 +745,23 @@ func (m *Manager) UpsertAssetBuyOrder(order BuyOrder) error { return nil } -// SellOrder is a struct that represents an asset sell order. +// SellOrder instructs the RFQ (Request For Quote) system to request a quote +// from one or more peers for the disposition of an asset. +// +// Normal usage of a sell order: +// 1. Alice creates a Lightning invoice for Bob to pay. +// 2. Bob wants to pay the invoice using a Tap asset. To do so, Bob pays an +// edge node with a Tap asset, and the edge node forwards the payment to the +// network to settle Alice's invoice. Bob submits a SellOrder to his local +// RFQ service. +// 3. The RFQ service converts the SellOrder into one or more SellRequests. +// These requests are sent to Charlie (the edge node), who shares a relevant +// Tap asset channel with Bob and can forward payments to settle Alice's +// invoice. +// 4. Charlie responds with a quote that satisfies Bob. +// 5. Bob transfers the appropriate Tap asset amount to Charlie via their +// shared Tap asset channel, and Charlie forwards the corresponding amount +// to Alice to settle the Lightning invoice. type SellOrder struct { // AssetSpecifier is the asset that the seller is interested in. AssetSpecifier asset.Specifier diff --git a/rfqmsg/sell_request.go b/rfqmsg/sell_request.go index b84693f05..8c8c84d92 100644 --- a/rfqmsg/sell_request.go +++ b/rfqmsg/sell_request.go @@ -18,7 +18,22 @@ const ( latestSellRequestVersion = V1 ) -// SellRequest is a struct that represents a asset sell quote request. +// SellRequest is a struct that represents an asset sell quote request. +// +// Normal usage of a sell request: +// 1. Alice creates a Lightning invoice for Bob to pay. +// 2. Bob wants to pay the invoice using a Tap asset. To do so, Bob pays an +// edge node with a Tap asset, and the edge node forwards the payment to the +// network to settle Alice's invoice. Bob submits a SellOrder to his local +// RFQ service. +// 3. The RFQ service converts the SellOrder into one or more SellRequests. +// These requests are sent to Charlie (the edge node), who shares a relevant +// Tap asset channel with Bob and can forward payments to settle Alice's +// invoice. +// 4. Charlie responds with a quote that satisfies Bob. +// 5. Bob transfers the appropriate Tap asset amount to Charlie via their +// shared Tap asset channel, and Charlie forwards the corresponding amount +// to Alice to settle the Lightning invoice. type SellRequest struct { // Peer is the peer that sent the quote request. Peer route.Vertex From 5e03af22d08a69b99e5481993bb44ae08ba22c16 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 13:58:50 +0000 Subject: [PATCH 08/15] rpc+rfq: populate expiry field in BuyOder Populate the expiry timestamp field in BuyOder and use type `time.Time`. --- rfq/manager.go | 6 +++--- rfq/negotiator.go | 3 +++ rpcserver.go | 9 ++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index 69eef0ea6..bd1cb7e28 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -689,7 +689,7 @@ func (m *Manager) UpsertAssetBuyOffer(offer BuyOffer) error { } // BuyOrder instructs the RFQ (Request For Quote) system to request a quote from -// a peer for the acquisition of an asset. +// one or more peers for the acquisition of an asset. // // The normal use of a buy order is as follows: // 1. Alice, operating a wallet node, wants to receive a Tap asset as payment @@ -715,8 +715,8 @@ type BuyOrder struct { // be willing to offer. AssetMaxAmt uint64 - // Expiry is the unix timestamp at which the buy order expires. - Expiry uint64 + // Expiry is the time at which the order expires. + Expiry time.Time // Peer is the peer that the buy order is intended for. This field is // optional. diff --git a/rfq/negotiator.go b/rfq/negotiator.go index f69c90e30..c020bed71 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -176,6 +176,9 @@ func (n *Negotiator) HandleOutgoingBuyOrder(buyOrder BuyOrder) error { buyOrder.AssetSpecifier.IsSome() { // Query the price oracle for a bid price. + // + // TODO(ffranr): Pass the BuyOrder expiry to the price + // oracle at this point. assetRate, err := n.queryBidFromPriceOracle( buyOrder.AssetSpecifier, fn.Some(buyOrder.AssetMaxAmt), diff --git a/rpcserver.go b/rpcserver.go index 6ed4ae5a4..e368dcb64 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6314,10 +6314,17 @@ func unmarshalAssetBuyOrder( err) } + // Convert expiry unix timestamp in seconds to time.Time. + if req.Expiry > math.MaxInt64 { + return nil, fmt.Errorf("expiry must be less than or equal to "+ + "math.MaxInt64 (expiry=%d)", req.Expiry) + } + expiry := time.Unix(int64(req.Expiry), 0).UTC() + return &rfq.BuyOrder{ AssetSpecifier: assetSpecifier, AssetMaxAmt: req.AssetMaxAmt, - Expiry: req.Expiry, + Expiry: expiry, Peer: fn.MaybeSome(peer), }, nil } From 46ab13c3e8d8a653b209b4647a455882c4d567a9 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 14:04:00 +0000 Subject: [PATCH 09/15] rfq: improve BuyRequest doc Improve doc by relating this type to the wider lightning payment flow context. --- rfqmsg/buy_request.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/rfqmsg/buy_request.go b/rfqmsg/buy_request.go index c6489e55d..e01b9cd6e 100644 --- a/rfqmsg/buy_request.go +++ b/rfqmsg/buy_request.go @@ -19,6 +19,23 @@ const ( ) // BuyRequest is a struct that represents an asset buy quote request. +// +// Normal usage of a buy request: +// 1. Alice, operating a wallet node, wants to receive a Tap asset as payment +// by issuing a Lightning invoice. +// 2. Alice has an asset channel established with Bob's edge node. +// 3. Before issuing the invoice, Alice needs to agree on an exchange rate with +// Bob, who will facilitate the asset transfer. +// 4. To obtain the best exchange rate, Alice creates a buy order specifying +// the desired asset. +// 5. Alice's RFQ subsystem processes the buy order and sends buy requests to +// relevant peers to find the best rate. In this example, Bob is the only +// available peer. +// 6. Once Bob provides a satisfactory quote, Alice accepts it. +// 7. Alice issues the Lightning invoice, which Charlie will pay. +// 8. Instead of paying Alice directly, Charlie pays Bob. +// 9. Bob then forwards the agreed amount of the Tap asset to Alice over their +// asset channel. type BuyRequest struct { // Peer is the peer that sent the quote request. Peer route.Vertex From 2702cb2fdc9bd4af66df1bce6891fd6ccd183b45 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 14:23:35 +0000 Subject: [PATCH 10/15] multi: refactor asset rate fields in SellAccept Refactor SellAccept by replacing multiple fields with a single asset rate field using the new AssetRate type. --- rfq/manager.go | 4 ++-- rfq/negotiator.go | 13 ++++++------- rfq/order.go | 4 ++-- rfqmsg/accept.go | 7 +++++-- rfqmsg/sell_accept.go | 24 ++++++++++-------------- rpcserver.go | 6 +++--- tapchannel/aux_invoice_manager.go | 2 +- tapchannel/aux_traffic_shaper.go | 4 ++-- taprpc/marshal.go | 6 +++--- 9 files changed, 34 insertions(+), 36 deletions(-) diff --git a/rfq/manager.go b/rfq/manager.go index bd1cb7e28..7bc589cd1 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -826,7 +826,7 @@ func (m *Manager) PeerAcceptedSellQuotes() SellAcceptMap { sellQuotesCopy := make(map[SerialisedScid]rfqmsg.SellAccept) m.peerAcceptedSellQuotes.ForEach( func(scid SerialisedScid, accept rfqmsg.SellAccept) error { - if time.Now().Unix() > int64(accept.Expiry) { + if time.Now().After(accept.AssetRate.Expiry) { m.peerAcceptedSellQuotes.Delete(scid) return nil } @@ -870,7 +870,7 @@ func (m *Manager) LocalAcceptedSellQuotes() SellAcceptMap { sellQuotesCopy := make(map[SerialisedScid]rfqmsg.SellAccept) m.localAcceptedSellQuotes.ForEach( func(scid SerialisedScid, accept rfqmsg.SellAccept) error { - if time.Now().Unix() > int64(accept.Expiry) { + if time.Now().After(accept.AssetRate.Expiry) { m.localAcceptedSellQuotes.Delete(scid) return nil } diff --git a/rfq/negotiator.go b/rfq/negotiator.go index c020bed71..839bbbde9 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -444,10 +444,7 @@ func (n *Negotiator) HandleIncomingSellRequest( } // Construct and send a sell accept message. - expiry := uint64(assetRate.Expiry.Unix()) - msg := rfqmsg.NewSellAcceptFromRequest( - request, assetRate.Rate, expiry, - ) + msg := rfqmsg.NewSellAcceptFromRequest(request, *assetRate) sendOutgoingMsg(msg) }() @@ -677,10 +674,12 @@ func (n *Negotiator) HandleIncomingSellAccept(msg rfqmsg.SellAccept, // // TODO(ffranr): Sanity check the quote expiry timestamp given // the expiry timestamp provided by the price oracle. - if !expiryWithinBounds(msg.Expiry, minAssetRatesExpiryLifetime) { + expiry := uint64(msg.AssetRate.Expiry.Unix()) + if !expiryWithinBounds(expiry, minAssetRatesExpiryLifetime) { // The expiry time is not within the acceptable bounds. log.Debugf("Sell accept quote expiry time is not within "+ - "acceptable bounds (expiry=%d)", msg.Expiry) + "acceptable bounds (asset_rate=%s)", + msg.AssetRate.String()) // Construct an invalid quote response event so that we can // inform the peer that the quote response has not validated @@ -760,7 +759,7 @@ func (n *Negotiator) HandleIncomingSellAccept(msg rfqmsg.SellAccept, tolerance := rfqmath.NewBigIntFromUint64( n.cfg.AcceptPriceDeviationPpm, ) - acceptablePrice := msg.AssetRate.WithinTolerance( + acceptablePrice := msg.AssetRate.Rate.WithinTolerance( assetRate.Rate, tolerance, ) if !acceptablePrice { diff --git a/rfq/order.go b/rfq/order.go index 9d3fef130..a897a7094 100644 --- a/rfq/order.go +++ b/rfq/order.go @@ -262,9 +262,9 @@ func NewAssetPurchasePolicy(quote rfqmsg.SellAccept) *AssetPurchasePolicy { scid: quote.ShortChannelId(), AssetSpecifier: quote.Request.AssetSpecifier, AcceptedQuoteId: quote.ID, - BidAssetRate: quote.AssetRate, + BidAssetRate: quote.AssetRate.Rate, PaymentMaxAmt: quote.Request.PaymentMaxAmt, - expiry: quote.Expiry, + expiry: uint64(quote.AssetRate.Expiry.Unix()), } } diff --git a/rfqmsg/accept.go b/rfqmsg/accept.go index b17c31798..c4e6ca86b 100644 --- a/rfqmsg/accept.go +++ b/rfqmsg/accept.go @@ -76,7 +76,10 @@ func newAcceptWireMsgDataFromBuy(q BuyAccept) (acceptWireMsgData, error) { func newAcceptWireMsgDataFromSell(q SellAccept) (acceptWireMsgData, error) { version := tlv.NewPrimitiveRecord[tlv.TlvType0](q.Version) id := tlv.NewRecordT[tlv.TlvType2](q.ID) - expiry := tlv.NewPrimitiveRecord[tlv.TlvType4](q.Expiry) + + expiryUnix := q.AssetRate.Expiry.Unix() + expiry := tlv.NewPrimitiveRecord[tlv.TlvType4](uint64(expiryUnix)) + sig := tlv.NewPrimitiveRecord[tlv.TlvType6](q.sig) // Currently, only BTC is supported as the incoming asset in sell @@ -87,7 +90,7 @@ func newAcceptWireMsgDataFromSell(q SellAccept) (acceptWireMsgData, error) { // The rate provided in the sell acceptance message represents the // exchange rate from the outgoing asset to BTC. - rate := NewTlvFixedPointFromBigInt(q.AssetRate) + rate := NewTlvFixedPointFromBigInt(q.AssetRate.Rate) outAssetRate := tlv.NewRecordT[tlv.TlvType10](rate) // Encode message data component as TLV bytes. diff --git a/rfqmsg/sell_accept.go b/rfqmsg/sell_accept.go index 81c3439fa..1e1701d97 100644 --- a/rfqmsg/sell_accept.go +++ b/rfqmsg/sell_accept.go @@ -2,8 +2,8 @@ package rfqmsg import ( "fmt" + "time" - "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightningnetwork/lnd/routing/route" ) @@ -30,10 +30,7 @@ type SellAccept struct { ID ID // AssetRate is the accepted asset to BTC rate. - AssetRate rfqmath.BigIntFixedPoint - - // Expiry is the bid price expiry lifetime unix timestamp. - Expiry uint64 + AssetRate AssetRate // sig is a signature over the serialized contents of the message. sig [64]byte @@ -41,10 +38,8 @@ type SellAccept struct { // NewSellAcceptFromRequest creates a new instance of an asset sell quote accept // message given an asset sell quote request message. -// -// // TODO(ffranr): Use new AssetRate type for assetRate arg. func NewSellAcceptFromRequest(request SellRequest, - assetRate rfqmath.BigIntFixedPoint, expiry uint64) *SellAccept { + assetRate AssetRate) *SellAccept { return &SellAccept{ Peer: request.Peer, @@ -52,7 +47,6 @@ func NewSellAcceptFromRequest(request SellRequest, Version: latestSellAcceptVersion, ID: request.ID, AssetRate: assetRate, - Expiry: expiry, } } @@ -72,6 +66,9 @@ func newSellAcceptFromWireMsg(wireMsg WireMessage, // currently assume that the in-asset is BTC. assetRate := msgData.OutAssetRate.Val.IntoBigIntFixedPoint() + // Convert the unix timestamp in seconds to a time.Time. + expiry := time.Unix(int64(msgData.Expiry.Val), 0).UTC() + // Note that the `Request` field is populated later in the RFQ stream // service. return &SellAccept{ @@ -79,8 +76,7 @@ func newSellAcceptFromWireMsg(wireMsg WireMessage, Request: request, Version: msgData.Version.Val, ID: msgData.ID.Val, - AssetRate: assetRate, - Expiry: msgData.Expiry.Val, + AssetRate: NewAssetRate(assetRate, expiry), sig: msgData.Sig.Val, }, nil } @@ -133,9 +129,9 @@ func (q *SellAccept) MsgID() ID { // String returns a human-readable string representation of the message. func (q *SellAccept) String() string { - return fmt.Sprintf("SellAccept(peer=%x, id=%x, bid_asset_rate=%v, "+ - "expiry=%d, scid=%d)", q.Peer[:], q.ID[:], q.AssetRate, - q.Expiry, q.ShortChannelId()) + return fmt.Sprintf("SellAccept(peer=%x, id=%x, asset_rate=%s, "+ + "scid=%d)", q.Peer[:], q.ID[:], q.AssetRate.String(), + q.ShortChannelId()) } // Ensure that the message type implements the OutgoingMsg interface. diff --git a/rpcserver.go b/rpcserver.go index e368dcb64..161b4deff 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6630,8 +6630,8 @@ func marshalPeerAcceptedSellQuotes(quotes map[rfq.SerialisedScid]rfqmsg.SellAcce rpcQuotes := make([]*rfqrpc.PeerAcceptedSellQuote, 0, len(quotes)) for scid, quote := range quotes { rpcAssetRate := &rfqrpc.FixedPoint{ - Coefficient: quote.AssetRate.Coefficient.String(), - Scale: uint32(quote.AssetRate.Scale), + Coefficient: quote.AssetRate.Rate.Coefficient.String(), + Scale: uint32(quote.AssetRate.Rate.Scale), } // TODO(ffranr): Add SellRequest payment max amount to @@ -6641,7 +6641,7 @@ func marshalPeerAcceptedSellQuotes(quotes map[rfq.SerialisedScid]rfqmsg.SellAcce Id: quote.ID[:], Scid: uint64(scid), BidAssetRate: rpcAssetRate, - Expiry: quote.Expiry, + Expiry: uint64(quote.AssetRate.Expiry.Unix()), } rpcQuotes = append(rpcQuotes, rpcQuote) } diff --git a/tapchannel/aux_invoice_manager.go b/tapchannel/aux_invoice_manager.go index f0630b620..7fcad8c9a 100644 --- a/tapchannel/aux_invoice_manager.go +++ b/tapchannel/aux_invoice_manager.go @@ -273,7 +273,7 @@ func (s *AuxInvoiceManager) priceFromQuote(rfqID rfqmsg.ID) ( log.Debugf("Found sell quote for ID %x / SCID %d: %#v", rfqID[:], rfqID.Scid(), sellQuote) - return &sellQuote.AssetRate, nil + return &sellQuote.AssetRate.Rate, nil default: return nil, fmt.Errorf("no accepted quote found for RFQ SCID "+ diff --git a/tapchannel/aux_traffic_shaper.go b/tapchannel/aux_traffic_shaper.go index f017a9a7e..1974f58ed 100644 --- a/tapchannel/aux_traffic_shaper.go +++ b/tapchannel/aux_traffic_shaper.go @@ -224,7 +224,7 @@ func (s *AuxTrafficShaper) PaymentBandwidth(htlcBlob, // expressed in milli-satoshis. localBalanceFp := rfqmath.NewBigIntFixedPoint(localBalance, 0) availableBalanceMsat := rfqmath.UnitsToMilliSatoshi( - localBalanceFp, quote.AssetRate, + localBalanceFp, quote.AssetRate.Rate, ) // At this point we have acquired what we need to express the asset @@ -283,7 +283,7 @@ func (s *AuxTrafficShaper) ProduceHtlcExtraData(totalAmount lnwire.MilliSatoshi, // corresponding number of assets, then reduce the number of satoshis of // the HTLC to the bare minimum that can be materialized on chain. numAssetUnitsFp := rfqmath.MilliSatoshiToUnits( - totalAmount, quote.AssetRate, + totalAmount, quote.AssetRate.Rate, ) numAssetUnits := numAssetUnitsFp.ScaleTo(0).ToUint64() diff --git a/taprpc/marshal.go b/taprpc/marshal.go index d2a2ac3f7..8ad92df6d 100644 --- a/taprpc/marshal.go +++ b/taprpc/marshal.go @@ -567,8 +567,8 @@ func MarshalAcceptedSellQuoteEvent( error) { rpcAssetRate := &rfqrpc.FixedPoint{ - Coefficient: event.AssetRate.Coefficient.String(), - Scale: uint32(event.AssetRate.Scale), + Coefficient: event.AssetRate.Rate.Coefficient.String(), + Scale: uint32(event.AssetRate.Rate.Scale), } // TODO(ffranr): Add SellRequest payment max amount to @@ -578,7 +578,7 @@ func MarshalAcceptedSellQuoteEvent( Id: event.ID[:], Scid: uint64(event.ShortChannelId()), BidAssetRate: rpcAssetRate, - Expiry: event.Expiry, + Expiry: uint64(event.AssetRate.Expiry.Unix()), }, nil } From 27dcb81757513373173742e81909170828889a55 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 14:27:35 +0000 Subject: [PATCH 11/15] rfq: update expiryWithinBounds to use time.Time for expiry Changed the `expiry` argument in `expiryWithinBounds` to be of type `time.Time` for improved type safety and clarity. --- rfq/negotiator.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 839bbbde9..b5cd86e4d 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -525,12 +525,8 @@ func (n *Negotiator) HandleOutgoingSellOrder(order SellOrder) { // expiryWithinBounds checks if a quote expiry unix timestamp (in seconds) is // within acceptable bounds. This check ensures that the expiry timestamp is far // enough in the future for the quote to be useful. -func expiryWithinBounds(expiryUnixTimestamp uint64, - minExpiryLifetime uint64) bool { - - // Convert the expiry timestamp into a time.Time. - actualExpiry := time.Unix(int64(expiryUnixTimestamp), 0) - diff := actualExpiry.Unix() - time.Now().Unix() +func expiryWithinBounds(expiry time.Time, minExpiryLifetime uint64) bool { + diff := expiry.Unix() - time.Now().Unix() return diff >= int64(minExpiryLifetime) } @@ -547,8 +543,9 @@ func (n *Negotiator) HandleIncomingBuyAccept(msg rfqmsg.BuyAccept, // timestamp given the expiry timestamp in our outgoing buy request. // The expiry timestamp in the outgoing request relates to the lifetime // of the lightning invoice. - expiry := uint64(msg.AssetRate.Expiry.Unix()) - if !expiryWithinBounds(expiry, minAssetRatesExpiryLifetime) { + if !expiryWithinBounds( + msg.AssetRate.Expiry, minAssetRatesExpiryLifetime, + ) { // The expiry time is not within the acceptable bounds. log.Debugf("Buy accept quote expiry time is not within "+ "acceptable bounds (asset_rate=%s)", @@ -674,8 +671,9 @@ func (n *Negotiator) HandleIncomingSellAccept(msg rfqmsg.SellAccept, // // TODO(ffranr): Sanity check the quote expiry timestamp given // the expiry timestamp provided by the price oracle. - expiry := uint64(msg.AssetRate.Expiry.Unix()) - if !expiryWithinBounds(expiry, minAssetRatesExpiryLifetime) { + if !expiryWithinBounds( + msg.AssetRate.Expiry, minAssetRatesExpiryLifetime, + ) { // The expiry time is not within the acceptable bounds. log.Debugf("Sell accept quote expiry time is not within "+ "acceptable bounds (asset_rate=%s)", From 847b8525e862590fa729ed2c3946f7a11321ce84 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 14:29:42 +0000 Subject: [PATCH 12/15] rfqmsg: ensure Unix time conversion to time.Time uses UTC Set the converted `time.Time` to UTC when transforming from Unix time. This change promotes consistency and simplifies debugging. --- rfqmsg/buy_accept.go | 2 +- rfqmsg/buy_request.go | 2 +- rfqmsg/sell_request.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rfqmsg/buy_accept.go b/rfqmsg/buy_accept.go index c37317b10..54a3e87d1 100644 --- a/rfqmsg/buy_accept.go +++ b/rfqmsg/buy_accept.go @@ -65,7 +65,7 @@ func newBuyAcceptFromWireMsg(wireMsg WireMessage, assetRate := msgData.InAssetRate.Val.IntoBigIntFixedPoint() // Convert the unix timestamp in seconds to a time.Time. - expiry := time.Unix(int64(msgData.Expiry.Val), 0) + expiry := time.Unix(int64(msgData.Expiry.Val), 0).UTC() return &BuyAccept{ Peer: wireMsg.Peer, diff --git a/rfqmsg/buy_request.go b/rfqmsg/buy_request.go index e01b9cd6e..9832a71f7 100644 --- a/rfqmsg/buy_request.go +++ b/rfqmsg/buy_request.go @@ -127,7 +127,7 @@ func NewBuyRequestFromWire(wireMsg WireMessage, return nil, fmt.Errorf("expiry time exceeds maximum int64") } - expiry := time.Unix(int64(msgData.Expiry.Val), 0) + expiry := time.Unix(int64(msgData.Expiry.Val), 0).UTC() // Extract the suggested asset to BTC rate if provided. var assetRateHint fn.Option[AssetRate] diff --git a/rfqmsg/sell_request.go b/rfqmsg/sell_request.go index 8c8c84d92..d0721337b 100644 --- a/rfqmsg/sell_request.go +++ b/rfqmsg/sell_request.go @@ -121,7 +121,7 @@ func NewSellRequestFromWire(wireMsg WireMessage, "request") } - expiry := time.Unix(int64(msgData.Expiry.Val), 0) + expiry := time.Unix(int64(msgData.Expiry.Val), 0).UTC() // Extract the suggested asset to BTC rate if provided. var assetRateHint fn.Option[AssetRate] From e4bbef07b881e2cf8c81f8de83acca6e004f9f60 Mon Sep 17 00:00:00 2001 From: ffranr Date: Tue, 19 Nov 2024 11:22:54 +0000 Subject: [PATCH 13/15] rfqmsg: enforce strict version matching for requests Ensure the request message version check validates only a specific version number, rather than accepting any version greater than or equal to the latest message number. --- rfqmsg/buy_request.go | 2 +- rfqmsg/sell_request.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rfqmsg/buy_request.go b/rfqmsg/buy_request.go index 9832a71f7..c40c82115 100644 --- a/rfqmsg/buy_request.go +++ b/rfqmsg/buy_request.go @@ -167,7 +167,7 @@ func (q *BuyRequest) Validate() error { } // Ensure that the message version is supported. - if q.Version > latestBuyRequestVersion { + if q.Version != latestBuyRequestVersion { return fmt.Errorf("unsupported buy request message version: %d", q.Version) } diff --git a/rfqmsg/sell_request.go b/rfqmsg/sell_request.go index d0721337b..8324e7efe 100644 --- a/rfqmsg/sell_request.go +++ b/rfqmsg/sell_request.go @@ -161,7 +161,7 @@ func (q *SellRequest) Validate() error { } // Ensure that the message version is supported. - if q.Version > latestSellRequestVersion { + if q.Version != latestSellRequestVersion { return fmt.Errorf("unsupported sell request message version: "+ "%d", q.Version) } From ee8aac2ef6c42e5b333921ee23db322138e41fb3 Mon Sep 17 00:00:00 2001 From: ffranr Date: Fri, 15 Nov 2024 15:16:32 +0000 Subject: [PATCH 14/15] rfqmsg: populate transfer type field in request message --- rfqmsg/messages.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++ rfqmsg/request.go | 10 +++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/rfqmsg/messages.go b/rfqmsg/messages.go index 6b386a690..5d77d4913 100644 --- a/rfqmsg/messages.go +++ b/rfqmsg/messages.go @@ -90,6 +90,63 @@ func (id *ID) Record() tlv.Record { return tlv.MakeStaticRecord(0, id, recordSize, IdEncoder, IdDecoder) } +// TransferType defines the type of transaction which will be performed if the +// quote request leads to an accepted agreement. +type TransferType uint8 + +const ( + // UnspecifiedTransferType represents an undefined or transfer type. + UnspecifiedTransferType TransferType = 0 + + // PayInvoiceTransferType indicates that the requesting peer wants to + // pay a Lightning Network invoice using a taproot asset. + PayInvoiceTransferType TransferType = 1 + + // RecvPaymentTransferType indicates that the requesting peer wants + // to receive taproot asset funds linked to a Lightning Network invoice. + RecvPaymentTransferType TransferType = 2 +) + +// Record returns a TLV record that can be used to encode/decode a transfer type +// to/from a TLV stream. +// +// NOTE: This is part of the tlv.RecordProducer interface. +func (t *TransferType) Record() tlv.Record { + // Note that we set the type here as zero, as when used with a + // tlv.RecordT, the type param will be used as the type. + return tlv.MakeStaticRecord( + 0, t, 1, TransferTypeEncoder, TransferTypeDecoder, + ) +} + +// TransferTypeEncoder is a function that can be used to encode a TransferType +// to a writer. +func TransferTypeEncoder(w io.Writer, val any, buf *[8]byte) error { + if transferType, ok := val.(*TransferType); ok { + transferTypeInt := uint8(*transferType) + return tlv.EUint8(w, &transferTypeInt, buf) + } + + return tlv.NewTypeForEncodingErr(val, "TransferType") +} + +// TransferTypeDecoder is a function that can be used to decode a TransferType +// from a reader. +func TransferTypeDecoder(r io.Reader, val any, buf *[8]byte, l uint64) error { + if transferType, ok := val.(*TransferType); ok { + var transferTypeInt uint8 + err := tlv.DUint8(r, &transferTypeInt, buf, l) + if err != nil { + return err + } + + *transferType = TransferType(transferTypeInt) + return nil + } + + return tlv.NewTypeForDecodingErr(val, "TransferType", l, 8) +} + // AssetRate represents the exchange rate of an asset to BTC, encapsulating // both the rate in fixed-point format and an expiration timestamp. // diff --git a/rfqmsg/request.go b/rfqmsg/request.go index e37192314..adf317a4c 100644 --- a/rfqmsg/request.go +++ b/rfqmsg/request.go @@ -64,7 +64,9 @@ type requestWireMsgData struct { // ID is the unique identifier of the quote request. ID tlv.RecordT[tlv.TlvType2, ID] - // TODO(ffranr): Add transfer type field with TLV type 4. + // TransferType defines the type of transaction which will be performed + // if the quote request leads to an accepted agreement. + TransferType tlv.RecordT[tlv.TlvType4, TransferType] // Expiry is the Unix timestamp (in seconds) when the quote expires. // The quote becomes invalid after this time. @@ -129,6 +131,7 @@ type requestWireMsgData struct { func newRequestWireMsgDataFromBuy(q BuyRequest) (requestWireMsgData, error) { version := tlv.NewRecordT[tlv.TlvType0](q.Version) id := tlv.NewRecordT[tlv.TlvType2](q.ID) + transferType := tlv.NewRecordT[tlv.TlvType4](RecvPaymentTransferType) // Set the expiry to the default request lifetime unless an asset rate // hint is provided. @@ -179,6 +182,7 @@ func newRequestWireMsgDataFromBuy(q BuyRequest) (requestWireMsgData, error) { return requestWireMsgData{ Version: version, ID: id, + TransferType: transferType, Expiry: expiryTlv, InAssetID: inAssetID, InAssetGroupKey: inAssetGroupKey, @@ -194,6 +198,7 @@ func newRequestWireMsgDataFromBuy(q BuyRequest) (requestWireMsgData, error) { func newRequestWireMsgDataFromSell(q SellRequest) (requestWireMsgData, error) { version := tlv.NewPrimitiveRecord[tlv.TlvType0](q.Version) id := tlv.NewRecordT[tlv.TlvType2](q.ID) + transferType := tlv.NewRecordT[tlv.TlvType4](PayInvoiceTransferType) // Set the expiry to the default request lifetime unless an asset rate // hint is provided. @@ -247,6 +252,7 @@ func newRequestWireMsgDataFromSell(q SellRequest) (requestWireMsgData, error) { return requestWireMsgData{ Version: version, ID: id, + TransferType: transferType, Expiry: expiryTlv, InAssetID: inAssetID, OutAssetID: outAssetID, @@ -328,6 +334,7 @@ func (m *requestWireMsgData) Encode(w io.Writer) error { records := []tlv.Record{ m.Version.Record(), m.ID.Record(), + m.TransferType.Record(), m.Expiry.Record(), m.MaxInAsset.Record(), } @@ -408,6 +415,7 @@ func (m *requestWireMsgData) Decode(r io.Reader) error { tlvStream, err := tlv.NewStream( m.Version.Record(), m.ID.Record(), + m.TransferType.Record(), m.Expiry.Record(), inAssetID.Record(), From a1d989d7c449ae27cf0570d6308462da6a28b426 Mon Sep 17 00:00:00 2001 From: ffranr Date: Tue, 19 Nov 2024 12:49:48 +0000 Subject: [PATCH 15/15] rfqmsg: use transfer type for buy/sell request classification Update the buy/sell classification logic for incoming requests to rely on the new transfer type field. When the requesting peer attempts to pay an invoice using a Tap asset, they are "selling" the Tap asset to the edge node. Conversely, when the requesting peer attempts to receive a Tap asset as payment to settle an invoice, they are "buying" the Tap asset from the edge node. --- rfqmsg/request.go | 52 +++++++++++++---------------------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/rfqmsg/request.go b/rfqmsg/request.go index adf317a4c..df1f1d81b 100644 --- a/rfqmsg/request.go +++ b/rfqmsg/request.go @@ -509,44 +509,20 @@ func NewIncomingRequestFromWire(wireMsg WireMessage) (IncomingMsg, error) { "request: %w", err) } - // We will now determine whether this is a buy or sell request. We - // currently only support exchanging a taproot asset for BTC. Therefore, - // we can distinguish between buy/sell requests by identifying the all - // zero in/out asset ID which designates BTC. - isBuyRequest := false - - // Check the outgoing asset ID to determine if this is a buy request. - msgData.OutAssetID.WhenSome( - func(outAssetID tlv.RecordT[tlv.TlvType13, asset.ID]) { - var zeroAssetID [32]byte - - // If the outgoing asset ID is all zeros (signifying - // BTC), then this is a buy request. In other words, the - // incoming asset is the taproot asset, and the outgoing - // asset is BTC. - isBuyRequest = outAssetID.Val == zeroAssetID - }, - ) - - // The outgoing asset ID may not be set, but the outgoing asset group - // key may be set. If the outbound asset group key is not specified - // (and the outbound asset ID is not set), then this is a buy request. - // In other words, only the inbound asset is specified, and the outbound - // asset is BTC. - msgData.OutAssetGroupKey.WhenSome( - func(gk tlv.RecordT[tlv.TlvType15, *btcec.PublicKey]) { - // Here we carry through any ture value of isBuyRequest - // from the previous check. - isBuyRequest = isBuyRequest || (gk.Val != nil) - }, - ) - - // If this is a buy request, then we will create a new buy request - // message. - if isBuyRequest { + // Classify the incoming request as a buy or sell. + // + // When the requesting peer attempts to pay an invoice using a Tap + // asset, they are "selling" the Tap asset to the edge node. Conversely, + // when the requesting peer attempts to receive a Tap asset as payment + // to settle an invoice, they are "buying" the Tap asset from the edge + // node. + switch msgData.TransferType.Val { + case PayInvoiceTransferType: + return NewSellRequestFromWire(wireMsg, msgData) + case RecvPaymentTransferType: return NewBuyRequestFromWire(wireMsg, msgData) + default: + return nil, fmt.Errorf("unknown incoming request message "+ + "transfer type: %d", msgData.TransferType.Val) } - - // Otherwise, this is a sell request. - return NewSellRequestFromWire(wireMsg, msgData) }