Skip to content

Commit

Permalink
Merge pull request #861 from lightninglabs/rfq-buy-offer
Browse files Browse the repository at this point in the history
Add RFQ buy offer
  • Loading branch information
ffranr authored Apr 3, 2024
2 parents 2affd4e + 2abddfc commit c723abd
Show file tree
Hide file tree
Showing 13 changed files with 1,037 additions and 185 deletions.
15 changes: 13 additions & 2 deletions itest/rfq_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,19 @@ func testRfqAssetSellHtlcIntercept(t *harnessTest) {
ctxt, cancel := context.WithTimeout(ctxb, defaultWaitTimeout)
defer cancel()

// TODO(ffranr): Add an asset buy offer to Bob's tapd node. This will
// allow Alice to sell the newly minted asset to Bob.
// Upsert an asset buy offer to Bob's tapd node. This will allow Bob to
// buy the newly minted asset from Alice.
_, err := ts.BobTapd.AddAssetBuyOffer(
ctxt, &rfqrpc.AddAssetBuyOfferRequest{
AssetSpecifier: &rfqrpc.AssetSpecifier{
Id: &rfqrpc.AssetSpecifier_AssetId{
AssetId: mintedAssetId,
},
},
MaxUnits: 1000,
},
)
require.NoError(t.t, err, "unable to upsert asset buy offer")

// Subscribe to Alice's RFQ events stream.
aliceEventNtfns, err := ts.AliceTapd.SubscribeRfqEventNtfns(
Expand Down
4 changes: 4 additions & 0 deletions perms/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ var (
Entity: "rfq",
Action: "write",
}},
"/rfqrpc.Rfq/AddAssetBuyOffer": {{
Entity: "rfq",
Action: "write",
}},
"/rfqrpc.Rfq/QueryPeerAcceptedQuotes": {{
Entity: "rfq",
Action: "read",
Expand Down
12 changes: 12 additions & 0 deletions rfq/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,18 @@ func (m *Manager) RemoveAssetSellOffer(assetID *asset.ID,
return nil
}

// UpsertAssetBuyOffer upserts an asset buy offer for management by the RFQ
// system. If the offer already exists for the given asset, it will be updated.
func (m *Manager) UpsertAssetBuyOffer(offer BuyOffer) error {
// Store the asset buy offer in the negotiator.
err := m.negotiator.UpsertAssetBuyOffer(offer)
if err != nil {
return fmt.Errorf("error registering asset buy offer: %w", err)
}

return nil
}

// BuyOrder is a struct that represents a buy order.
type BuyOrder struct {
// AssetID is the ID of the asset that the buyer is interested in.
Expand Down
179 changes: 172 additions & 7 deletions rfq/negotiator.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@ type Negotiator struct {

// assetGroupSellOffers is a map (keyed on asset group key) that holds
// asset sell offers.
assetGroupSellOffers lnutils.SyncMap[btcec.PublicKey, SellOffer]
assetGroupSellOffers lnutils.SyncMap[asset.SerializedKey, SellOffer]

// assetBuyOffers is a map (keyed on asset ID) that holds asset buy
// offers.
assetBuyOffers lnutils.SyncMap[asset.ID, BuyOffer]

// assetGroupBuyOffers is a map (keyed on asset group key) that holds
// asset buy offers.
assetGroupBuyOffers lnutils.SyncMap[asset.SerializedKey, BuyOffer]

// ContextGuard provides a wait group and main quit channel that can be
// used to create guarded contexts.
Expand All @@ -61,7 +69,11 @@ func NewNegotiator(cfg NegotiatorCfg) (*Negotiator, error) {

assetSellOffers: lnutils.SyncMap[asset.ID, SellOffer]{},
assetGroupSellOffers: lnutils.SyncMap[
btcec.PublicKey, SellOffer]{},
asset.SerializedKey, SellOffer]{},

assetBuyOffers: lnutils.SyncMap[asset.ID, BuyOffer]{},
assetGroupBuyOffers: lnutils.SyncMap[
asset.SerializedKey, BuyOffer]{},

ContextGuard: &fn.ContextGuard{
DefaultTimeout: DefaultTimeout,
Expand Down Expand Up @@ -305,8 +317,33 @@ func (n *Negotiator) HandleIncomingBuyRequest(
func (n *Negotiator) HandleIncomingSellRequest(
request rfqmsg.SellRequest) error {

// TODO(ffranr): Ensure that we have a suitable buy offer for the asset
// that our peer is trying to sell to us.
// The sell request is attempting to sell some amount of an asset to our
// node. Here we ensure that we have a suitable buy offer for the asset.
// A buy offer is the criteria that this node uses to determine whether
// it is willing to buy a particular asset (before price is considered).
// At this point we can handle the case where this node does not wish
// to buy some amount of a particular asset regardless of its price.
offerAvailable := n.HasAssetBuyOffer(
request.AssetID, request.AssetGroupKey, request.AssetAmount,
)
if !offerAvailable {
// If we do not have a suitable buy offer, then we will reject
// the asset sell quote request with an error.
reject := rfqmsg.NewReject(
request.Peer, request.ID, rfqmsg.ErrNoSuitableBuyOffer,
)
var msg rfqmsg.OutgoingMsg = reject

sendSuccess := fn.SendOrQuit(
n.cfg.OutgoingMessages, msg, n.Quit,
)
if !sendSuccess {
return fmt.Errorf("negotiator failed to send reject " +
"message")
}

return nil
}

// Define a thread safe helper function for adding outgoing message to
// the outgoing messages channel.
Expand Down Expand Up @@ -461,7 +498,13 @@ func (n *Negotiator) UpsertAssetSellOffer(offer SellOffer) error {
// the offer. Otherwise, we will use the asset ID as the key.
switch {
case offer.AssetGroupKey != nil:
n.assetGroupSellOffers.Store(*offer.AssetGroupKey, offer)
// We will serialize the public key to a fixed size byte array
// before using it as a map key. This is because functionally
// identical public keys can have different internal
// representations. These differences would cause the map to
// treat them as different keys.
keyFixedBytes := asset.ToSerialized(offer.AssetGroupKey)
n.assetGroupSellOffers.Store(keyFixedBytes, offer)

case offer.AssetID != nil:
n.assetSellOffers.Store(*offer.AssetID, offer)
Expand All @@ -480,7 +523,8 @@ func (n *Negotiator) RemoveAssetSellOffer(assetID *asset.ID,
// the offer. Otherwise, we will use the asset ID as the key.
switch {
case assetGroupKey != nil:
n.assetGroupSellOffers.Delete(*assetGroupKey)
keyFixedBytes := asset.ToSerialized(assetGroupKey)
n.assetGroupSellOffers.Delete(keyFixedBytes)

case assetID != nil:
n.assetSellOffers.Delete(*assetID)
Expand All @@ -505,7 +549,8 @@ func (n *Negotiator) HasAssetSellOffer(assetID *asset.ID,
var sellOffer *SellOffer
switch {
case assetGroupKey != nil:
offer, ok := n.assetGroupSellOffers.Load(*assetGroupKey)
keyFixedBytes := asset.ToSerialized(assetGroupKey)
offer, ok := n.assetGroupSellOffers.Load(keyFixedBytes)
if !ok {
// Corresponding offer not found.
return false
Expand Down Expand Up @@ -541,6 +586,126 @@ func (n *Negotiator) HasAssetSellOffer(assetID *asset.ID,
return true
}

// BuyOffer is a struct that represents an asset buy offer. This data structure
// describes the maximum amount of an asset that this node is willing to
// purchase.
//
// A buy offer is passive (unlike a buy order), meaning that it does not
// actively lead to a buy request being sent to a peer. Instead, it is used by
// the node to selectively accept or reject incoming asset sell quote requests
// before price is considered.
type BuyOffer struct {
// AssetID represents the identifier of the subject asset.
AssetID *asset.ID

// AssetGroupKey is the public group key of the subject asset.
AssetGroupKey *btcec.PublicKey

// MaxUnits is the maximum amount of the asset which this node is
// willing to purchase.
MaxUnits uint64
}

// Validate validates the asset buy offer.
func (a *BuyOffer) Validate() error {
if a.AssetID == nil && a.AssetGroupKey == nil {
return fmt.Errorf("asset ID is nil and asset group key is nil")
}

if a.AssetID != nil && a.AssetGroupKey != nil {
return fmt.Errorf("asset ID and asset group key are both set")
}

if a.MaxUnits == 0 {
return fmt.Errorf("max asset amount is zero")
}

return nil
}

// UpsertAssetBuyOffer upserts an asset buy offer. If the offer already exists
// for the given asset, it will be updated.
func (n *Negotiator) UpsertAssetBuyOffer(offer BuyOffer) error {
// Validate the offer.
err := offer.Validate()
if err != nil {
return fmt.Errorf("invalid asset buy offer: %w", err)
}

// Store the offer in the appropriate map.
//
// If the asset group key is not nil, then we will use it as the key for
// the offer. Otherwise, we will use the asset ID as the key.
switch {
case offer.AssetGroupKey != nil:
// We will serialize the public key to a fixed size byte array
// before using it as a map key. This is because functionally
// identical public keys can have different internal
// representations. These differences would cause the map to
// treat them as different keys.
keyFixedBytes := asset.ToSerialized(offer.AssetGroupKey)
n.assetGroupBuyOffers.Store(keyFixedBytes, offer)

case offer.AssetID != nil:
n.assetBuyOffers.Store(*offer.AssetID, offer)
}

return nil
}

// HasAssetBuyOffer returns true if the negotiator has an asset buy offer which
// matches the given asset ID/group and asset amount.
//
// TODO(ffranr): This method should return errors which can be used to
// differentiate between a missing offer and an invalid offer.
func (n *Negotiator) HasAssetBuyOffer(assetID *asset.ID,
assetGroupKey *btcec.PublicKey, assetAmt uint64) bool {

// If the asset group key is not nil, then we will use it as the lookup
// key to retrieve an offer. Otherwise, we will use the asset ID as the
// lookup key.
var buyOffer *BuyOffer
switch {
case assetGroupKey != nil:
keyFixedBytes := asset.ToSerialized(assetGroupKey)
offer, ok := n.assetGroupBuyOffers.Load(keyFixedBytes)
if !ok {
// Corresponding offer not found.
return false
}

buyOffer = &offer

case assetID != nil:
offer, ok := n.assetBuyOffers.Load(*assetID)
if !ok {
// Corresponding offer not found.
return false
}

buyOffer = &offer
}

// We should never have a nil buy offer at this point. Check added here
// for robustness.
if buyOffer == nil {
return false
}

// If the asset amount is greater than the maximum asset amount under
// offer, then we will return false (we do not have a suitable offer).
if assetAmt > buyOffer.MaxUnits {
// At this point, the sell request is asking us to buy more of
// the asset than we are willing to purchase.
log.Warnf("asset amount is greater than buy offer max units "+
"(asset_amt=%d, buy_offer_max_units=%d)", assetAmt,
buyOffer.MaxUnits)
return false
}

return true
}

// Start starts the service.
func (n *Negotiator) Start() error {
var startErr error
Expand Down
7 changes: 7 additions & 0 deletions rfqmsg/reject.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ var (
Code: 1,
Msg: "no suitable sell offer available",
}

// ErrNoSuitableBuyOffer is the error code for when there is no suitable
// buy offer available.
ErrNoSuitableBuyOffer = RejectErr{
Code: 2,
Msg: "no suitable buy offer available",
}
)

// rejectMsgData is a struct that represents the data field of a quote
Expand Down
35 changes: 35 additions & 0 deletions rpcserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -5914,6 +5914,41 @@ func (r *rpcServer) AddAssetSellOffer(_ context.Context,
return &rfqrpc.AddAssetSellOfferResponse{}, nil
}

// AddAssetBuyOffer upserts a new buy offer for the given asset into the RFQ
// manager. If the offer already exists for the given asset, it will be updated.
//
// A buy offer is used by the node to selectively accept or reject incoming
// asset sell quote requests before price is considered.
func (r *rpcServer) AddAssetBuyOffer(_ context.Context,
req *rfqrpc.AddAssetBuyOfferRequest) (*rfqrpc.AddAssetBuyOfferResponse,
error) {

// Unmarshal the asset specifier from the RPC form.
assetID, assetGroupKey, err := unmarshalAssetSpecifier(
req.AssetSpecifier,
)
if err != nil {
return nil, fmt.Errorf("error unmarshalling asset specifier: "+
"%w", err)
}

// Upsert the offer into the RFQ manager.
buyOffer := rfq.BuyOffer{
AssetID: assetID,
AssetGroupKey: assetGroupKey,
MaxUnits: req.MaxUnits,
}
rpcsLog.Debugf("[AddAssetBuyOffer]: upserting buy offer (buy_offer=%v)",
buyOffer)
err = r.cfg.RfqManager.UpsertAssetBuyOffer(buyOffer)
if err != nil {
return nil, fmt.Errorf("error upserting buy offer into RFQ "+
"manager: %w", err)
}

return &rfqrpc.AddAssetBuyOfferResponse{}, nil
}

// marshalPeerAcceptedBuyQuotes marshals a map of peer accepted asset buy quotes
// into the RPC form. These are quotes that were requested by our node and have
// been accepted by our peers.
Expand Down
Loading

0 comments on commit c723abd

Please sign in to comment.