diff --git a/.gitignore b/.gitignore index e6cc0d1b4a..9ed1ba78bc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ cmd/tapd/tapd /itest/custom_channels/*.log.* /docs/examples/basic-price-oracle/basic-price-oracle +/docs/examples/basic-portfolio-pilot/basic-portfolio-pilot +basic-portfolio-pilot # Test binary, built with `go test -c` *.test diff --git a/docs/examples/basic-portfolio-pilot/main.go b/docs/examples/basic-portfolio-pilot/main.go index a35de9f5ac..898f1f7153 100644 --- a/docs/examples/basic-portfolio-pilot/main.go +++ b/docs/examples/basic-portfolio-pilot/main.go @@ -104,9 +104,19 @@ func (p *RpcPortfolioPilotServer) ResolveRequest(_ context.Context, var hint *portfoliopilotrpc.AssetRate switch r := req.GetRequest().(type) { case *portfoliopilotrpc.ResolveRequestRequest_BuyRequest: - hint = r.BuyRequest.GetAssetRateHint() + br := r.BuyRequest + hint = br.GetAssetRateHint() + log.Printf("ResolveRequest buy: max=%d min=%d "+ + "rate_limit=%v", br.GetAssetMaxAmount(), + br.GetAssetMinAmount(), + br.GetAssetRateLimit()) case *portfoliopilotrpc.ResolveRequestRequest_SellRequest: - hint = r.SellRequest.GetAssetRateHint() + sr := r.SellRequest + hint = sr.GetAssetRateHint() + log.Printf("ResolveRequest sell: max=%d min=%d "+ + "rate_limit=%v", sr.GetPaymentMaxAmount(), + sr.GetPaymentMinAmount(), + sr.GetAssetRateLimit()) default: return nil, fmt.Errorf("unknown request type: %T", r) } diff --git a/docs/release-notes/release-notes-0.8.0.md b/docs/release-notes/release-notes-0.8.0.md index 2221af8739..bd772172fa 100644 --- a/docs/release-notes/release-notes-0.8.0.md +++ b/docs/release-notes/release-notes-0.8.0.md @@ -103,6 +103,30 @@ **Note:** For full functionality, it is highly recommended to start LND with the `--store-final-htlc-resolutions` flag enabled, which is disabled by default. +- [Limit-Order Constraints](https://github.com/lightninglabs/taproot-assets/pull/2048): + RFQ buy and sell orders can now carry explicit limit-price bounds + (`asset_rate_limit`) and minimum fill sizes (`asset_min_amt` / + `payment_min_amt`). Quotes that violate these constraints are rejected + with machine-readable reasons (`RATE_BOUND_MISS`, `MIN_FILL_NOT_MET`). + New fields are optional and backward-compatible; constraint validation + only activates when they are present. + +- [Execution Policy](https://github.com/lightninglabs/taproot-assets/pull/2049): + RFQ buy and sell orders can now specify an execution policy: IOC + (Immediate-Or-Cancel, the default) allows partial fills above the + minimum, while FOK (Fill-Or-Kill) requires the full requested amount + or rejects the quote. FOK viability is checked in `VerifyAcceptQuote` + with a new `FOK_NOT_VIABLE` reject code. New fields are optional and + backward-compatible. + +- [Fill Quantity Negotiation](https://github.com/lightninglabs/taproot-assets/pull/2050): + RFQ accept messages now carry an optional fill quantity, allowing + the responder to signal the maximum amount it is willing to fill. + The negotiated fill caps HTLC policies so forwarding never exceeds + the agreed amount. `VerifyAcceptQuote` validates the fill against + request constraints (min fill, FOK viability). Zero fill on the + wire is normalised to None for backward compatibility. + ## Functional Enhancements - [Wallet Backup/Restore](https://github.com/lightninglabs/taproot-assets/pull/1980): @@ -148,6 +172,26 @@ Add `RemoveMessage` RPC to the auth mailbox service. Receivers can authenticate with a Schnorr signature to delete their own messages by ID. +- [PR#2048](https://github.com/lightninglabs/taproot-assets/pull/2048): + Add `asset_rate_limit` to `AddAssetBuyOrder` and `AddAssetSellOrder` + requests. Add `asset_min_amt` to buy orders and `payment_min_amt` + to sell orders. Add `asset_rate_limit` and min fill fields to + `PortfolioPilot.ResolveRequest` for constraint forwarding. Add + `RATE_BOUND_MISS` and `MIN_FILL_NOT_MET` to `QuoteRespStatus`. + +- [PR#2049](https://github.com/lightninglabs/taproot-assets/pull/2049): + Add `execution_policy` enum (`EXECUTION_POLICY_IOC`, + `EXECUTION_POLICY_FOK`) to `AddAssetBuyOrder` and `AddAssetSellOrder` + requests, and to `PortfolioPilot.ResolveRequest` for constraint + forwarding. Add `FOK_NOT_VIABLE` to `QuoteRespStatus`. + +- [PR#2050](https://github.com/lightninglabs/taproot-assets/pull/2050): + Add `max_in_asset` to `PeerAcceptedBuyQuote` and + `PeerAcceptedSellQuote` in both the RFQ and PortfolioPilot + services, exposing the negotiated fill quantity to RPC clients. + Add `fill_amount` to `PortfolioPilot.ResolveResponse` for + responder-side fill signalling. + ## tapcli Additions - [Wallet Backup CLI](https://github.com/lightninglabs/taproot-assets/pull/1980): @@ -331,6 +375,19 @@ New integration test `testForwardingEventHistory` verifies that forwarding events are properly logged when routing asset payments. +- [PR#2048](https://github.com/lightninglabs/taproot-assets/pull/2048): + Add unit, property-based, and integration tests for limit-order + constraint fields. + +- [PR#2049](https://github.com/lightninglabs/taproot-assets/pull/2049): + Add unit, property-based, and integration tests for execution policy. + +- [PR#2050](https://github.com/lightninglabs/taproot-assets/pull/2050): + Add unit and integration tests for fill quantity negotiation, + including wire roundtrip, zero-normalisation, fill-vs-constraint + validation, sell-side FOK/IOC with fill caps, and responder + constraint rejection. + ## Database - [forwards table](https://github.com/lightninglabs/taproot-assets/pull/1921): @@ -340,8 +397,18 @@ Add `DeleteUniverseLeaf` SQL query for single-leaf deletion from a universe. +- [PR#2050](https://github.com/lightninglabs/taproot-assets/pull/2050) + Add `accepted_max_amount` column to the `rfq_policies` table + (migration 55) to persist the negotiated fill quantity alongside + HTLC policies. + ## Code Health +- [PR#2050](https://github.com/lightninglabs/taproot-assets/pull/2050) + Unify buy and sell request constraint logic behind a shared + `RequestConstraints` interface, removing duplicated validation + in `VerifyAcceptQuote` and `checkRateBound`. + ## Tooling and Documentation - [Wallet Backup Format Spec](https://github.com/lightninglabs/taproot-assets/pull/1980): diff --git a/itest/custom_channels/custom_channels_test.go b/itest/custom_channels/custom_channels_test.go index 00614d383c..292ab4c78d 100644 --- a/itest/custom_channels/custom_channels_test.go +++ b/itest/custom_channels/custom_channels_test.go @@ -99,6 +99,10 @@ var testCases = []*ccTestCase{ name: "invoice quote expiry mismatch", test: testCustomChannelsInvoiceQuoteExpiryMismatch, }, + { + name: "limit constraints", + test: testCustomChannelsLimitConstraints, + }, { name: "fee", test: testCustomChannelsFee, diff --git a/itest/custom_channels/limit_constraints_test.go b/itest/custom_channels/limit_constraints_test.go new file mode 100644 index 0000000000..0b616986a5 --- /dev/null +++ b/itest/custom_channels/limit_constraints_test.go @@ -0,0 +1,365 @@ +//go:build itest + +package custom_channels + +import ( + "context" + "fmt" + "math" + "math/big" + "slices" + "time" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/itest" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/lightningnetwork/lnd/lntest/port" + "github.com/stretchr/testify/require" +) + +// testCustomChannelsLimitConstraints verifies that RFQ limit-order +// constraints (asset_rate_limit, payment_min_amt) work correctly in +// the context of real asset channels. It negotiates a sell quote with +// satisfied constraints, then sends a payment using that quote. +// +//nolint:lll +func testCustomChannelsLimitConstraints(_ context.Context, + net *itest.IntegratedNetworkHarness, t *ccHarnessTest) { + + usdMetaData := &taprpc.AssetMeta{ + Data: []byte(`{ +"description":"USD stablecoin for limit constraint test" +}`), + Type: taprpc.AssetMetaType_META_TYPE_JSON, + } + + const decimalDisplay = 6 + tcAsset := &mintrpc.MintAsset{ + AssetType: taprpc.AssetType_NORMAL, + Name: "USD-limits", + AssetMeta: usdMetaData, + Amount: 1_000_000_000_000, + DecimalDisplay: decimalDisplay, + } + + oracleAddr := fmt.Sprintf( + "localhost:%d", port.NextAvailablePort(), + ) + oracle := itest.NewOracleHarness(oracleAddr) + oracle.Start(t.t) + t.t.Cleanup(oracle.Stop) + + lndArgs := slices.Clone(lndArgsTemplate) + tapdArgs := slices.Clone(tapdArgsTemplateNoOracle) + tapdArgs = append(tapdArgs, fmt.Sprintf( + "--experimental.rfq.priceoracleaddress=rfqrpc://%s", + oracleAddr, + )) + tapdArgs = append( + tapdArgs, + "--experimental.rfq.priceoracletlsinsecure", + ) + + charliePort := port.NextAvailablePort() + tapdArgs = append(tapdArgs, fmt.Sprintf( + "--proofcourieraddr=%s://%s", + proof.UniverseRpcCourierType, + fmt.Sprintf(node.ListenerFormat, charliePort), + )) + + // Topology: Charlie --[assets]--> Dave --[sats]--> Erin + charlieLndArgs := slices.Clone(lndArgs) + charlieLndArgs = append(charlieLndArgs, fmt.Sprintf( + "--rpclisten=127.0.0.1:%d", charliePort, + )) + charlie := net.NewNode("Charlie", charlieLndArgs, tapdArgs) + dave := net.NewNode("Dave", lndArgs, tapdArgs) + erin := net.NewNode("Erin", lndArgs, tapdArgs) + + nodes := []*itest.IntegratedNode{charlie, dave, erin} + connectAllNodes(t.t, net, nodes) + fundAllNodes(t.t, net, nodes) + + // Open a normal BTC channel between Dave and Erin. + const btcChannelFundingAmount = 10_000_000 + chanPointDE := openChannelAndAssert( + t, net, dave, erin, lntest.OpenChannelParams{ + Amt: btcChannelFundingAmount, + SatPerVByte: 5, + }, + ) + defer closeChannelAndAssert(t, net, dave, chanPointDE, false) + + assertChannelKnown(t.t, charlie, chanPointDE) + + // Mint on Charlie. + mintedAssets := itest.MintAssetsConfirmBatch( + t.t, net.Miner.Client, asTapd(charlie), + []*mintrpc.MintAssetRequest{ + {Asset: tcAsset}, + }, + ) + usdAsset := mintedAssets[0] + assetID := usdAsset.AssetGenesis.AssetId + + var id asset.ID + copy(id[:], assetID) + + // Oracle price: ~66,548.40 USD/BTC with decimal display 6. + salePrice := rfqmath.NewBigIntFixedPoint(65_217_43, 2) + purchasePrice := rfqmath.NewBigIntFixedPoint(67_879_37, 2) + factor := rfqmath.NewBigInt( + big.NewInt(int64(math.Pow10(decimalDisplay))), + ) + salePrice.Coefficient = salePrice.Coefficient.Mul(factor) + purchasePrice.Coefficient = purchasePrice.Coefficient.Mul( + factor, + ) + oracle.SetPrice( + asset.NewSpecifierFromId(id), purchasePrice, salePrice, + ) + + t.Logf("Syncing universes...") + syncUniverses(t.t, charlie, dave, erin) + + // Send assets to Dave so he has a balance. + const sendAmount = uint64(400_000_000) + charlieFundingAmount := usdAsset.Amount - sendAmount + + ctxb := context.Background() + daveAddr, err := dave.NewAddr(ctxb, &taprpc.NewAddrRequest{ + Amt: sendAmount, + AssetId: assetID, + ProofCourierAddr: fmt.Sprintf( + "%s://%s", proof.UniverseRpcCourierType, + charlie.RPCAddr(), + ), + }) + require.NoError(t.t, err) + + sendResp, err := charlie.SendAsset( + ctxb, &taprpc.SendAssetRequest{ + TapAddrs: []string{daveAddr.Encoded}, + }, + ) + require.NoError(t.t, err) + itest.ConfirmAndAssertOutboundTransfer( + t.t, net.Miner.Client, asTapd(charlie), sendResp, + assetID, + []uint64{usdAsset.Amount - sendAmount, sendAmount}, + 0, 1, + ) + itest.AssertNonInteractiveRecvComplete(t.t, asTapd(dave), 1) + + // Open asset channel Charlie → Dave. + t.Logf("Opening asset channel Charlie → Dave...") + net.EnsureConnected(t.t, charlie, dave) + fundResp, err := charlie.FundChannel( + ctxb, &tchrpc.FundChannelRequest{ + AssetAmount: charlieFundingAmount, + AssetId: assetID, + PeerPubkey: dave.PubKey[:], + FeeRateSatPerVbyte: 5, + PushSat: DefaultPushSat, + }, + ) + require.NoError(t.t, err) + t.Logf("Funded channel: %v", fundResp) + + mineBlocks(t, net, 6, 1) + + chanPointCD := &lnrpc.ChannelPoint{ + OutputIndex: uint32(fundResp.OutputIndex), + FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: fundResp.Txid, + }, + } + + logBalance(t.t, nodes, assetID, "after channel open") + + // ----------------------------------------------------------------- + // Negotiate a sell order from Charlie with constraints. + // Rate limit is set well above the oracle rate (ceiling for + // sell), so the constraint is satisfied. + // ----------------------------------------------------------------- + t.Logf("Negotiating sell order with constraints...") + + inOneHour := time.Now().Add(time.Hour) + sellResp, err := asTapd(charlie).RfqClient.AddAssetSellOrder( + ctxb, &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + PaymentMaxAmt: 180_000_000, + AssetRateLimit: &rfqrpc.FixedPoint{ + // Ceiling well above oracle rate. + Coefficient: "100000000000000", + Scale: 2, + }, + Expiry: uint64(inOneHour.Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 10, + }, + ) + require.NoError(t.t, err, "sell order with constraints") + + accepted := sellResp.GetAcceptedQuote() + require.NotNil(t.t, accepted, "expected accepted sell quote") + t.Logf("Sell quote accepted: scid=%d", accepted.Scid) + + // ----------------------------------------------------------------- + // Pay an invoice using the pre-negotiated quote. + // ----------------------------------------------------------------- + t.Logf("Paying invoice with constrained quote...") + + // Erin has no asset channel, so we create a regular BTC + // invoice. Charlie pays it with assets via the sell quote. + const invoiceMsat = 100_000_000 // 100K sats + invoiceResp, err := erin.LightningClient.AddInvoice( + ctxb, &lnrpc.Invoice{ + ValueMsat: invoiceMsat, + }, + ) + require.NoError(t.t, err) + + var quoteID rfqmsg.ID + copy(quoteID[:], accepted.Id) + + numUnits, _ := payInvoiceWithAssets( + t.t, charlie, dave, + invoiceResp.PaymentRequest, + assetID, withRFQ(quoteID), + ) + require.Greater(t.t, numUnits, uint64(0)) + + logBalance(t.t, nodes, assetID, "after payment") + t.Logf("Payment completed: %d asset units sent", numUnits) + + // ----------------------------------------------------------------- + // Negotiate a sell order from Charlie with FOK policy. + // Rate limit ceiling is generous and the payment max is + // large enough that FOK conversion yields non-zero units. + // ----------------------------------------------------------------- + t.Logf("Negotiating sell order with FOK policy...") + + sellRespFOK, err := asTapd(charlie).RfqClient.AddAssetSellOrder( + ctxb, &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + PaymentMaxAmt: 180_000_000, + AssetRateLimit: &rfqrpc.FixedPoint{ + Coefficient: "100000000000000", + Scale: 2, + }, + ExecutionPolicy: rfqrpc.ExecutionPolicy_EXECUTION_POLICY_FOK, + Expiry: uint64(inOneHour.Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 10, + }, + ) + require.NoError(t.t, err, "sell order with FOK policy") + + acceptedFOK := sellRespFOK.GetAcceptedQuote() + require.NotNil( + t.t, acceptedFOK, "expected accepted FOK sell quote", + ) + t.Logf("FOK sell quote accepted: scid=%d", acceptedFOK.Scid) + + // Pay using the FOK quote with a regular BTC invoice. + invoiceRespFOK, err := erin.LightningClient.AddInvoice( + ctxb, &lnrpc.Invoice{ + ValueMsat: invoiceMsat, + }, + ) + require.NoError(t.t, err) + + var quoteIDFOK rfqmsg.ID + copy(quoteIDFOK[:], acceptedFOK.Id) + + numUnitsFOK, _ := payInvoiceWithAssets( + t.t, charlie, dave, + invoiceRespFOK.PaymentRequest, + assetID, withRFQ(quoteIDFOK), + ) + require.Greater(t.t, numUnitsFOK, uint64(0)) + + logBalance(t.t, nodes, assetID, "after FOK payment") + t.Logf("FOK payment completed: %d units sent", numUnitsFOK) + + // ----------------------------------------------------------------- + // Negotiate a sell order with explicit IOC policy. + // This mirrors the implicit-IOC block above but sets the + // execution policy explicitly to prove the RPC surface + // accepts and correctly handles the value end-to-end. + // ----------------------------------------------------------------- + t.Logf("Negotiating sell order with explicit IOC policy...") + + sellRespIOC, err := asTapd(charlie).RfqClient.AddAssetSellOrder( + ctxb, &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: assetID, + }, + }, + PaymentMaxAmt: 180_000_000, + PaymentMinAmt: 1000, + AssetRateLimit: &rfqrpc.FixedPoint{ + Coefficient: "100000000000000", + Scale: 2, + }, + ExecutionPolicy: rfqrpc.ExecutionPolicy_EXECUTION_POLICY_IOC, + Expiry: uint64(inOneHour.Unix()), + PeerPubKey: dave.PubKey[:], + TimeoutSeconds: 10, + }, + ) + require.NoError(t.t, err, "sell order with explicit IOC") + + acceptedIOC := sellRespIOC.GetAcceptedQuote() + require.NotNil( + t.t, acceptedIOC, + "expected accepted IOC sell quote", + ) + t.Logf("IOC sell quote accepted: scid=%d", acceptedIOC.Scid) + + // Pay using the IOC quote with a regular BTC invoice. + invoiceRespIOC, err := erin.LightningClient.AddInvoice( + ctxb, &lnrpc.Invoice{ + ValueMsat: invoiceMsat, + }, + ) + require.NoError(t.t, err) + + var quoteIDIOC rfqmsg.ID + copy(quoteIDIOC[:], acceptedIOC.Id) + + numUnitsIOC, _ := payInvoiceWithAssets( + t.t, charlie, dave, invoiceRespIOC.PaymentRequest, + assetID, withRFQ(quoteIDIOC), + ) + require.Greater(t.t, numUnitsIOC, uint64(0)) + + logBalance(t.t, nodes, assetID, "after explicit IOC payment") + t.Logf("IOC payment completed: %d units sent", numUnitsIOC) + + // Close channels. + closeAssetChannelAndAssert( + t, net, charlie, dave, chanPointCD, + [][]byte{assetID}, nil, charlie, + noOpCoOpCloseBalanceCheck, + ) +} diff --git a/itest/portfolio_pilot_harness.go b/itest/portfolio_pilot_harness.go index ec23785c98..ad86424f1c 100644 --- a/itest/portfolio_pilot_harness.go +++ b/itest/portfolio_pilot_harness.go @@ -54,6 +54,10 @@ type portfolioPilotHarness struct { // verifyStatus is the quote verification status returned by the server. verifyStatus pilotrpc.QuoteRespStatus + + // fillAmount is the optional fill cap returned in + // ResolveRequestResponse. 0 means no cap. + fillAmount uint64 } // newPortfolioPilotHarness returns a new portfolio pilot harness instance that @@ -116,6 +120,15 @@ func (p *portfolioPilotHarness) callCounts() (resolve, verify, query int) { return p.resolveCalls, p.verifyCalls, p.queryCalls } +// SetFillAmount sets the fill cap returned in ResolveRequest +// responses. 0 means no cap. +func (p *portfolioPilotHarness) SetFillAmount(amt uint64) { + p.mu.Lock() + defer p.mu.Unlock() + + p.fillAmount = amt +} + // defaultAssetRate returns a default asset rate in RPC form. func (p *portfolioPilotHarness) defaultAssetRate() (*pilotrpc.AssetRate, error) { @@ -140,6 +153,7 @@ func (p *portfolioPilotHarness) ResolveRequest(_ context.Context, p.mu.Lock() p.resolveCalls++ p.lastResolve = req + fa := p.fillAmount p.mu.Unlock() if req == nil { @@ -149,9 +163,19 @@ func (p *portfolioPilotHarness) ResolveRequest(_ context.Context, var hint *pilotrpc.AssetRate switch r := req.GetRequest().(type) { case *pilotrpc.ResolveRequestRequest_BuyRequest: - hint = r.BuyRequest.GetAssetRateHint() + br := r.BuyRequest + hint = br.GetAssetRateHint() + log.Infof("ResolveRequest buy: max=%d min=%d "+ + "rate_limit=%v", br.GetAssetMaxAmount(), + br.GetAssetMinAmount(), + br.GetAssetRateLimit()) case *pilotrpc.ResolveRequestRequest_SellRequest: - hint = r.SellRequest.GetAssetRateHint() + sr := r.SellRequest + hint = sr.GetAssetRateHint() + log.Infof("ResolveRequest sell: max=%d min=%d "+ + "rate_limit=%v", sr.GetPaymentMaxAmount(), + sr.GetPaymentMinAmount(), + sr.GetAssetRateLimit()) default: return nil, fmt.Errorf("unknown request type: %T", r) } @@ -168,6 +192,7 @@ func (p *portfolioPilotHarness) ResolveRequest(_ context.Context, Result: &pilotrpc.ResolveRequestResponse_Accept{ Accept: hint, }, + AcceptedMaxAmount: fa, }, nil } diff --git a/itest/rfq_test.go b/itest/rfq_test.go index e38f6bc45f..cc48820a3a 100644 --- a/itest/rfq_test.go +++ b/itest/rfq_test.go @@ -842,6 +842,700 @@ func testRfqPortfolioPilotRpc(t *harnessTest) { return resolveCalls > 0 && verifyCalls > 0 && queryCalls > 0 }, rfqTimeout, 50*time.Millisecond) + + err = carolEventNtfns.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test: Buy order with fill cap. + // + // Set pilot fill amount to 3. Submit a buy order with + // AssetMaxAmt = 6. The accepted quote should report + // AcceptedMaxAmount = 3. + // ----------------------------------------------------------------- + t.Log("Sub-test: buy order with fill cap") + + pilot.SetFillAmount(3) + + carolEvents2, err := ts.CarolTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + buyReq2 := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 6, + Expiry: buyOrderExpiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32( + rfqTimeout.Seconds(), + ), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReq2) + require.NoError(t.t, err, "buy order with fill cap") + + var buyCapQuoteID []byte + BeforeTimeout(t.t, func() { + event, err := carolEvents2.Recv() + require.NoError(t.t, err) + + e, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedBuyQuote) + require.True(t.t, ok, "unexpected event: %v", event) + + buyCapQuoteID = e.PeerAcceptedBuyQuote. + PeerAcceptedBuyQuote.Id + }, rfqTimeout) + + acceptedQuotes, err := ts.CarolTapd.QueryPeerAcceptedQuotes( + ctx, &rfqrpc.QueryPeerAcceptedQuotesRequest{}, + ) + require.NoError(t.t, err) + + var matchedBuy *rfqrpc.PeerAcceptedBuyQuote + for _, q := range acceptedQuotes.BuyQuotes { + if bytes.Equal(q.Id, buyCapQuoteID) { + matchedBuy = q + break + } + } + require.NotNil(t.t, matchedBuy, "quote not found by ID") + require.Equal( + t.t, uint64(3), matchedBuy.AcceptedMaxAmount, + "expected fill cap of 3", + ) + + err = carolEvents2.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test: Buy order without fill cap. + // + // Set pilot fill amount to 0 (no cap). The accepted quote + // should report AcceptedMaxAmount = 0. + // ----------------------------------------------------------------- + t.Log("Sub-test: buy order without fill cap") + + pilot.SetFillAmount(0) + + carolEvents3, err := ts.CarolTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + buyReq3 := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 6, + Expiry: buyOrderExpiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32( + rfqTimeout.Seconds(), + ), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReq3) + require.NoError(t.t, err, "buy order without fill cap") + + var buyNoCapQuoteID []byte + BeforeTimeout(t.t, func() { + event, err := carolEvents3.Recv() + require.NoError(t.t, err) + + e, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedBuyQuote) + require.True(t.t, ok, "unexpected event: %v", event) + + buyNoCapQuoteID = e.PeerAcceptedBuyQuote. + PeerAcceptedBuyQuote.Id + }, rfqTimeout) + + acceptedQuotes, err = ts.CarolTapd.QueryPeerAcceptedQuotes( + ctx, &rfqrpc.QueryPeerAcceptedQuotesRequest{}, + ) + require.NoError(t.t, err) + + var matchedBuyNoCap *rfqrpc.PeerAcceptedBuyQuote + for _, q := range acceptedQuotes.BuyQuotes { + if bytes.Equal(q.Id, buyNoCapQuoteID) { + matchedBuyNoCap = q + break + } + } + require.NotNil(t.t, matchedBuyNoCap, "quote not found by ID") + require.Equal( + t.t, uint64(0), matchedBuyNoCap.AcceptedMaxAmount, + "expected no fill cap", + ) + + err = carolEvents3.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test: Sell order with fill cap. + // + // Register a buy offer on Bob so Alice can sell. Set pilot + // fill amount to 20000. Submit a sell order from Alice and + // verify AcceptedMaxAmount = 20000. + // ----------------------------------------------------------------- + t.Log("Sub-test: sell order with fill cap") + + _, err = ts.BobTapd.AddAssetBuyOffer( + ctx, &rfqrpc.AddAssetBuyOfferRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + MaxUnits: 1000, + }, + ) + require.NoError(t.t, err) + + pilot.SetFillAmount(20000) + + aliceEvents, err := ts.AliceTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + sellOrderExpiry := uint64( + time.Now().Add(24 * time.Hour).Unix(), + ) + sellReq := &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + PaymentMaxAmt: 42000, + Expiry: sellOrderExpiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32( + rfqTimeout.Seconds(), + ), + SkipAssetChannelCheck: true, + } + _, err = ts.AliceTapd.AddAssetSellOrder(ctx, sellReq) + require.NoError(t.t, err, "sell order with fill cap") + + var sellCapQuoteID []byte + BeforeTimeout(t.t, func() { + event, err := aliceEvents.Recv() + require.NoError(t.t, err) + + e, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedSellQuote) + require.True(t.t, ok, "unexpected event: %v", event) + + sellCapQuoteID = e.PeerAcceptedSellQuote. + PeerAcceptedSellQuote.Id + }, rfqTimeout) + + acceptedQuotes, err = ts.AliceTapd.QueryPeerAcceptedQuotes( + ctx, &rfqrpc.QueryPeerAcceptedQuotesRequest{}, + ) + require.NoError(t.t, err) + + var matchedSell *rfqrpc.PeerAcceptedSellQuote + for _, q := range acceptedQuotes.SellQuotes { + if bytes.Equal(q.Id, sellCapQuoteID) { + matchedSell = q + break + } + } + require.NotNil(t.t, matchedSell, "quote not found by ID") + require.Equal( + t.t, uint64(20000), matchedSell.AcceptedMaxAmount, + "expected fill cap of 20000", + ) + + err = aliceEvents.CloseSend() + require.NoError(t.t, err) +} + +// testRfqLimitConstraints tests that RFQ negotiation correctly enforces +// limit-order constraints (asset_rate_limit, asset_min_amt, +// payment_min_amt) at the RPC layer. It uses the oracle harness with +// SetPrice for deterministic rates and the standard 3-node topology. +func testRfqLimitConstraints(t *harnessTest) { + // Start a mock price oracle RPC server. + oracleAddr := fmt.Sprintf("127.0.0.1:%d", port.NextAvailablePort()) + oracle := NewOracleHarness(oracleAddr) + oracle.Start(t.t) + t.t.Cleanup(oracle.Stop) + + oracleURL := fmt.Sprintf("rfqrpc://%s", oracleAddr) + + // Initialize the standard 3-node test scenario. + ts := newRfqTestScenario(t, WithRfqOracleServer(oracleURL)) + + // Mint an asset with Bob's tapd node. + rpcAssets := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, ts.BobTapd, + []*mintrpc.MintAssetRequest{issuableAssets[0]}, + ) + mintedAssetId := rpcAssets[0].AssetGenesis.AssetId + + var assetID asset.ID + copy(assetID[:], mintedAssetId) + specifier := asset.NewSpecifierFromId(assetID) + + // Set oracle rate to 1000 asset units per BTC (coeff=1000000, + // scale=3). Use same price for both bid and ask (no spread). + oracleRate := rfqmath.NewBigIntFixedPoint(1000_000, 3) + oracle.SetPrice(specifier, oracleRate, oracleRate) + + ctx := context.Background() + + // Bob registers a sell offer so Carol can buy. + _, err := ts.BobTapd.AddAssetSellOffer( + ctx, &rfqrpc.AddAssetSellOfferRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + MaxUnits: 1000, + }, + ) + require.NoError(t.t, err) + + // Bob also registers a buy offer so Alice can sell. + _, err = ts.BobTapd.AddAssetBuyOffer( + ctx, &rfqrpc.AddAssetBuyOfferRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + MaxUnits: 1000, + }, + ) + require.NoError(t.t, err) + + expiry := uint64(time.Now().Add(24 * time.Hour).Unix()) + + // ----------------------------------------------------------------- + // Sub-test 1: Buy with satisfied rate limit. + // + // Oracle rate = 1000. Carol sets rate limit = 500 (floor for + // buy). Since 1000 >= 500, the constraint passes. + // ----------------------------------------------------------------- + t.Log("Sub-test 1: buy with satisfied rate limit") + + carolEvents, err := ts.CarolTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + buyReq := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 6, + AssetMinAmt: 1, + AssetRateLimit: &rfqrpc.FixedPoint{ + Coefficient: "500000", + Scale: 3, + }, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReq) + require.NoError(t.t, err, "buy with satisfied rate limit") + + BeforeTimeout(t.t, func() { + event, err := carolEvents.Recv() + require.NoError(t.t, err) + + _, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedBuyQuote) + require.True(t.t, ok, "expected PeerAcceptedBuyQuote, "+ + "got: %v", event) + }, rfqTimeout) + + err = carolEvents.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test 2: Buy with violated rate limit. + // + // Oracle rate = 1000. Carol sets rate limit = 2000 (floor for + // buy). Since 1000 < 2000, the rate bound check fails. + // ----------------------------------------------------------------- + t.Log("Sub-test 2: buy with violated rate limit") + + buyReq2 := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 6, + AssetRateLimit: &rfqrpc.FixedPoint{ + Coefficient: "2000000", + Scale: 3, + }, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReq2) + require.ErrorContains( + t.t, err, "rejected quote", + "expected rate bound rejection for buy order", + ) + + // ----------------------------------------------------------------- + // Sub-test 3: Sell with satisfied constraints. + // + // Oracle rate = 1000. Alice sets rate limit = 2000 (ceiling + // for sell). Since 1000 <= 2000, the constraint passes. + // Payment amounts must be large enough to convert to non-zero + // asset units at this rate (1 unit = 10^8 msat). + // ----------------------------------------------------------------- + t.Log("Sub-test 3: sell with satisfied constraints") + + aliceEvents, err := ts.AliceTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + sellReq := &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + PaymentMaxAmt: 200_000_000, + PaymentMinAmt: 100_000_000, + AssetRateLimit: &rfqrpc.FixedPoint{ + Coefficient: "2000000", + Scale: 3, + }, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.AliceTapd.AddAssetSellOrder(ctx, sellReq) + require.NoError(t.t, err, "sell with satisfied constraints") + + BeforeTimeout(t.t, func() { + event, err := aliceEvents.Recv() + require.NoError(t.t, err) + + _, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedSellQuote) + require.True(t.t, ok, "expected PeerAcceptedSellQuote, "+ + "got: %v", event) + }, rfqTimeout) + + err = aliceEvents.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test 4: Sell with violated rate limit. + // + // Oracle rate = 1000. Alice sets rate limit = 500 (ceiling for + // sell). Since 1000 > 500, the rate bound check fails. + // ----------------------------------------------------------------- + t.Log("Sub-test 4: sell with violated rate limit") + + sellReq2 := &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + PaymentMaxAmt: 200_000_000, + AssetRateLimit: &rfqrpc.FixedPoint{ + Coefficient: "500000", + Scale: 3, + }, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.AliceTapd.AddAssetSellOrder(ctx, sellReq2) + require.ErrorContains( + t.t, err, "rejected quote", + "expected rate bound rejection for sell order", + ) + + // ----------------------------------------------------------------- + // Sub-test 5: Client validation — min > max rejected locally. + // + // Carol sends a buy order with asset_min_amt = 10 and + // asset_max_amt = 5. The Validate() check in NewBuyRequest + // catches this before any wire negotiation. + // ----------------------------------------------------------------- + t.Log("Sub-test 5: client validation min > max") + + buyReq3 := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 5, + AssetMinAmt: 10, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReq3) + require.ErrorContains( + t.t, err, "exceeds max amount", + "expected immediate min > max rejection", + ) + + // ----------------------------------------------------------------- + // Sub-test 6: Buy FOK — rate supports full amount. + // + // Oracle rate = 1000. Carol sets FOK on a max of 6 units. + // At 1000 units/BTC, 6 units = 6e-3 BTC = 6M msat, which + // is non-zero. FOK is satisfied. + // ----------------------------------------------------------------- + t.Log("Sub-test 6: buy FOK accepted") + + carolEvents2, err := ts.CarolTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + buyReqFOK := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 6, + ExecutionPolicy: rfqrpc.ExecutionPolicy_EXECUTION_POLICY_FOK, //nolint:lll + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReqFOK) + require.NoError(t.t, err, "buy FOK with viable rate") + + BeforeTimeout(t.t, func() { + event, err := carolEvents2.Recv() + require.NoError(t.t, err) + + _, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedBuyQuote) + require.True(t.t, ok, "expected PeerAcceptedBuyQuote, "+ + "got: %v", event) + }, rfqTimeout) + + err = carolEvents2.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test 7: Sell FOK — rate supports full amount. + // + // Oracle rate = 1000. Alice sets FOK on a max of 200M msat. + // At 1000 units/BTC, 200M msat = 2 units, which is non-zero. + // FOK is satisfied. + // ----------------------------------------------------------------- + t.Log("Sub-test 7: sell FOK accepted") + + aliceEvents2, err := ts.AliceTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + sellReqFOK := &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + PaymentMaxAmt: 200_000_000, + ExecutionPolicy: rfqrpc.ExecutionPolicy_EXECUTION_POLICY_FOK, //nolint:lll + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.AliceTapd.AddAssetSellOrder(ctx, sellReqFOK) + require.NoError(t.t, err, "sell FOK with viable rate") + + BeforeTimeout(t.t, func() { + event, err := aliceEvents2.Recv() + require.NoError(t.t, err) + + _, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedSellQuote) + require.True(t.t, ok, "expected PeerAcceptedSellQuote, "+ + "got: %v", event) + }, rfqTimeout) + + err = aliceEvents2.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test 8: Buy FOK — rate cannot support full amount. + // + // Set oracle to a very high rate (1e12 units/BTC), then FOK + // with max = 1 unit. 1 unit at 1e12 rate = ~0 msat. FOK + // fails. + // ----------------------------------------------------------------- + t.Log("Sub-test 8: buy FOK rejected") + + hugeRate := rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ) + oracle.SetPrice(specifier, hugeRate, hugeRate) + + buyReqFOKFail := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 1, + ExecutionPolicy: rfqrpc.ExecutionPolicy_EXECUTION_POLICY_FOK, //nolint:lll + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReqFOKFail) + require.ErrorContains( + t.t, err, "rejected quote", + "expected FOK rejection for buy order", + ) + + // ----------------------------------------------------------------- + // Sub-test 9: IOC (default) — same extreme rate, no rejection. + // + // Same huge rate but without FOK. IOC doesn't enforce the + // full-amount conversion, so the quote is accepted. + // ----------------------------------------------------------------- + t.Log("Sub-test 9: IOC default accepted with extreme rate") + + carolEvents3, err := ts.CarolTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + buyReqIOC := &rfqrpc.AddAssetBuyOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + AssetMaxAmt: 1, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32(rfqTimeout.Seconds()), + SkipAssetChannelCheck: true, + } + _, err = ts.CarolTapd.AddAssetBuyOrder(ctx, buyReqIOC) + require.NoError(t.t, err, "IOC should not reject") + + BeforeTimeout(t.t, func() { + event, err := carolEvents3.Recv() + require.NoError(t.t, err) + + _, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedBuyQuote) + require.True(t.t, ok, "expected PeerAcceptedBuyQuote, "+ + "got: %v", event) + }, rfqTimeout) + + err = carolEvents3.CloseSend() + require.NoError(t.t, err) + + // ----------------------------------------------------------------- + // Sub-test 10: Sell FOK — rate cannot support full amount. + // + // Set oracle to a tiny rate (1 unit/BTC). With + // PaymentMaxAmt = 1 msat, MilliSatoshiToUnits(1, 1) ≈ 0 + // asset units — FOK viability check fails. + // + // Note: the hugeRate from sub-test 8 does NOT work here + // because sell-side converts msat→units (not units→msat), + // and 1 msat * 1e12 ≈ 10 units (non-zero). + // ----------------------------------------------------------------- + t.Log("Sub-test 10: sell FOK rejected") + + tinyRate := rfqmath.NewBigIntFixedPoint(1, 0) + oracle.SetPrice(specifier, tinyRate, tinyRate) + + sellReqFOKFail := &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + PaymentMaxAmt: 1, + ExecutionPolicy: rfqrpc.ExecutionPolicy_EXECUTION_POLICY_FOK, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32( + rfqTimeout.Seconds(), + ), + SkipAssetChannelCheck: true, + } + _, err = ts.AliceTapd.AddAssetSellOrder(ctx, sellReqFOKFail) + require.ErrorContains( + t.t, err, "rejected quote", + "expected FOK rejection for sell order", + ) + + // ----------------------------------------------------------------- + // Sub-test 11: Sell IOC — same tiny rate, no rejection. + // + // Same tinyRate but without FOK (default IOC). IOC doesn't + // enforce full-amount conversion, so the quote is accepted. + // ----------------------------------------------------------------- + t.Log("Sub-test 11: sell IOC accepted with tiny rate") + + aliceEvents3, err := ts.AliceTapd.SubscribeRfqEventNtfns( + ctx, &rfqrpc.SubscribeRfqEventNtfnsRequest{}, + ) + require.NoError(t.t, err) + + sellReqIOC := &rfqrpc.AddAssetSellOrderRequest{ + AssetSpecifier: &rfqrpc.AssetSpecifier{ + Id: &rfqrpc.AssetSpecifier_AssetId{ + AssetId: mintedAssetId, + }, + }, + PaymentMaxAmt: 1, + Expiry: expiry, + PeerPubKey: ts.BobLnd.PubKey[:], + TimeoutSeconds: uint32( + rfqTimeout.Seconds(), + ), + SkipAssetChannelCheck: true, + } + _, err = ts.AliceTapd.AddAssetSellOrder(ctx, sellReqIOC) + require.NoError(t.t, err, "sell IOC should not reject") + + BeforeTimeout(t.t, func() { + event, err := aliceEvents3.Recv() + require.NoError(t.t, err) + + _, ok := event.Event.(*rfqrpc.RfqEvent_PeerAcceptedSellQuote) + require.True(t.t, ok, "expected PeerAcceptedSellQuote, "+ + "got: %v", event) + }, rfqTimeout) + + err = aliceEvents3.CloseSend() + require.NoError(t.t, err) } // rfqTestScenario is a struct which holds test scenario helper infra. diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index 177701e288..30156adb36 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -383,6 +383,10 @@ var allTestCases = []*testCase{ name: "rfq portfolio pilot rpc", test: testRfqPortfolioPilotRpc, }, + { + name: "rfq limit constraints", + test: testRfqLimitConstraints, + }, { name: "multi signature on all levels", test: testMultiSignature, diff --git a/rfq/manager.go b/rfq/manager.go index b424bddf6e..48070c86fe 100644 --- a/rfq/manager.go +++ b/rfq/manager.go @@ -1440,6 +1440,18 @@ const ( // ValidAcceptQuoteRespStatus indicates that the accepted quote passed // all validation checks successfully. ValidAcceptQuoteRespStatus QuoteRespStatus = 4 + + // MinFillNotMetQuoteRespStatus indicates that the minimum fill + // constraint was not satisfiable at the accepted rate. + MinFillNotMetQuoteRespStatus QuoteRespStatus = 5 + + // RateBoundMissQuoteRespStatus indicates that the accepted rate + // violated the requester's rate limit constraint. + RateBoundMissQuoteRespStatus QuoteRespStatus = 6 + + // FOKNotViableQuoteRespStatus indicates that the FOK execution + // policy could not be satisfied at the accepted rate. + FOKNotViableQuoteRespStatus QuoteRespStatus = 7 ) // InvalidQuoteRespEvent is an event that is broadcast when the RFQ manager diff --git a/rfq/marshal.go b/rfq/marshal.go index a554533ad9..c673d0df2c 100644 --- a/rfq/marshal.go +++ b/rfq/marshal.go @@ -49,6 +49,7 @@ func MarshalAcceptedSellQuote( AssetAmount: numAssetUnits.ScaleTo(0).ToUint64(), MinTransportableMsat: uint64(minTransportableMSat), PriceOracleMetadata: accept.Request.PriceOracleMetadata, + AcceptedMaxAmount: accept.AcceptedMaxAmount.UnwrapOr(0), } // Populate asset ID and/or group key based on the asset specifier. @@ -96,6 +97,7 @@ func MarshalAcceptedBuyQuote(q rfqmsg.BuyAccept) *rfqrpc.PeerAcceptedBuyQuote { Expiry: uint64(q.AssetRate.Expiry.Unix()), MinTransportableUnits: minTransportableUnits, PriceOracleMetadata: q.Request.PriceOracleMetadata, + AcceptedMaxAmount: q.AcceptedMaxAmount.UnwrapOr(0), } // Populate asset ID and/or group key based on the asset specifier. diff --git a/rfq/negotiator.go b/rfq/negotiator.go index 569bea1fc8..4d4c610f3e 100644 --- a/rfq/negotiator.go +++ b/rfq/negotiator.go @@ -236,7 +236,9 @@ func (n *Negotiator) HandleOutgoingBuyOrder(ctx context.Context, // Construct a new buy request to send to the peer. request, err := rfqmsg.NewBuyRequest( peer, buyOrder.AssetSpecifier, buyOrder.AssetMaxAmt, + buyOrder.AssetMinAmt, buyOrder.AssetRateLimit, assetRateHint, buyOrder.PriceOracleMetadata, + buyOrder.ExecutionPolicy, ) if err != nil { err := fmt.Errorf("unable to create buy request "+ @@ -312,9 +314,10 @@ func (n *Negotiator) HandleIncomingQuoteRequest(ctx context.Context, } var acceptErr error + fillAmount := resp.FillAmount() resp.WhenAccept(func(assetRate rfqmsg.AssetRate) { msg, err := rfqmsg.NewQuoteAcceptFromRequest( - request, assetRate, + request, assetRate, fillAmount, ) if err != nil { acceptErr = fmt.Errorf("create quote accept "+ @@ -391,8 +394,10 @@ func (n *Negotiator) HandleOutgoingSellOrder(ctx context.Context, ) request, err := rfqmsg.NewSellRequest( - peer, order.AssetSpecifier, order.PaymentMaxAmt, assetRateHint, - order.PriceOracleMetadata, + peer, order.AssetSpecifier, order.PaymentMaxAmt, + order.PaymentMinAmt, order.AssetRateLimit, + assetRateHint, order.PriceOracleMetadata, + order.ExecutionPolicy, ) if err != nil { diff --git a/rfq/order.go b/rfq/order.go index 68783e8063..17afccb812 100644 --- a/rfq/order.go +++ b/rfq/order.go @@ -191,6 +191,10 @@ type AssetSalePolicy struct { // peer is the peer pub key of the peer we established this policy with. peer route.Vertex + // ExecutionPolicy is the execution policy from the original + // request (annotation only; not enforced at the HTLC level). + ExecutionPolicy fn.Option[rfqmsg.ExecutionPolicy] + // expiry is the policy's expiry unix timestamp after which the policy // is no longer valid. expiry uint64 @@ -202,16 +206,24 @@ func NewAssetSalePolicy(quote rfqmsg.BuyAccept, noop bool, htlcToAmtMap := make(map[models.CircuitKey]lnwire.MilliSatoshi) + maxAmt := quote.Request.AssetMaxAmt + quote.AcceptedMaxAmount.WhenSome(func(fill uint64) { + if fill < maxAmt { + maxAmt = fill + } + }) + return &AssetSalePolicy{ AssetSpecifier: quote.Request.AssetSpecifier, AcceptedQuoteId: quote.ID, - MaxOutboundAssetAmount: quote.Request.AssetMaxAmt, + MaxOutboundAssetAmount: maxAmt, AskAssetRate: quote.AssetRate.Rate, expiry: uint64(quote.AssetRate.Expiry.Unix()), htlcToAmt: htlcToAmtMap, NoOpHTLCs: noop, auxChanNegotiator: chanNegotiator, peer: quote.Peer, + ExecutionPolicy: quote.Request.ExecutionPolicy, } } @@ -426,6 +438,10 @@ type AssetPurchasePolicy struct { // that they carry. htlcToAmt map[models.CircuitKey]lnwire.MilliSatoshi + // ExecutionPolicy is the execution policy from the original + // request (annotation only; not enforced at the HTLC level). + ExecutionPolicy fn.Option[rfqmsg.ExecutionPolicy] + // expiry is the policy's expiry unix timestamp in seconds after which // the policy is no longer valid. expiry uint64 @@ -435,14 +451,23 @@ type AssetPurchasePolicy struct { func NewAssetPurchasePolicy(quote rfqmsg.SellAccept) *AssetPurchasePolicy { htlcToAmtMap := make(map[models.CircuitKey]lnwire.MilliSatoshi) + payMax := quote.Request.PaymentMaxAmt + quote.AcceptedMaxAmount.WhenSome(func(fill uint64) { + fillMsat := lnwire.MilliSatoshi(fill) + if fillMsat < payMax { + payMax = fillMsat + } + }) + return &AssetPurchasePolicy{ scid: quote.ShortChannelId(), AssetSpecifier: quote.Request.AssetSpecifier, AcceptedQuoteId: quote.ID, BidAssetRate: quote.AssetRate.Rate, - PaymentMaxAmt: quote.Request.PaymentMaxAmt, + PaymentMaxAmt: payMax, expiry: uint64(quote.AssetRate.Expiry.Unix()), htlcToAmt: htlcToAmtMap, + ExecutionPolicy: quote.Request.ExecutionPolicy, } } @@ -1733,6 +1758,17 @@ type BuyOrder struct { // be willing to offer. AssetMaxAmt uint64 + // AssetMinAmt is an optional minimum asset amount for the order. + AssetMinAmt fn.Option[uint64] + + // AssetRateLimit is an optional minimum acceptable rate (asset + // units per BTC) for the order. + AssetRateLimit fn.Option[rfqmath.BigIntFixedPoint] + + // ExecutionPolicy is an optional execution policy (IOC or + // FOK) for the order. + ExecutionPolicy fn.Option[rfqmsg.ExecutionPolicy] + // Expiry is the time at which the order expires. Expiry time.Time @@ -1800,6 +1836,17 @@ type SellOrder struct { // must agree to pay. PaymentMaxAmt lnwire.MilliSatoshi + // PaymentMinAmt is an optional minimum msat amount for the order. + PaymentMinAmt fn.Option[lnwire.MilliSatoshi] + + // AssetRateLimit is an optional maximum acceptable rate (asset + // units per BTC) for the order. + AssetRateLimit fn.Option[rfqmath.BigIntFixedPoint] + + // ExecutionPolicy is an optional execution policy (IOC or + // FOK) for the order. + ExecutionPolicy fn.Option[rfqmsg.ExecutionPolicy] + // Expiry is the time at which the order expires. Expiry time.Time diff --git a/rfq/order_test.go b/rfq/order_test.go new file mode 100644 index 0000000000..b23af28c37 --- /dev/null +++ b/rfq/order_test.go @@ -0,0 +1,155 @@ +package rfq + +import ( + "testing" + "time" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rfqmsg" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// TestNewAssetSalePolicyFillCap tests that NewAssetSalePolicy caps +// MaxOutboundAssetAmount when a fill quantity is present. +func TestNewAssetSalePolicyFillCap(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{0x01}) + peer := route.Vertex{0x0A} + rate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint(100, 0), + time.Now().Add(time.Hour), + ) + + tests := []struct { + name string + maxAmt uint64 + fill fn.Option[uint64] + expectMax uint64 + }{ + { + name: "no fill uses request max", + maxAmt: 100, + fill: fn.None[uint64](), + expectMax: 100, + }, + { + name: "fill < max caps to fill", + maxAmt: 100, + fill: fn.Some[uint64](60), + expectMax: 60, + }, + { + name: "fill > max uses request max", + maxAmt: 100, + fill: fn.Some[uint64](200), + expectMax: 100, + }, + { + name: "fill == max uses request max", + maxAmt: 100, + fill: fn.Some[uint64](100), + expectMax: 100, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + buyReq := &rfqmsg.BuyRequest{ + Peer: peer, + AssetSpecifier: spec, + AssetMaxAmt: tc.maxAmt, + } + + accept := rfqmsg.BuyAccept{ + Peer: peer, + Request: *buyReq, + AssetRate: rate, + AcceptedMaxAmount: tc.fill, + } + + policy := NewAssetSalePolicy( + accept, false, nil, + ) + require.Equal( + t, tc.expectMax, + policy.MaxOutboundAssetAmount, + ) + }) + } +} + +// TestNewAssetPurchasePolicyFillCap tests that NewAssetPurchasePolicy +// caps PaymentMaxAmt when a fill quantity is present. +func TestNewAssetPurchasePolicyFillCap(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{0x01}) + peer := route.Vertex{0x0A} + rate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint(100, 0), + time.Now().Add(time.Hour), + ) + + tests := []struct { + name string + maxAmt lnwire.MilliSatoshi + fill fn.Option[uint64] + expectMax lnwire.MilliSatoshi + }{ + { + name: "no fill uses request max", + maxAmt: 1000, + fill: fn.None[uint64](), + expectMax: 1000, + }, + { + name: "fill < max caps to fill", + maxAmt: 1000, + fill: fn.Some[uint64](600), + expectMax: 600, + }, + { + name: "fill > max uses request max", + maxAmt: 1000, + fill: fn.Some[uint64](2000), + expectMax: 1000, + }, + { + name: "fill == max uses request max", + maxAmt: 1000, + fill: fn.Some[uint64](1000), + expectMax: 1000, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + sellReq := &rfqmsg.SellRequest{ + Peer: peer, + AssetSpecifier: spec, + PaymentMaxAmt: tc.maxAmt, + } + + accept := rfqmsg.SellAccept{ + Peer: peer, + Request: *sellReq, + AssetRate: rate, + AcceptedMaxAmount: tc.fill, + } + + policy := NewAssetPurchasePolicy(accept) + require.Equal( + t, tc.expectMax, policy.PaymentMaxAmt, + ) + }) + } +} diff --git a/rfq/portfolio_pilot.go b/rfq/portfolio_pilot.go index 8a690593a4..52fe34840c 100644 --- a/rfq/portfolio_pilot.go +++ b/rfq/portfolio_pilot.go @@ -90,21 +90,29 @@ type AssetRateQuery struct { Expiry fn.Option[time.Time] } -// ResolveResp captures the portfolio pilot's resolution decision for an RFQ. It -// carries either an accepted asset rate quote or a structured rejection reason. +// ResolveResp captures the portfolio pilot's resolution decision for an +// RFQ. It carries either an accepted asset rate quote or a structured +// rejection reason, plus an optional fill amount. type ResolveResp struct { - // outcome holds either the accepted asset rate (left) or the rejection - // error (right). + // outcome holds either the accepted asset rate (left) or the + // rejection error (right). outcome fn.Either[rfqmsg.AssetRate, rfqmsg.RejectErr] + + // fillAmount is an optional fill quantity that caps the amount + // the responder is willing to accept. + fillAmount fn.Option[uint64] } -// NewAcceptResolveResp builds an acceptance response with the provided asset -// rate quote. -func NewAcceptResolveResp(assetRate rfqmsg.AssetRate) ResolveResp { +// NewAcceptResolveResp builds an acceptance response with the provided +// asset rate quote and optional fill amount. +func NewAcceptResolveResp(assetRate rfqmsg.AssetRate, + fillAmount fn.Option[uint64]) ResolveResp { + return ResolveResp{ outcome: fn.NewLeft[rfqmsg.AssetRate, rfqmsg.RejectErr]( assetRate, ), + fillAmount: fillAmount, } } @@ -118,6 +126,11 @@ func NewRejectResolveResp(rejectErr rfqmsg.RejectErr) ResolveResp { } } +// FillAmount returns the optional fill quantity. +func (r *ResolveResp) FillAmount() fn.Option[uint64] { + return r.fillAmount +} + // IsAccept reports whether the response contains an accepted asset rate. func (r *ResolveResp) IsAccept() bool { return r.outcome.IsLeft() @@ -275,7 +288,20 @@ func (p *InternalPortfolioPilot) ResolveRequest(ctx context.Context, resp.Err) } - return NewAcceptResolveResp(resp.AssetRate), nil + // Enforce the requester's constraints on the responder side + // to avoid wasted round-trips. + status := checkAllConstraints( + request, resp.AssetRate.Rate, fn.None[uint64](), + ) + if status != ValidAcceptQuoteRespStatus { + return NewRejectResolveResp( + rejectForStatus(status), + ), nil + } + + return NewAcceptResolveResp( + resp.AssetRate, fn.None[uint64](), + ), nil } // VerifyAcceptQuote verifies that an accepted quote from a peer meets @@ -363,6 +389,15 @@ func (p *InternalPortfolioPilot) VerifyAcceptQuote(ctx context.Context, return InvalidAssetRatesQuoteRespStatus, nil } + // Enforce all limit-order constraints (rate bound, min fill, + // FOK, and fill-vs-constraint compatibility). + status := checkAllConstraints( + req, counterRate.Rate, accept.AcceptedFillAmount(), + ) + if status != ValidAcceptQuoteRespStatus { + return status, nil + } + return ValidAcceptQuoteRespStatus, nil } @@ -443,3 +478,180 @@ func (p *InternalPortfolioPilot) QueryAssetRates(ctx context.Context, func (p *InternalPortfolioPilot) Close() error { return nil } + +// checkAllConstraints runs every limit-order constraint check +// against the given request, rate, and optional fill amount. +// It returns the first non-valid status, or ValidAcceptQuoteRespStatus +// if all checks pass. +func checkAllConstraints(req rfqmsg.Request, + rate rfqmath.BigIntFixedPoint, + fill fn.Option[uint64]) QuoteRespStatus { + + for _, check := range []func() QuoteRespStatus{ + func() QuoteRespStatus { + return checkRateBound(req, rate) + }, + func() QuoteRespStatus { + return checkMinFill(req, rate) + }, + func() QuoteRespStatus { + return checkFOK(req, rate) + }, + func() QuoteRespStatus { + return checkFillConstraints(req, fill) + }, + } { + if s := check(); s != ValidAcceptQuoteRespStatus { + return s + } + } + + return ValidAcceptQuoteRespStatus +} + +// amountIsTransportable returns true if the given amount converts to +// a non-zero result at the given rate. For buy requests (RateBoundCmp +// == -1), the amount is in asset units and converts to msat. For sell +// requests (RateBoundCmp == +1), the amount is in msat and converts +// to asset units. +func amountIsTransportable(amt uint64, + rate rfqmath.BigIntFixedPoint, rateBoundCmp int) bool { + + if rateBoundCmp < 0 { + // Buy: asset units → msat. + units := rfqmath.NewBigIntFixedPoint(amt, 0) + return rfqmath.UnitsToMilliSatoshi(units, rate) != 0 + } + + // Sell: msat → asset units. + units := rfqmath.MilliSatoshiToUnits( + lnwire.MilliSatoshi(amt), rate, + ) + zero := rfqmath.NewBigIntFromUint64(0) + + return !units.Coefficient.Equals(zero) +} + +// isFOK returns true if the execution policy is Fill-Or-Kill. +func isFOK(p fn.Option[rfqmsg.ExecutionPolicy]) bool { + return fn.MapOptionZ(p, func(ep rfqmsg.ExecutionPolicy) bool { + return ep == rfqmsg.ExecutionPolicyFOK + }) +} + +// checkRateBound verifies that the accepted rate satisfies the +// requester's rate limit constraint. For a buy request the accepted +// rate must be >= the limit (floor); for a sell request it must be +// <= the limit (ceiling). The comparison direction is encoded in +// RequestConstraints.RateBoundCmp. +func checkRateBound(req rfqmsg.Request, + acceptedRate rfqmath.BigIntFixedPoint) QuoteRespStatus { + + c := req.Constraints() + miss := fn.MapOptionZ( + c.RateLimit, + func(limit rfqmath.BigIntFixedPoint) bool { + // RateBoundCmp is -1 for buy (floor) and +1 + // for sell (ceiling). A miss occurs when the + // accepted rate falls on the wrong side: + // buy: accepted < limit → Cmp returns -1 + // sell: accepted > limit → Cmp returns +1 + return acceptedRate.Cmp(limit) == c.RateBoundCmp + }, + ) + if miss { + return RateBoundMissQuoteRespStatus + } + + return ValidAcceptQuoteRespStatus +} + +// checkFOK verifies that the full max amount is transportable at the +// accepted rate when the execution policy is FOK. +func checkFOK(req rfqmsg.Request, + acceptedRate rfqmath.BigIntFixedPoint) QuoteRespStatus { + + c := req.Constraints() + if !isFOK(c.ExecutionPolicy) { + return ValidAcceptQuoteRespStatus + } + + if !amountIsTransportable( + c.MaxAmount, acceptedRate, c.RateBoundCmp, + ) { + + return FOKNotViableQuoteRespStatus + } + + return ValidAcceptQuoteRespStatus +} + +// checkMinFill verifies that the requester's minimum fill amount is +// transportable at the accepted rate. +func checkMinFill(req rfqmsg.Request, + acceptedRate rfqmath.BigIntFixedPoint) QuoteRespStatus { + + c := req.Constraints() + notMet := fn.MapOptionZ( + c.MinAmount, + func(minAmt uint64) bool { + return !amountIsTransportable( + minAmt, acceptedRate, c.RateBoundCmp, + ) + }, + ) + if notMet { + return MinFillNotMetQuoteRespStatus + } + + return ValidAcceptQuoteRespStatus +} + +// checkFillConstraints verifies that the negotiated fill amount (if +// present) is compatible with the requester's min-fill and FOK +// constraints. +func checkFillConstraints(req rfqmsg.Request, + fill fn.Option[uint64]) QuoteRespStatus { + + if fill.IsNone() { + // No fill → full request max implied; nothing to + // check. + return ValidAcceptQuoteRespStatus + } + + fillAmt := fill.UnwrapOr(0) + c := req.Constraints() + + // Fill must be >= min fill when set. + tooSmall := fn.MapOptionZ( + c.MinAmount, + func(minAmt uint64) bool { + return fillAmt < minAmt + }, + ) + if tooSmall { + return MinFillNotMetQuoteRespStatus + } + + // FOK requires the full max to be fillable. + if isFOK(c.ExecutionPolicy) && fillAmt < c.MaxAmount { + return FOKNotViableQuoteRespStatus + } + + return ValidAcceptQuoteRespStatus +} + +// rejectForStatus maps a constraint-violation QuoteRespStatus to the +// corresponding RejectErr. +func rejectForStatus(status QuoteRespStatus) rfqmsg.RejectErr { + switch status { + case RateBoundMissQuoteRespStatus: + return rfqmsg.ErrPriceBoundMiss + case MinFillNotMetQuoteRespStatus: + return rfqmsg.ErrMinFillNotMet + case FOKNotViableQuoteRespStatus: + return rfqmsg.ErrFOKNotViable + default: + return rfqmsg.ErrUnknownReject + } +} diff --git a/rfq/portfolio_pilot_rpc.go b/rfq/portfolio_pilot_rpc.go index e0bdc965e3..c416ae26c0 100644 --- a/rfq/portfolio_pilot_rpc.go +++ b/rfq/portfolio_pilot_rpc.go @@ -8,9 +8,12 @@ import ( "time" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightninglabs/taproot-assets/rfqmsg" "github.com/lightninglabs/taproot-assets/rpcutils" pilotrpc "github.com/lightninglabs/taproot-assets/taprpc/portfoliopilotrpc" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -162,7 +165,12 @@ func (r *RpcPortfolioPilot) ResolveRequest(ctx context.Context, err) } - return NewAcceptResolveResp(*assetRate), nil + fillAmount := fn.None[uint64]() + if resp.AcceptedMaxAmount > 0 { + fillAmount = fn.Some(resp.AcceptedMaxAmount) + } + + return NewAcceptResolveResp(*assetRate, fillAmount), nil case *pilotrpc.ResolveRequestResponse_Reject: if result.Reject == nil { @@ -311,9 +319,10 @@ func rpcMarshalVerifyAcceptQuoteRequest( } return &pilotrpc.VerifyAcceptQuoteRequest{ Accept: &pilotrpc.AcceptedQuote{ - PeerId: peer[:], - AcceptedRate: rpcAcceptedRate, - Request: requestWrapper, + PeerId: peer[:], + AcceptedRate: rpcAcceptedRate, + Request: requestWrapper, + AcceptedMaxAmount: msg.AcceptedMaxAmount.UnwrapOr(0), //nolint:lll }, }, nil @@ -336,9 +345,10 @@ func rpcMarshalVerifyAcceptQuoteRequest( } return &pilotrpc.VerifyAcceptQuoteRequest{ Accept: &pilotrpc.AcceptedQuote{ - PeerId: peer[:], - AcceptedRate: rpcAcceptedRate, - Request: requestWrapper, + PeerId: peer[:], + AcceptedRate: rpcAcceptedRate, + Request: requestWrapper, + AcceptedMaxAmount: msg.AcceptedMaxAmount.UnwrapOr(0), //nolint:lll }, }, nil @@ -441,14 +451,25 @@ func rpcMarshalBuyRequest( return nil, fmt.Errorf("marshal rate hint: %w", err) } + var rpcRateLimit *pilotrpc.FixedPoint + req.AssetRateLimit.WhenSome( + func(fp rfqmath.BigIntFixedPoint) { + rpcRateLimit = rpcutils.MarshalPortfolioFixedPoint(fp) + }, + ) + peer := req.MsgPeer() - rpcSpecifier := rpcMarshalPortfolioAssetSpecifier(req.AssetSpecifier) + rpcSpecifier := rpcMarshalPortfolioAssetSpecifier( + req.AssetSpecifier, + ) return &pilotrpc.BuyRequest{ AssetSpecifier: rpcSpecifier, AssetMaxAmount: req.AssetMaxAmt, AssetRateHint: rpcRateHint, PriceOracleMetadata: req.PriceOracleMetadata, PeerId: peer[:], + AssetMinAmount: req.AssetMinAmt.UnwrapOr(0), + AssetRateLimit: rpcRateLimit, }, nil } @@ -475,14 +496,32 @@ func rpcMarshalSellRequest( return nil, fmt.Errorf("marshal rate hint: %w", err) } + var rpcRateLimit *pilotrpc.FixedPoint + req.AssetRateLimit.WhenSome( + func(fp rfqmath.BigIntFixedPoint) { + rpcRateLimit = rpcutils.MarshalPortfolioFixedPoint(fp) + }, + ) + + var paymentMinAmt uint64 + req.PaymentMinAmt.WhenSome( + func(v lnwire.MilliSatoshi) { + paymentMinAmt = uint64(v) + }, + ) + peer := req.MsgPeer() - rpcSpecifier := rpcMarshalPortfolioAssetSpecifier(req.AssetSpecifier) + rpcSpecifier := rpcMarshalPortfolioAssetSpecifier( + req.AssetSpecifier, + ) return &pilotrpc.SellRequest{ AssetSpecifier: rpcSpecifier, PaymentMaxAmount: uint64(req.PaymentMaxAmt), AssetRateHint: rpcRateHint, PriceOracleMetadata: req.PriceOracleMetadata, PeerId: peer[:], + PaymentMinAmount: paymentMinAmt, + AssetRateLimit: rpcRateLimit, }, nil } @@ -573,6 +612,12 @@ func rpcUnmarshalQuoteRespStatus( return PortfolioPilotErrQuoteRespStatus, nil case pilotrpc.QuoteRespStatus_VALID_ACCEPT_QUOTE: return ValidAcceptQuoteRespStatus, nil + case pilotrpc.QuoteRespStatus_MIN_FILL_NOT_MET: + return MinFillNotMetQuoteRespStatus, nil + case pilotrpc.QuoteRespStatus_RATE_BOUND_MISS: + return RateBoundMissQuoteRespStatus, nil + case pilotrpc.QuoteRespStatus_FOK_NOT_VIABLE: + return FOKNotViableQuoteRespStatus, nil default: return 0, fmt.Errorf("unknown quote response status: %v", status) @@ -588,6 +633,12 @@ func rpcUnmarshalRejectCode( return rfqmsg.PriceOracleUnspecifiedRejectCode case pilotrpc.RejectCode_REJECT_CODE_PRICE_ORACLE_UNAVAILABLE: return rfqmsg.PriceOracleUnavailableRejectCode + case pilotrpc.RejectCode_REJECT_CODE_MIN_FILL_NOT_MET: + return rfqmsg.MinFillNotMetRejectCode + case pilotrpc.RejectCode_REJECT_CODE_PRICE_BOUND_MISS: + return rfqmsg.PriceBoundMissRejectCode + case pilotrpc.RejectCode_REJECT_CODE_FOK_NOT_VIABLE: + return rfqmsg.FOKNotViableRejectCode default: return rfqmsg.PriceOracleUnspecifiedRejectCode } diff --git a/rfq/portfolio_pilot_test.go b/rfq/portfolio_pilot_test.go index 86774ee46c..55df23ce1d 100644 --- a/rfq/portfolio_pilot_test.go +++ b/rfq/portfolio_pilot_test.go @@ -124,7 +124,10 @@ func TestResolveRequest(t *testing.T) { req, err := rfqmsg.NewBuyRequest( route.Vertex{0x01, 0x02, 0x03}, asset.NewSpecifierFromId(asset.ID{assetID}), 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), rateHint, "order-metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) return req @@ -140,7 +143,11 @@ func TestResolveRequest(t *testing.T) { req, err := rfqmsg.NewSellRequest( route.Vertex{0x0A, 0x0B, 0x0C}, asset.NewSpecifierFromId(asset.ID{assetID}), - paymentMax, rateHint, "order-metadata", + paymentMax, + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + rateHint, "order-metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) return req @@ -178,6 +185,10 @@ func TestResolveRequest(t *testing.T) { // error. expectErr string + // expectReject, if true, means we expect a reject response + // (not an error, but a valid reject). + expectReject bool + // assertFn performs per-case assertions. assertFn func( t *testing.T, resp ResolveResp, req rfqmsg.Request, @@ -450,6 +461,320 @@ func TestResolveRequest(t *testing.T) { ) }, }, + + // --- Responder-side constraint enforcement --- + + { + name: "buy: rate bound reject", + expectReject: true, + makeReq: func(t *testing.T) rfqmsg.Request { + // Rate limit of 200 means the + // oracle's 125 is below bound. + req, err := rfqmsg.NewBuyRequest( + route.Vertex{0x01, 0x02, 0x03}, + asset.NewSpecifierFromId( + asset.ID{0xA0}, + ), + 100, + fn.None[uint64](), + fn.Some( + rfqmath.NewBigIntFixedPoint( + 200, 0, + ), + ), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + return req + }, + setupOracle: func(o *MockPriceOracle) { + expectQuerySellPrice( + o, &OracleResponse{ + AssetRate: expectedBuyRate, + }, nil, + ) + }, + assertFn: func( + t *testing.T, resp ResolveResp, + _ rfqmsg.Request, + _ *MockPriceOracle, + ) { + + require.True(t, resp.IsReject()) + resp.WhenReject( + func(e rfqmsg.RejectErr) { + require.Equal( + t, + rfqmsg.PriceBoundMissRejectCode, //nolint:lll + e.Code, + ) + }, + ) + }, + }, + { + name: "buy: min fill reject", + expectReject: true, + makeReq: func(t *testing.T) rfqmsg.Request { + // Huge rate: 1 unit → ~0 msat, + // so min of 1 is untransportable. + req, err := rfqmsg.NewBuyRequest( + route.Vertex{0x01, 0x02, 0x03}, + asset.NewSpecifierFromId( + asset.ID{0xA1}, + ), + 1, + fn.Some[uint64](1), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + return req + }, + setupOracle: func(o *MockPriceOracle) { + hugeRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ), + buyResponseExpiry, + ) + expectQuerySellPrice( + o, &OracleResponse{ + AssetRate: hugeRate, + }, nil, + ) + }, + assertFn: func( + t *testing.T, resp ResolveResp, + _ rfqmsg.Request, + _ *MockPriceOracle, + ) { + + require.True(t, resp.IsReject()) + resp.WhenReject( + func(e rfqmsg.RejectErr) { + require.Equal( + t, + rfqmsg.MinFillNotMetRejectCode, //nolint:lll + e.Code, + ) + }, + ) + }, + }, + { + name: "buy: FOK reject", + expectReject: true, + makeReq: func(t *testing.T) rfqmsg.Request { + // Huge rate: 1 unit → ~0 msat. + req, err := rfqmsg.NewBuyRequest( + route.Vertex{0x01, 0x02, 0x03}, + asset.NewSpecifierFromId( + asset.ID{0xA2}, + ), + 1, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + return req + }, + setupOracle: func(o *MockPriceOracle) { + hugeRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ), + buyResponseExpiry, + ) + expectQuerySellPrice( + o, &OracleResponse{ + AssetRate: hugeRate, + }, nil, + ) + }, + assertFn: func( + t *testing.T, resp ResolveResp, + _ rfqmsg.Request, + _ *MockPriceOracle, + ) { + + require.True(t, resp.IsReject()) + resp.WhenReject( + func(e rfqmsg.RejectErr) { + require.Equal( + t, + rfqmsg.FOKNotViableRejectCode, //nolint:lll + e.Code, + ) + }, + ) + }, + }, + { + name: "sell: rate bound reject", + expectReject: true, + makeReq: func(t *testing.T) rfqmsg.Request { + // Rate limit of 50: oracle's 200 is + // above the sell upper bound. + req, err := rfqmsg.NewSellRequest( + route.Vertex{0x0A, 0x0B, 0x0C}, + asset.NewSpecifierFromId( + asset.ID{0xB0}, + ), + lnwire.MilliSatoshi(10000), + fn.None[lnwire.MilliSatoshi](), + fn.Some( + rfqmath.NewBigIntFixedPoint( + 50, 0, + ), + ), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + return req + }, + setupOracle: func(o *MockPriceOracle) { + expectQueryBuyPrice( + o, &OracleResponse{ + AssetRate: expectedSellRate, + }, nil, + ) + }, + assertFn: func( + t *testing.T, resp ResolveResp, + _ rfqmsg.Request, + _ *MockPriceOracle, + ) { + + require.True(t, resp.IsReject()) + resp.WhenReject( + func(e rfqmsg.RejectErr) { + require.Equal( + t, + rfqmsg.PriceBoundMissRejectCode, //nolint:lll + e.Code, + ) + }, + ) + }, + }, + { + name: "sell: min fill reject", + expectReject: true, + makeReq: func(t *testing.T) rfqmsg.Request { + // Low rate (1 unit/BTC): 1 msat + // converts to 0 units. + req, err := rfqmsg.NewSellRequest( + route.Vertex{0x0A, 0x0B, 0x0C}, + asset.NewSpecifierFromId( + asset.ID{0xB1}, + ), + lnwire.MilliSatoshi(1), + fn.Some(lnwire.MilliSatoshi(1)), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + return req + }, + setupOracle: func(o *MockPriceOracle) { + lowRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1, 0, + ), + sellResponseExpiry, + ) + expectQueryBuyPrice( + o, &OracleResponse{ + AssetRate: lowRate, + }, nil, + ) + }, + assertFn: func( + t *testing.T, resp ResolveResp, + _ rfqmsg.Request, + _ *MockPriceOracle, + ) { + + require.True(t, resp.IsReject()) + resp.WhenReject( + func(e rfqmsg.RejectErr) { + require.Equal( + t, + rfqmsg.MinFillNotMetRejectCode, //nolint:lll + e.Code, + ) + }, + ) + }, + }, + { + name: "sell: FOK reject", + expectReject: true, + makeReq: func(t *testing.T) rfqmsg.Request { + // Low rate (1 unit/BTC): 1 msat + // converts to 0 units. + req, err := rfqmsg.NewSellRequest( + route.Vertex{0x0A, 0x0B, 0x0C}, + asset.NewSpecifierFromId( + asset.ID{0xB2}, + ), + lnwire.MilliSatoshi(1), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + return req + }, + setupOracle: func(o *MockPriceOracle) { + lowRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1, 0, + ), + sellResponseExpiry, + ) + expectQueryBuyPrice( + o, &OracleResponse{ + AssetRate: lowRate, + }, nil, + ) + }, + assertFn: func( + t *testing.T, resp ResolveResp, + _ rfqmsg.Request, + _ *MockPriceOracle, + ) { + + require.True(t, resp.IsReject()) + resp.WhenReject( + func(e rfqmsg.RejectErr) { + require.Equal( + t, + rfqmsg.FOKNotViableRejectCode, //nolint:lll + e.Code, + ) + }, + ) + }, + }, } for _, tc := range tests { @@ -477,6 +802,11 @@ func TestResolveRequest(t *testing.T) { require.False(t, resp.IsAccept()) require.False(t, resp.IsReject()) + case tc.expectReject: + require.NoError(t, err) + require.True(t, resp.IsReject()) + require.False(t, resp.IsAccept()) + default: require.NoError(t, err) require.False(t, resp.IsReject()) @@ -546,8 +876,11 @@ func TestVerifyAcceptQuote(t *testing.T) { makeAccept: func(t *testing.T) rfqmsg.Accept { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -574,8 +907,11 @@ func TestVerifyAcceptQuote(t *testing.T) { makeAccept: func(t *testing.T) rfqmsg.Accept { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -599,8 +935,11 @@ func TestVerifyAcceptQuote(t *testing.T) { makeAccept: func(t *testing.T) rfqmsg.Accept { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -629,8 +968,11 @@ func TestVerifyAcceptQuote(t *testing.T) { makeAccept: func(t *testing.T) rfqmsg.Accept { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -655,8 +997,11 @@ func TestVerifyAcceptQuote(t *testing.T) { makeAccept: func(t *testing.T) rfqmsg.Accept { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -680,8 +1025,11 @@ func TestVerifyAcceptQuote(t *testing.T) { makeAccept: func(t *testing.T) rfqmsg.Accept { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -708,8 +1056,11 @@ func TestVerifyAcceptQuote(t *testing.T) { sellReq, err := rfqmsg.NewSellRequest( peerID, assetSpec, lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -737,8 +1088,11 @@ func TestVerifyAcceptQuote(t *testing.T) { sellReq, err := rfqmsg.NewSellRequest( peerID, assetSpec, lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -763,8 +1117,11 @@ func TestVerifyAcceptQuote(t *testing.T) { sellReq, err := rfqmsg.NewSellRequest( peerID, assetSpec, lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -789,8 +1146,11 @@ func TestVerifyAcceptQuote(t *testing.T) { sellReq, err := rfqmsg.NewSellRequest( peerID, assetSpec, lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -811,64 +1171,665 @@ func TestVerifyAcceptQuote(t *testing.T) { expectStatus: InvalidAssetRatesQuoteRespStatus, expectErr: false, }, - } - - for _, tc := range tests { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - accept := tc.makeAccept(t) - oracle := &MockPriceOracle{} - tc.setupOracle(oracle) - - cfg := InternalPortfolioPilotConfig{ - PriceOracle: oracle, - ForwardPeerIDToOracle: false, - AcceptPriceDeviationPpm: 50_000, // 5% - MinAssetRatesExpiryLifetime: 10, - } - pilot, err := NewInternalPortfolioPilot(cfg) - require.NoError(t, err) - - ctx := context.Background() - status, err := pilot.VerifyAcceptQuote(ctx, accept) - - // Verify the status matches expectations. - require.Equal(t, tc.expectStatus, status) + // --- Rate bound enforcement cases --- - // Check error expectations. - if tc.expectErr { - // Unexpected error case: err should be non-nil. - require.Error(t, err) - require.Contains( - t, err.Error(), tc.expectErrSubstring, + { + name: "buy accept: rate below limit", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Buyer sets floor at 150 units/BTC, + // but peer offers only 100. + limit := rfqmath.NewBigIntFixedPoint( + 150, 0, + ) + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.None[uint64](), + fn.Some(limit), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) - } else { - // Expected validation failure or success: - // err should be nil. require.NoError(t, err) - } - oracle.AssertExpectations(t) - }) - } -} + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: RateBoundMissQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: rate at limit", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Buyer floor exactly equals accepted + // rate. + limit := rfqmath.NewBigIntFixedPoint( + 100, 0, + ) + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.None[uint64](), + fn.Some(limit), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) -// TestResolveRequestWithoutPriceOracleRejects ensures that requests are -// rejected during resolution if a price oracle is not configured for the -// internal portfolio pilot. -func TestResolveRequestWithoutPriceOracleRejects(t *testing.T) { - t.Parallel() + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: rate above limit", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Buyer floor is 50, accepted rate is + // 100 — should pass. + limit := rfqmath.NewBigIntFixedPoint( + 50, 0, + ) + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.None[uint64](), + fn.Some(limit), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + { + name: "sell accept: rate above limit", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Seller ceiling is 50, but accepted + // rate is 100 — should fail. + limit := rfqmath.NewBigIntFixedPoint( + 50, 0, + ) + sellReq, err := rfqmsg.NewSellRequest( + peerID, assetSpec, + lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.Some(limit), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.SellAccept{ + Peer: peerID, + Request: *sellReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + resp := OracleResponse{ + AssetRate: oracleRateMatch, + } + expectQuerySellPrice(p, &resp, nil) + }, + expectStatus: RateBoundMissQuoteRespStatus, + expectErr: false, + }, + { + name: "sell accept: rate at limit", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Seller ceiling equals accepted rate. + limit := rfqmath.NewBigIntFixedPoint( + 100, 0, + ) + sellReq, err := rfqmsg.NewSellRequest( + peerID, assetSpec, + lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.Some(limit), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.SellAccept{ + Peer: peerID, + Request: *sellReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + resp := OracleResponse{ + AssetRate: oracleRateMatch, + } + expectQuerySellPrice(p, &resp, nil) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + + // --- Min fill enforcement cases --- + + { + name: "buy accept: min fill zero msat", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Rate is very high (1e12 units/BTC), + // so 1 unit converts to ~0 msat. + hugeRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ), + validExpiryFuture, + ) + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.Some[uint64](1), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: hugeRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + oracleRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ), + validExpiryFuture, + ) + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRate, + }, nil, + ) + }, + expectStatus: MinFillNotMetQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: min fill transportable", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Rate of 100 units/BTC, min of 50 + // units = 0.5 BTC = 500M msat. Easily + // transportable. + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.Some[uint64](50), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + + // --- FOK enforcement cases --- + + { + name: "buy accept: FOK viable", + makeAccept: func(t *testing.T) rfqmsg.Accept { + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: FOK not viable", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Rate is huge: 1 unit converts + // to ~0 msat. + hugeRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ), + validExpiryFuture, + ) + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 1, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: hugeRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + oracleRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ), + validExpiryFuture, + ) + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRate, + }, nil, + ) + }, + expectStatus: FOKNotViableQuoteRespStatus, + expectErr: false, + }, + { + name: "sell accept: FOK viable", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // 50B msat at 100 units/BTC = + // 5 units (non-zero). + sellReq, err := rfqmsg.NewSellRequest( + peerID, assetSpec, + lnwire.MilliSatoshi(50_000_000_000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + + return &rfqmsg.SellAccept{ + Peer: peerID, + Request: *sellReq, + AssetRate: peerRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + resp := OracleResponse{ + AssetRate: oracleRateMatch, + } + expectQuerySellPrice(p, &resp, nil) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + { + name: "sell accept: FOK not viable", + makeAccept: func(t *testing.T) rfqmsg.Accept { + // Low rate (1 unit/BTC): 1 msat + // converts to 0 units. + lowRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1, 0, + ), + validExpiryFuture, + ) + sellReq, err := rfqmsg.NewSellRequest( + peerID, assetSpec, + lnwire.MilliSatoshi(1), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + + return &rfqmsg.SellAccept{ + Peer: peerID, + Request: *sellReq, + AssetRate: lowRate, + } + }, + setupOracle: func(p *MockPriceOracle) { + oracleRate := rfqmsg.NewAssetRate( + rfqmath.NewBigIntFixedPoint( + 1, 0, + ), + validExpiryFuture, + ) + resp := OracleResponse{ + AssetRate: oracleRate, + } + expectQuerySellPrice(p, &resp, nil) + }, + expectStatus: FOKNotViableQuoteRespStatus, + expectErr: false, + }, + + // --- Fill constraint cases --- + + { + name: "buy accept: fill < min fill", + makeAccept: func(t *testing.T) rfqmsg.Accept { + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.Some[uint64](50), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + AcceptedMaxAmount: fn.Some[uint64](30), + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: MinFillNotMetQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: FOK fill < max", + makeAccept: func(t *testing.T) rfqmsg.Accept { + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + AcceptedMaxAmount: fn.Some[uint64](80), + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: FOKNotViableQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: IOC partial fill accepted", + makeAccept: func(t *testing.T) rfqmsg.Accept { + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyIOC, + ), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + AcceptedMaxAmount: fn.Some[uint64](60), + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + { + name: "sell accept: fill < min fill", + makeAccept: func(t *testing.T) rfqmsg.Accept { + sellReq, err := rfqmsg.NewSellRequest( + peerID, assetSpec, + lnwire.MilliSatoshi(1000), + fn.Some( + lnwire.MilliSatoshi(500), + ), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.SellAccept{ + Peer: peerID, + Request: *sellReq, + AssetRate: peerRate, + AcceptedMaxAmount: fn.Some[uint64](300), + } + }, + setupOracle: func(p *MockPriceOracle) { + resp := OracleResponse{ + AssetRate: oracleRateMatch, + } + expectQuerySellPrice(p, &resp, nil) + }, + expectStatus: MinFillNotMetQuoteRespStatus, + expectErr: false, + }, + { + name: "sell accept: FOK fill < max", + makeAccept: func(t *testing.T) rfqmsg.Accept { + sellReq, err := rfqmsg.NewSellRequest( + peerID, assetSpec, + lnwire.MilliSatoshi(1000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + ) + require.NoError(t, err) + + return &rfqmsg.SellAccept{ + Peer: peerID, + Request: *sellReq, + AssetRate: peerRate, + AcceptedMaxAmount: fn.Some[uint64](800), + } + }, + setupOracle: func(p *MockPriceOracle) { + resp := OracleResponse{ + AssetRate: oracleRateMatch, + } + expectQuerySellPrice(p, &resp, nil) + }, + expectStatus: FOKNotViableQuoteRespStatus, + expectErr: false, + }, + { + name: "buy accept: fill >= min (passes)", + makeAccept: func(t *testing.T) rfqmsg.Accept { + buyReq, err := rfqmsg.NewBuyRequest( + peerID, assetSpec, 100, + fn.Some[uint64](50), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[rfqmsg.AssetRate](), + "metadata", + fn.None[rfqmsg.ExecutionPolicy](), + ) + require.NoError(t, err) + + return &rfqmsg.BuyAccept{ + Peer: peerID, + Request: *buyReq, + AssetRate: peerRate, + AcceptedMaxAmount: fn.Some[uint64](60), + } + }, + setupOracle: func(p *MockPriceOracle) { + expectQueryBuyPrice( + p, &OracleResponse{ + AssetRate: oracleRateMatch, + }, nil, + ) + }, + expectStatus: ValidAcceptQuoteRespStatus, + expectErr: false, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + accept := tc.makeAccept(t) + oracle := &MockPriceOracle{} + tc.setupOracle(oracle) + + cfg := InternalPortfolioPilotConfig{ + PriceOracle: oracle, + ForwardPeerIDToOracle: false, + AcceptPriceDeviationPpm: 50_000, // 5% + MinAssetRatesExpiryLifetime: 10, + } + pilot, err := NewInternalPortfolioPilot(cfg) + require.NoError(t, err) + + ctx := context.Background() + status, err := pilot.VerifyAcceptQuote(ctx, accept) + + // Verify the status matches expectations. + require.Equal(t, tc.expectStatus, status) + + // Check error expectations. + if tc.expectErr { + // Unexpected error case: err should be non-nil. + require.Error(t, err) + require.Contains( + t, err.Error(), tc.expectErrSubstring, + ) + } else { + // Expected validation failure or success: + // err should be nil. + require.NoError(t, err) + } + + oracle.AssertExpectations(t) + }) + } +} + +// TestResolveRequestWithoutPriceOracleRejects ensures that requests are +// rejected during resolution if a price oracle is not configured for the +// internal portfolio pilot. +func TestResolveRequestWithoutPriceOracleRejects(t *testing.T) { + t.Parallel() assetSpec := asset.NewSpecifierFromId(asset.ID{0x01, 0x02, 0x03}) peerID := route.Vertex{0x0A, 0x0B, 0x0C} req, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -906,8 +1867,11 @@ func TestVerifyAcceptQuoteWithoutPriceOracle(t *testing.T) { buyReq, err := rfqmsg.NewBuyRequest( peerID, assetSpec, 100, + fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), fn.None[rfqmsg.AssetRate](), "metadata", + fn.None[rfqmsg.ExecutionPolicy](), ) require.NoError(t, err) @@ -932,3 +1896,485 @@ func TestVerifyAcceptQuoteWithoutPriceOracle(t *testing.T) { require.NoError(t, err) require.Equal(t, PriceOracleQueryErrQuoteRespStatus, status) } + +// TestCheckRateBound exercises the checkRateBound helper directly. +func TestCheckRateBound(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{0x01}) + rate100 := rfqmath.NewBigIntFixedPoint(100, 0) + rate50 := rfqmath.NewBigIntFixedPoint(50, 0) + rate150 := rfqmath.NewBigIntFixedPoint(150, 0) + noLimit := fn.None[rfqmath.BigIntFixedPoint]() + + tests := []struct { + name string + req rfqmsg.Request + rate rfqmath.BigIntFixedPoint + expect QuoteRespStatus + }{ + { + name: "buy: no limit set", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetRateLimit: noLimit, + }, + rate: rate100, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: rate above limit", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetRateLimit: fn.Some(rate50), + }, + rate: rate100, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: rate at limit", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetRateLimit: fn.Some(rate100), + }, + rate: rate100, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: rate below limit", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetRateLimit: fn.Some(rate150), + }, + rate: rate100, + expect: RateBoundMissQuoteRespStatus, + }, + { + name: "sell: no limit set", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + AssetRateLimit: noLimit, + }, + rate: rate100, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: rate below limit", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + AssetRateLimit: fn.Some(rate150), + }, + rate: rate100, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: rate at limit", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + AssetRateLimit: fn.Some(rate100), + }, + rate: rate100, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: rate above limit", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + AssetRateLimit: fn.Some(rate50), + }, + rate: rate100, + expect: RateBoundMissQuoteRespStatus, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status := checkRateBound(tc.req, tc.rate) + require.Equal(t, tc.expect, status) + }) + } +} + +// TestCheckMinFill exercises the checkMinFill helper directly. +func TestCheckMinFill(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{0x01}) + + // A rate of 100 units/BTC. 50 units = 0.5 BTC = + // 50_000_000_000 msat (easily non-zero). + normalRate := rfqmath.NewBigIntFixedPoint(100, 0) + + // A huge rate: 1e12 units/BTC. 1 unit = 1e-12 BTC = + // 0.1 msat, rounds to 0. + hugeRate := rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ) + + tests := []struct { + name string + req rfqmsg.Request + rate rfqmath.BigIntFixedPoint + expect QuoteRespStatus + }{ + { + name: "buy: no min set", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetMinAmt: fn.None[uint64](), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: min fill transportable", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](50), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: min fill rounds to zero", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](1), + }, + rate: hugeRate, + expect: MinFillNotMetQuoteRespStatus, + }, + { + name: "sell: no min set", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + PaymentMinAmt: fn.None[lnwire.MilliSatoshi](), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: min fill transportable", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: lnwire.MilliSatoshi( + 50_000_000_000, + ), + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi( + 10_000_000_000, + ), + ), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + // NOTE: A sell min fill that rounds to zero units is + // very hard to trigger with the default arithmetic + // scale (11), since MilliSatoshiToUnits preserves + // enough precision for any non-zero msat. The + // check acts as a safety net for degenerate inputs. + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status := checkMinFill(tc.req, tc.rate) + require.Equal(t, tc.expect, status) + }) + } +} + +// TestCheckFOK exercises the checkFOK helper directly. +func TestCheckFOK(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{0x01}) + + // Normal rate: 100 units/BTC. + normalRate := rfqmath.NewBigIntFixedPoint(100, 0) + + // Huge rate: 1e12 units/BTC. Max of 1 unit converts + // to ~0 msat. + hugeRate := rfqmath.NewBigIntFixedPoint( + 1_000_000_000_000, 0, + ) + + tests := []struct { + name string + req rfqmsg.Request + rate rfqmath.BigIntFixedPoint + expect QuoteRespStatus + }{ + { + name: "buy: no policy", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: IOC policy", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyIOC, + ), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: FOK viable", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: FOK rounds to zero", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 1, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + rate: hugeRate, + expect: FOKNotViableQuoteRespStatus, + }, + { + name: "sell: no policy", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: FOK viable", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: lnwire.MilliSatoshi( + 50_000_000_000, + ), + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + rate: normalRate, + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: FOK rounds to zero", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: lnwire.MilliSatoshi(1), + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + // 1 unit/BTC: 1 msat = 1e-11 BTC * 1 = 0 units. + rate: rfqmath.NewBigIntFixedPoint(1, 0), + expect: FOKNotViableQuoteRespStatus, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status := checkFOK(tc.req, tc.rate) + require.Equal(t, tc.expect, status) + }) + } +} + +// TestCheckFillConstraints exercises the checkFillConstraints helper +// directly. +func TestCheckFillConstraints(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{0x01}) + + tests := []struct { + name string + req rfqmsg.Request + fill fn.Option[uint64] + expect QuoteRespStatus + }{ + { + name: "buy: no fill", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](50), + }, + fill: fn.None[uint64](), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: fill >= min", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](50), + }, + fill: fn.Some[uint64](60), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: fill < min", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](50), + }, + fill: fn.Some[uint64](30), + expect: MinFillNotMetQuoteRespStatus, + }, + { + name: "buy: FOK fill == max", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + fill: fn.Some[uint64](100), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "buy: FOK fill < max", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + fill: fn.Some[uint64](80), + expect: FOKNotViableQuoteRespStatus, + }, + { + name: "sell: no fill", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(500), + ), + }, + fill: fn.None[uint64](), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: fill >= min", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(500), + ), + }, + fill: fn.Some[uint64](600), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: fill < min", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(500), + ), + }, + fill: fn.Some[uint64](300), + expect: MinFillNotMetQuoteRespStatus, + }, + { + name: "sell: FOK fill == max", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + fill: fn.Some[uint64](1000), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: FOK fill < max", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyFOK, + ), + }, + fill: fn.Some[uint64](800), + expect: FOKNotViableQuoteRespStatus, + }, + { + name: "buy: IOC fill < max (partial fill ok)", + req: &rfqmsg.BuyRequest{ + AssetSpecifier: spec, + AssetMaxAmt: 100, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyIOC, + ), + }, + fill: fn.Some[uint64](60), + expect: ValidAcceptQuoteRespStatus, + }, + { + name: "sell: IOC fill < max (partial fill ok)", + req: &rfqmsg.SellRequest{ + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + ExecutionPolicy: fn.Some( + rfqmsg.ExecutionPolicyIOC, + ), + }, + fill: fn.Some[uint64](600), + expect: ValidAcceptQuoteRespStatus, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + status := checkFillConstraints( + tc.req, tc.fill, + ) + require.Equal(t, tc.expect, status) + }) + } +} diff --git a/rfqmsg/accept.go b/rfqmsg/accept.go index a95b62a4c8..f411e786d3 100644 --- a/rfqmsg/accept.go +++ b/rfqmsg/accept.go @@ -6,9 +6,19 @@ import ( "io" "time" + "github.com/lightninglabs/taproot-assets/fn" "github.com/lightningnetwork/lnd/tlv" ) +type ( + // acceptMaxInAsset is a type alias for a record that represents + // an optional maximum in-asset amount (fill quantity) in the + // accept message. + acceptMaxInAsset = tlv.OptionalRecordT[ + tlv.TlvType11, uint64, + ] +) + const ( // latestAcceptWireMsgDataVersion is the latest supported quote accept // wire message data field version. @@ -36,6 +46,10 @@ type acceptWireMsgData struct { // OutAssetRate is the out-asset to BTC rate. OutAssetRate tlv.RecordT[tlv.TlvType10, TlvFixedPoint] + + // MaxInAsset is an optional maximum in-asset amount (fill + // quantity) that the responder is willing to accept. + MaxInAsset acceptMaxInAsset } // newAcceptWireMsgDataFromBuy creates a new acceptWireMsgData from a buy @@ -60,6 +74,14 @@ func newAcceptWireMsgDataFromBuy(q BuyAccept) (acceptWireMsgData, error) { NewTlvFixedPointFromBigInt(MilliSatPerBtc), ) + // Set optional max in-asset fill quantity. + var maxInAsset acceptMaxInAsset + q.AcceptedMaxAmount.WhenSome(func(amt uint64) { + maxInAsset = tlv.SomeRecordT[tlv.TlvType11]( + tlv.NewPrimitiveRecord[tlv.TlvType11](amt), + ) + }) + // Encode message data component as TLV bytes. return acceptWireMsgData{ Version: version, @@ -68,6 +90,7 @@ func newAcceptWireMsgDataFromBuy(q BuyAccept) (acceptWireMsgData, error) { Sig: sig, InAssetRate: inAssetRate, OutAssetRate: outAssetRate, + MaxInAsset: maxInAsset, }, nil } @@ -93,6 +116,14 @@ func newAcceptWireMsgDataFromSell(q SellAccept) (acceptWireMsgData, error) { rate := NewTlvFixedPointFromBigInt(q.AssetRate.Rate) outAssetRate := tlv.NewRecordT[tlv.TlvType10](rate) + // Set optional max in-asset fill quantity. + var maxInAsset acceptMaxInAsset + q.AcceptedMaxAmount.WhenSome(func(amt uint64) { + maxInAsset = tlv.SomeRecordT[tlv.TlvType11]( + tlv.NewPrimitiveRecord[tlv.TlvType11](amt), + ) + }) + // Encode message data component as TLV bytes. return acceptWireMsgData{ Version: version, @@ -101,6 +132,7 @@ func newAcceptWireMsgDataFromSell(q SellAccept) (acceptWireMsgData, error) { Sig: sig, InAssetRate: inAssetRate, OutAssetRate: outAssetRate, + MaxInAsset: maxInAsset, }, nil } @@ -137,6 +169,12 @@ func (m *acceptWireMsgData) Encode(w io.Writer) error { m.OutAssetRate.Record(), } + m.MaxInAsset.WhenSome( + func(r tlv.RecordT[tlv.TlvType11, uint64]) { + records = append(records, r.Record()) + }, + ) + tlv.SortRecords(records) // Create the tlv stream. @@ -150,6 +188,8 @@ func (m *acceptWireMsgData) Encode(w io.Writer) error { // Decode deserializes the acceptWireMsgData from the given io.Reader. func (m *acceptWireMsgData) Decode(r io.Reader) error { + maxInAsset := m.MaxInAsset.Zero() + // Create a tlv stream with all the fields. tlvStream, err := tlv.NewStream( m.Version.Record(), @@ -158,17 +198,27 @@ func (m *acceptWireMsgData) Decode(r io.Reader) error { m.Sig.Record(), m.InAssetRate.Record(), m.OutAssetRate.Record(), + maxInAsset.Record(), ) if err != nil { return err } // Decode the reader's contents into the tlv stream. - _, err = tlvStream.DecodeWithParsedTypes(r) + tlvMap, err := tlvStream.DecodeWithParsedTypes(r) if err != nil { return err } + if _, ok := tlvMap[maxInAsset.TlvType()]; ok { + // Normalize 0 to None: a zero fill is semantically + // "unset" (full request max) and must not cap the + // policy at zero. + if maxInAsset.Val > 0 { + m.MaxInAsset = tlv.SomeRecordT(maxInAsset) + } + } + return nil } @@ -251,6 +301,10 @@ type Accept interface { // message responds to. OriginalRequest() Request + // AcceptedFillAmount returns the optional negotiated fill + // quantity. When None the full request max is implied. + AcceptedFillAmount() fn.Option[uint64] + // acceptMarker is an unexported marker method that ensures only rfqmsg // package types may satisfy this interface. acceptMarker() @@ -258,14 +312,18 @@ type Accept interface { // NewQuoteAcceptFromRequest creates a new instance of a quote accept message // given a quote request message. -func NewQuoteAcceptFromRequest(request Request, assetRate AssetRate) (Accept, - error) { +func NewQuoteAcceptFromRequest(request Request, assetRate AssetRate, + fillAmount fn.Option[uint64]) (Accept, error) { switch req := request.(type) { case *BuyRequest: - return NewBuyAcceptFromRequest(*req, assetRate), nil + return NewBuyAcceptFromRequest( + *req, assetRate, fillAmount, + ), nil case *SellRequest: - return NewSellAcceptFromRequest(*req, assetRate), nil + return NewSellAcceptFromRequest( + *req, assetRate, fillAmount, + ), nil default: return nil, fmt.Errorf("unknown request type: %T", request) } diff --git a/rfqmsg/accept_test.go b/rfqmsg/accept_test.go index 4c9cd4f597..3d462122b6 100644 --- a/rfqmsg/accept_test.go +++ b/rfqmsg/accept_test.go @@ -21,6 +21,7 @@ type acceptEncodeDecodeTC struct { sig [64]byte inAssetRate TlvFixedPoint outAssetRate TlvFixedPoint + maxInAsset acceptMaxInAsset } // MsgData generates a acceptWireMsgData instance from the test case. @@ -39,6 +40,7 @@ func (tc acceptEncodeDecodeTC) MsgData() acceptWireMsgData { Sig: sig, InAssetRate: inAssetRate, OutAssetRate: outAssetRate, + MaxInAsset: tc.maxInAsset, } } @@ -83,6 +85,29 @@ func TestAcceptMsgDataEncodeDecode(t *testing.T) { inAssetRate: inAssetRate, outAssetRate: outAssetRate, }, + { + testName: "with max in-asset fill", + version: V1, + id: id, + expiry: expiry, + sig: randSig, + inAssetRate: inAssetRate, + outAssetRate: outAssetRate, + maxInAsset: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType11]( + uint64(500), + ), + ), + }, + { + testName: "no max in-asset fill", + version: V1, + id: id, + expiry: expiry, + sig: randSig, + inAssetRate: inAssetRate, + outAssetRate: outAssetRate, + }, } for _, tc := range testCases { @@ -105,4 +130,34 @@ func TestAcceptMsgDataEncodeDecode(t *testing.T) { require.Equal(tt, msgData, decodedMsgData) }) } + + // Verify that a zero fill value on the wire is normalised to + // None during decode. + t.Run("zero max in-asset normalised to None", func(tt *testing.T) { + zeroFill := acceptEncodeDecodeTC{ + testName: "zero fill", + version: V1, + id: id, + expiry: expiry, + sig: randSig, + inAssetRate: inAssetRate, + outAssetRate: outAssetRate, + maxInAsset: tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType11]( + uint64(0), + ), + ), + } + msgData := zeroFill.MsgData() + + msgDataBytes, err := msgData.Bytes() + require.NoError(tt, err) + + var decoded acceptWireMsgData + err = decoded.Decode(bytes.NewReader(msgDataBytes)) + require.NoError(tt, err) + + // Zero should have been normalised away. + require.True(tt, decoded.MaxInAsset.IsNone()) + }) } diff --git a/rfqmsg/buy_accept.go b/rfqmsg/buy_accept.go index e522709e19..b6e423278b 100644 --- a/rfqmsg/buy_accept.go +++ b/rfqmsg/buy_accept.go @@ -4,7 +4,9 @@ import ( "fmt" "time" + "github.com/lightninglabs/taproot-assets/fn" "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/tlv" ) const ( @@ -32,6 +34,11 @@ type BuyAccept struct { // AssetRate is the accepted asset to BTC rate. AssetRate AssetRate + // AcceptedMaxAmount is an optional fill quantity that caps the + // amount the responder is willing to accept. When None the full + // request max is implied. + AcceptedMaxAmount fn.Option[uint64] + // sig is a signature over the serialized contents of the message. sig [64]byte @@ -46,15 +53,17 @@ type BuyAccept struct { // AgreedAt value (e.g., when reconstructing from storage), they should // manually construct the BuyAccept. func NewBuyAcceptFromRequest(request BuyRequest, - assetRate AssetRate) *BuyAccept { + assetRate AssetRate, + fillAmount fn.Option[uint64]) *BuyAccept { return &BuyAccept{ - Peer: request.Peer, - Request: request, - Version: latestBuyAcceptVersion, - ID: request.ID, - AssetRate: assetRate, - AgreedAt: time.Now().UTC(), + Peer: request.Peer, + Request: request, + Version: latestBuyAcceptVersion, + ID: request.ID, + AssetRate: assetRate, + AcceptedMaxAmount: fillAmount, + AgreedAt: time.Now().UTC(), } } @@ -75,14 +84,23 @@ func newBuyAcceptFromWireMsg(wireMsg WireMessage, // Convert the unix timestamp in seconds to a time.Time. expiry := time.Unix(int64(msgData.Expiry.Val), 0).UTC() + // Extract the optional fill quantity. + var acceptedMax fn.Option[uint64] + msgData.MaxInAsset.WhenSome( + func(r tlv.RecordT[tlv.TlvType11, uint64]) { + acceptedMax = fn.Some(r.Val) + }, + ) + return &BuyAccept{ - Peer: wireMsg.Peer, - Request: request, - Version: msgData.Version.Val, - ID: msgData.ID.Val, - AssetRate: NewAssetRate(assetRate, expiry), - sig: msgData.Sig.Val, - AgreedAt: time.Now().UTC(), + Peer: wireMsg.Peer, + Request: request, + Version: msgData.Version.Val, + ID: msgData.ID.Val, + AssetRate: NewAssetRate(assetRate, expiry), + AcceptedMaxAmount: acceptedMax, + sig: msgData.Sig.Val, + AgreedAt: time.Now().UTC(), }, nil } @@ -142,14 +160,28 @@ func (q *BuyAccept) OriginalRequest() Request { return &q.Request } +// AcceptedFillAmount returns the optional negotiated fill quantity. +func (q *BuyAccept) AcceptedFillAmount() fn.Option[uint64] { + return q.AcceptedMaxAmount +} + // acceptMarker makes BuyAccept satisfy the Accept interface while keeping // implementations local to this package. func (q *BuyAccept) acceptMarker() {} // String returns a human-readable string representation of the message. func (q *BuyAccept) String() string { - return fmt.Sprintf("BuyAccept(peer=%x, id=%x, asset_rate=%s, scid=%d)", - q.Peer[:], q.ID[:], q.AssetRate.String(), q.ShortChannelId()) + fillStr := "" + q.AcceptedMaxAmount.WhenSome(func(amt uint64) { + fillStr = fmt.Sprintf(", fill=%d", amt) + }) + + return fmt.Sprintf( + "BuyAccept(peer=%x, id=%x, asset_rate=%s, "+ + "scid=%d%s)", + q.Peer[:], q.ID[:], q.AssetRate.String(), + q.ShortChannelId(), fillStr, + ) } // Ensure that the message type implements the OutgoingMsg interface. diff --git a/rfqmsg/buy_request.go b/rfqmsg/buy_request.go index b61ed89a7b..db4a05302d 100644 --- a/rfqmsg/buy_request.go +++ b/rfqmsg/buy_request.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/tlv" ) @@ -54,6 +55,21 @@ type BuyRequest struct { // peer must agree to divest. AssetMaxAmt uint64 + // AssetMinAmt is an optional minimum asset amount for the quote. + // When set, the responding peer must be willing to divest at + // least this many units. + AssetMinAmt fn.Option[uint64] + + // AssetRateLimit is an optional minimum acceptable rate (asset + // units per BTC). The buyer sets a floor: "I won't accept fewer + // than X units per BTC." + AssetRateLimit fn.Option[rfqmath.BigIntFixedPoint] + + // ExecutionPolicy is an optional execution policy for the + // quote request. IOC (default) accepts partial fills; FOK + // requires the full max amount to be viable. + ExecutionPolicy fn.Option[ExecutionPolicy] + // AssetRateHint represents a proposed conversion rate between the // subject asset and BTC. This rate is an initial suggestion intended to // initiate the RFQ negotiation process and may differ from the final @@ -71,8 +87,11 @@ type BuyRequest struct { // NewBuyRequest creates a new asset buy quote request. func NewBuyRequest(peer route.Vertex, assetSpecifier asset.Specifier, - assetMaxAmt uint64, assetRateHint fn.Option[AssetRate], - oracleMetadata string) (*BuyRequest, error) { + assetMaxAmt uint64, assetMinAmt fn.Option[uint64], + assetRateLimit fn.Option[rfqmath.BigIntFixedPoint], + assetRateHint fn.Option[AssetRate], + oracleMetadata string, + execPolicy fn.Option[ExecutionPolicy]) (*BuyRequest, error) { id, err := NewID() if err != nil { @@ -86,15 +105,25 @@ func NewBuyRequest(peer route.Vertex, assetSpecifier asset.Specifier, "length of %d bytes", MaxOracleMetadataLength) } - return &BuyRequest{ + req := &BuyRequest{ Peer: peer, Version: latestBuyRequestVersion, ID: id, AssetSpecifier: assetSpecifier, AssetMaxAmt: assetMaxAmt, + AssetMinAmt: assetMinAmt, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, AssetRateHint: assetRateHint, PriceOracleMetadata: oracleMetadata, - }, nil + } + + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("unable to validate buy "+ + "request: %w", err) + } + + return req, nil } // NewBuyRequestFromWire instantiates a new instance from a wire message. @@ -153,13 +182,43 @@ func NewBuyRequestFromWire(wireMsg WireMessage, }, ) + // Extract optional min asset amount. + var assetMinAmt fn.Option[uint64] + msgData.MinInAsset.WhenSome( + func(r tlv.RecordT[tlv.TlvType23, uint64]) { + if r.Val > 0 { + assetMinAmt = fn.Some(r.Val) + } + }, + ) + + // Extract optional rate limit. + var assetRateLimit fn.Option[rfqmath.BigIntFixedPoint] + msgData.AssetRateLimit.WhenSome( + func(r tlv.RecordT[tlv.TlvType29, TlvFixedPoint]) { + fp := r.Val.IntoBigIntFixedPoint() + assetRateLimit = fn.Some(fp) + }, + ) + + // Extract optional execution policy. + var execPolicy fn.Option[ExecutionPolicy] + msgData.ExecutionPolicy.WhenSome( + func(r tlv.RecordT[tlv.TlvType31, uint8]) { + execPolicy = fn.Some(ExecutionPolicy(r.Val)) + }, + ) + req := BuyRequest{ - Peer: wireMsg.Peer, - Version: msgData.Version.Val, - ID: msgData.ID.Val, - AssetSpecifier: assetSpecifier, - AssetMaxAmt: msgData.MaxInAsset.Val, - AssetRateHint: assetRateHint, + Peer: wireMsg.Peer, + Version: msgData.Version.Val, + ID: msgData.ID.Val, + AssetSpecifier: assetSpecifier, + AssetMaxAmt: msgData.MaxInAsset.Val, + AssetMinAmt: assetMinAmt, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, + AssetRateHint: assetRateHint, } msgData.PriceOracleMetadata.ValOpt().WhenSome(func(metaBytes []byte) { @@ -195,6 +254,30 @@ func (q *BuyRequest) Validate() error { "length of %d bytes", MaxOracleMetadataLength) } + // Ensure min <= max when min is set. + err = fn.MapOptionZ(q.AssetMinAmt, func(minAmt uint64) error { + if minAmt > q.AssetMaxAmt { + return fmt.Errorf("asset min amount (%d) exceeds "+ + "max amount (%d)", minAmt, q.AssetMaxAmt) + } + return nil + }) + if err != nil { + return err + } + + // Ensure rate limit is strictly positive when set. + if err := validateRateLimit(q.AssetRateLimit); err != nil { + return err + } + + // Ensure execution policy is valid when set. + if err := validateExecutionPolicy( + q.ExecutionPolicy, + ); err != nil { + return err + } + // Ensure that the suggested asset rate has not expired. err = fn.MapOptionZ(q.AssetRateHint, func(rate AssetRate) error { if rate.Expiry.Before(time.Now()) { @@ -246,6 +329,18 @@ func (q *BuyRequest) MsgID() ID { return q.ID } +// Constraints returns the normalised limit-order constraints for +// this buy request. +func (q *BuyRequest) Constraints() RequestConstraints { + return RequestConstraints{ + MaxAmount: q.AssetMaxAmt, + MinAmount: q.AssetMinAmt, + RateLimit: q.AssetRateLimit, + RateBoundCmp: -1, + ExecutionPolicy: q.ExecutionPolicy, + } +} + // requestMarker makes BuyRequest satisfy the Request interface while keeping // implementations local to this package. func (q *BuyRequest) requestMarker() {} @@ -261,10 +356,29 @@ func (q *BuyRequest) String() string { }, ) + minAmtStr := fn.MapOptionZ(q.AssetMinAmt, func(v uint64) string { + return fmt.Sprintf(", min_asset_amount=%d", v) + }) + + rateLimitStr := fn.MapOptionZ( + q.AssetRateLimit, + func(v rfqmath.BigIntFixedPoint) string { + return fmt.Sprintf(", asset_rate_limit=%s", + v.String()) + }, + ) + + execPolicyStr := fn.MapOptionZ( + q.ExecutionPolicy, + func(p ExecutionPolicy) string { + return fmt.Sprintf(", exec_policy=%d", p) + }, + ) + return fmt.Sprintf("BuyRequest(peer=%x, id=%x, asset=%s, "+ - "max_asset_amount=%d, asset_rate_hint=%s)", + "max_asset_amount=%d%s%s%s, asset_rate_hint=%s)", q.Peer[:], q.ID[:], q.AssetSpecifier.String(), q.AssetMaxAmt, - assetRateHintStr) + minAmtStr, rateLimitStr, execPolicyStr, assetRateHintStr) } // Ensure that the message type implements the OutgoingMsg interface. diff --git a/rfqmsg/buy_request_test.go b/rfqmsg/buy_request_test.go new file mode 100644 index 0000000000..5f3b9cc5be --- /dev/null +++ b/rfqmsg/buy_request_test.go @@ -0,0 +1,267 @@ +package rfqmsg + +import ( + "bytes" + "testing" + "time" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// buyRequestRoundtrip encodes a BuyRequest to wire and decodes it +// back, returning the decoded request. +func buyRequestRoundtrip(t *testing.T, + req *BuyRequest) *BuyRequest { + + t.Helper() + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode(bytes.NewReader(wireMsg.Data)) + require.NoError(t, err) + + decoded, err := NewBuyRequestFromWire(wireMsg, msgData) + require.NoError(t, err) + + return decoded +} + +// TestBuyRequestMinAmtRoundtrip verifies that AssetMinAmt survives +// a wire roundtrip. +func TestBuyRequestMinAmtRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x01} + spec := asset.NewSpecifierFromId(asset.ID{0xAA}) + + req, err := NewBuyRequest( + peer, spec, 100, fn.Some[uint64](50), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := buyRequestRoundtrip(t, req) + + require.True(t, decoded.AssetMinAmt.IsSome()) + decoded.AssetMinAmt.WhenSome(func(v uint64) { + require.Equal(t, uint64(50), v) + }) + require.Equal(t, uint64(100), decoded.AssetMaxAmt) +} + +// TestBuyRequestRateLimitRoundtrip verifies that AssetRateLimit +// survives a wire roundtrip. +func TestBuyRequestRateLimitRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x02} + spec := asset.NewSpecifierFromId(asset.ID{0xBB}) + limit := rfqmath.NewBigIntFixedPoint(42000, 2) + + req, err := NewBuyRequest( + peer, spec, 200, fn.None[uint64](), + fn.Some(limit), fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := buyRequestRoundtrip(t, req) + + require.True(t, decoded.AssetRateLimit.IsSome()) + decoded.AssetRateLimit.WhenSome( + func(v rfqmath.BigIntFixedPoint) { + require.Equal(t, 0, v.Cmp(limit)) + }, + ) +} + +// TestBuyRequestNoOptionalFieldsRoundtrip verifies backward +// compatibility when no optional fields are set. +func TestBuyRequestNoOptionalFieldsRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x03} + spec := asset.NewSpecifierFromId(asset.ID{0xCC}) + + req, err := NewBuyRequest( + peer, spec, 300, fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := buyRequestRoundtrip(t, req) + + require.True(t, decoded.AssetMinAmt.IsNone()) + require.True(t, decoded.AssetRateLimit.IsNone()) + require.Equal(t, uint64(300), decoded.AssetMaxAmt) +} + +// TestBuyRequestAllFieldsRoundtrip verifies that all optional fields +// survive a roundtrip together. +func TestBuyRequestAllFieldsRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x04} + spec := asset.NewSpecifierFromId(asset.ID{0xDD}) + limit := rfqmath.NewBigIntFixedPoint(99000, 3) + expiry := time.Now().Add(5 * time.Minute).UTC() + rateHint := NewAssetRate( + rfqmath.NewBigIntFixedPoint(100000, 0), expiry, + ) + + req, err := NewBuyRequest( + peer, spec, 500, fn.Some[uint64](10), + fn.Some(limit), fn.Some(rateHint), "metadata", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := buyRequestRoundtrip(t, req) + + require.True(t, decoded.AssetMinAmt.IsSome()) + decoded.AssetMinAmt.WhenSome(func(v uint64) { + require.Equal(t, uint64(10), v) + }) + + require.True(t, decoded.AssetRateLimit.IsSome()) + decoded.AssetRateLimit.WhenSome( + func(v rfqmath.BigIntFixedPoint) { + require.Equal(t, 0, v.Cmp(limit)) + }, + ) + + require.True(t, decoded.AssetRateHint.IsSome()) + require.Equal(t, "metadata", decoded.PriceOracleMetadata) +} + +// TestBuyRequestValidateMinGtMax ensures Validate rejects min > max. +func TestBuyRequestValidateMinGtMax(t *testing.T) { + t.Parallel() + + req := &BuyRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](200), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), + AssetRateHint: fn.None[AssetRate](), + } + + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds max amount") +} + +// TestBuyRequestValidateMinEqMax ensures min == max is valid. +func TestBuyRequestValidateMinEqMax(t *testing.T) { + t.Parallel() + + req := &BuyRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + AssetMaxAmt: 100, + AssetMinAmt: fn.Some[uint64](100), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), + AssetRateHint: fn.None[AssetRate](), + } + + err := req.Validate() + require.NoError(t, err) +} + +// TestBuyRequestValidateZeroRateLimit ensures a zero rate limit +// coefficient is rejected. +func TestBuyRequestValidateZeroRateLimit(t *testing.T) { + t.Parallel() + + zeroLimit := rfqmath.NewBigIntFixedPoint(0, 0) + req := &BuyRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + AssetMaxAmt: 100, + AssetMinAmt: fn.None[uint64](), + AssetRateLimit: fn.Some(zeroLimit), + AssetRateHint: fn.None[AssetRate](), + } + + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "must be positive") +} + +// TestBuyRequestExecutionPolicyRoundtrip verifies that a FOK +// execution policy survives a wire roundtrip. +func TestBuyRequestExecutionPolicyRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x05} + spec := asset.NewSpecifierFromId(asset.ID{0xEE}) + + req, err := NewBuyRequest( + peer, spec, 400, fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.Some(ExecutionPolicyFOK), + ) + require.NoError(t, err) + + decoded := buyRequestRoundtrip(t, req) + + require.True(t, decoded.ExecutionPolicy.IsSome()) + decoded.ExecutionPolicy.WhenSome( + func(v ExecutionPolicy) { + require.Equal(t, ExecutionPolicyFOK, v) + }, + ) +} + +// TestBuyRequestNoExecutionPolicyRoundtrip verifies that an absent +// execution policy stays None after a wire roundtrip. +func TestBuyRequestNoExecutionPolicyRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x06} + spec := asset.NewSpecifierFromId(asset.ID{0xFF}) + + req, err := NewBuyRequest( + peer, spec, 500, fn.None[uint64](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := buyRequestRoundtrip(t, req) + + require.True(t, decoded.ExecutionPolicy.IsNone()) +} + +// TestBuyRequestInvalidExecutionPolicy ensures Validate rejects +// an unknown execution policy value. +func TestBuyRequestInvalidExecutionPolicy(t *testing.T) { + t.Parallel() + + req := &BuyRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + AssetMaxAmt: 100, + AssetMinAmt: fn.None[uint64](), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), + AssetRateHint: fn.None[AssetRate](), + ExecutionPolicy: fn.Some(ExecutionPolicy(2)), + } + + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "execution policy") +} diff --git a/rfqmsg/reject.go b/rfqmsg/reject.go index 2988d6f015..56c4552131 100644 --- a/rfqmsg/reject.go +++ b/rfqmsg/reject.go @@ -89,6 +89,18 @@ const ( // PriceOracleUnavailableRejectCode indicates that a request-for-quote // was rejected as a price oracle was unavailable. PriceOracleUnavailableRejectCode RejectCode = 1 + + // MinFillNotMetRejectCode indicates that the minimum fill + // constraint was not satisfiable at the accepted rate. + MinFillNotMetRejectCode RejectCode = 2 + + // PriceBoundMissRejectCode indicates that the accepted rate + // violated the requester's rate limit constraint. + PriceBoundMissRejectCode RejectCode = 3 + + // FOKNotViableRejectCode indicates that the FOK execution + // policy could not be satisfied at the accepted rate. + FOKNotViableRejectCode RejectCode = 4 ) var ( @@ -105,6 +117,27 @@ var ( Code: PriceOracleUnavailableRejectCode, Msg: "price oracle unavailable", } + + // ErrMinFillNotMet is the error for when the minimum fill + // constraint cannot be met at the accepted rate. + ErrMinFillNotMet = RejectErr{ + Code: MinFillNotMetRejectCode, + Msg: "minimum fill not met", + } + + // ErrPriceBoundMiss is the error for when the accepted rate + // violates the requester's rate limit constraint. + ErrPriceBoundMiss = RejectErr{ + Code: PriceBoundMissRejectCode, + Msg: "rate limit constraint violated", + } + + // ErrFOKNotViable is the error for when the FOK execution + // policy cannot be satisfied at the accepted rate. + ErrFOKNotViable = RejectErr{ + Code: FOKNotViableRejectCode, + Msg: "FOK not viable at accepted rate", + } ) // NewRejectErr produces the "unknown" error code, but pairs it with a diff --git a/rfqmsg/request.go b/rfqmsg/request.go index 477f0a6e2e..a0cef13736 100644 --- a/rfqmsg/request.go +++ b/rfqmsg/request.go @@ -8,7 +8,10 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" lfn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/tlv" ) @@ -59,8 +62,70 @@ type ( // the optional metadata that can be included in a quote request to // provide additional context to the price oracle. requestOracleMetadata = tlv.OptionalRecordT[tlv.TlvType27, []byte] + + // requestAssetRateLimit is a type alias for a record that + // represents an optional rate limit constraint on the quote + // request. + requestAssetRateLimit = tlv.OptionalRecordT[ + tlv.TlvType29, TlvFixedPoint, + ] + + // requestExecutionPolicy is a type alias for a record that + // represents an optional execution policy on the quote + // request. + requestExecutionPolicy = tlv.OptionalRecordT[ + tlv.TlvType31, uint8, + ] +) + +// ExecutionPolicy specifies how a quote request should be filled. +type ExecutionPolicy uint8 + +const ( + // ExecutionPolicyIOC (Immediate-Or-Cancel) accepts any partial + // fill >= the min threshold. This is the default behaviour. + ExecutionPolicyIOC ExecutionPolicy = 0 + + // ExecutionPolicyFOK (Fill-Or-Kill) requires the accepted rate + // to support the full max amount or the quote is rejected. + ExecutionPolicyFOK ExecutionPolicy = 1 ) +// validateRateLimit checks that an optional rate limit has a strictly +// positive coefficient. Shared by BuyRequest and SellRequest. +func validateRateLimit( + limit fn.Option[rfqmath.BigIntFixedPoint]) error { + + return fn.MapOptionZ( + limit, + func(fp rfqmath.BigIntFixedPoint) error { + zero := rfqmath.NewBigIntFromUint64(0) + if !fp.Coefficient.Gt(zero) { + return fmt.Errorf("asset rate limit " + + "coefficient must be positive") + } + return nil + }, + ) +} + +// validateExecutionPolicy checks that an optional execution policy +// is a known value. Shared by BuyRequest and SellRequest. +func validateExecutionPolicy( + p fn.Option[ExecutionPolicy]) error { + + return fn.MapOptionZ( + p, + func(ep ExecutionPolicy) error { + if ep > ExecutionPolicyFOK { + return fmt.Errorf("invalid execution "+ + "policy: %d", ep) + } + return nil + }, + ) +} + // requestWireMsgData is a struct that represents the message data field for // a quote request wire message. type requestWireMsgData struct { @@ -138,6 +203,15 @@ type requestWireMsgData struct { // price oracle can use to give out a more accurate (or discount) asset // rate. The maximum length of this field is 32'768 bytes. PriceOracleMetadata requestOracleMetadata + + // AssetRateLimit is an optional rate limit constraint. For buy + // requests this is the minimum acceptable rate; for sell requests + // this is the maximum acceptable rate. + AssetRateLimit requestAssetRateLimit + + // ExecutionPolicy is an optional execution policy for the + // quote request (IOC or FOK). + ExecutionPolicy requestExecutionPolicy } // newRequestWireMsgDataFromBuy creates a new requestWireMsgData from a buy @@ -201,6 +275,33 @@ func newRequestWireMsgDataFromBuy(q BuyRequest) (requestWireMsgData, error) { ) } + // Set optional min asset amount. + var minInAsset tlv.OptionalRecordT[tlv.TlvType23, uint64] + q.AssetMinAmt.WhenSome(func(minAmt uint64) { + minInAsset = tlv.SomeRecordT[tlv.TlvType23]( + tlv.NewPrimitiveRecord[tlv.TlvType23](minAmt), + ) + }) + + // Set optional rate limit. + var assetRateLimit requestAssetRateLimit + q.AssetRateLimit.WhenSome(func(limit rfqmath.BigIntFixedPoint) { + wireRate := NewTlvFixedPointFromBigInt(limit) + assetRateLimit = tlv.SomeRecordT[tlv.TlvType29]( + tlv.NewRecordT[tlv.TlvType29](wireRate), + ) + }) + + // Set optional execution policy. + var execPolicy requestExecutionPolicy + q.ExecutionPolicy.WhenSome(func(p ExecutionPolicy) { + execPolicy = tlv.SomeRecordT[tlv.TlvType31]( + tlv.NewPrimitiveRecord[tlv.TlvType31]( + uint8(p), + ), + ) + }) + // Encode message data component as TLV bytes. return requestWireMsgData{ Version: version, @@ -213,6 +314,9 @@ func newRequestWireMsgDataFromBuy(q BuyRequest) (requestWireMsgData, error) { OutAssetGroupKey: outAssetGroupKey, MaxInAsset: maxInAsset, InAssetRateHint: inAssetRateHint, + MinInAsset: minInAsset, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, PriceOracleMetadata: oracleMetadata, }, nil } @@ -281,6 +385,35 @@ func newRequestWireMsgDataFromSell(q SellRequest) (requestWireMsgData, error) { ) } + // Set optional min payment amount. + var minOutAsset tlv.OptionalRecordT[tlv.TlvType25, uint64] + q.PaymentMinAmt.WhenSome(func(minAmt lnwire.MilliSatoshi) { + minOutAsset = tlv.SomeRecordT[tlv.TlvType25]( + tlv.NewPrimitiveRecord[tlv.TlvType25]( + uint64(minAmt), + ), + ) + }) + + // Set optional rate limit. + var assetRateLimit requestAssetRateLimit + q.AssetRateLimit.WhenSome(func(limit rfqmath.BigIntFixedPoint) { + wireRate := NewTlvFixedPointFromBigInt(limit) + assetRateLimit = tlv.SomeRecordT[tlv.TlvType29]( + tlv.NewRecordT[tlv.TlvType29](wireRate), + ) + }) + + // Set optional execution policy. + var execPolicy requestExecutionPolicy + q.ExecutionPolicy.WhenSome(func(p ExecutionPolicy) { + execPolicy = tlv.SomeRecordT[tlv.TlvType31]( + tlv.NewPrimitiveRecord[tlv.TlvType31]( + uint8(p), + ), + ) + }) + // Encode message data component as TLV bytes. return requestWireMsgData{ Version: version, @@ -292,6 +425,9 @@ func newRequestWireMsgDataFromSell(q SellRequest) (requestWireMsgData, error) { OutAssetGroupKey: outAssetGroupKey, MaxInAsset: maxInAsset, OutAssetRateHint: outAssetRateHint, + MinOutAsset: minOutAsset, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, PriceOracleMetadata: oracleMetadata, }, nil } @@ -431,6 +567,16 @@ func (m *requestWireMsgData) Encode(w io.Writer) error { records = append(records, r.Record()) }, ) + m.AssetRateLimit.WhenSome( + func(r tlv.RecordT[tlv.TlvType29, TlvFixedPoint]) { + records = append(records, r.Record()) + }, + ) + m.ExecutionPolicy.WhenSome( + func(r tlv.RecordT[tlv.TlvType31, uint8]) { + records = append(records, r.Record()) + }, + ) tlv.SortRecords(records) @@ -459,6 +605,8 @@ func (m *requestWireMsgData) Decode(r io.Reader) error { minOutAsset := m.MinOutAsset.Zero() oracleMetadata := m.PriceOracleMetadata.Zero() + assetRateLimit := m.AssetRateLimit.Zero() + executionPolicy := m.ExecutionPolicy.Zero() // Create a tlv stream with all the fields. tlvStream, err := tlv.NewStream( @@ -482,6 +630,8 @@ func (m *requestWireMsgData) Decode(r io.Reader) error { minOutAsset.Record(), oracleMetadata.Record(), + assetRateLimit.Record(), + executionPolicy.Record(), ) if err != nil { return err @@ -524,6 +674,12 @@ func (m *requestWireMsgData) Decode(r io.Reader) error { if _, ok := tlvMap[oracleMetadata.TlvType()]; ok { m.PriceOracleMetadata = tlv.SomeRecordT(oracleMetadata) } + if _, ok := tlvMap[assetRateLimit.TlvType()]; ok { + m.AssetRateLimit = tlv.SomeRecordT(assetRateLimit) + } + if _, ok := tlvMap[executionPolicy.TlvType()]; ok { + m.ExecutionPolicy = tlv.SomeRecordT(executionPolicy) + } return nil } @@ -581,11 +737,39 @@ func NewIncomingRequestFromWire(wireMsg WireMessage) (IncomingMsg, error) { } } +// RequestConstraints is a normalised view of the limit-order +// constraint fields that BuyRequest and SellRequest share. It lets +// validation and enforcement code operate generically without +// type-switching on the concrete request type. +type RequestConstraints struct { + // MaxAmount is the maximum amount for the quote (asset units + // for buy, msat for sell). + MaxAmount uint64 + + // MinAmount is an optional minimum amount for the quote. + MinAmount fn.Option[uint64] + + // RateLimit is an optional rate bound constraint. + RateLimit fn.Option[rfqmath.BigIntFixedPoint] + + // RateBoundCmp defines how the accepted rate is compared to + // the limit. A buy request uses -1 (floor: accepted >= limit), + // a sell request uses +1 (ceiling: accepted <= limit). + RateBoundCmp int + + // ExecutionPolicy is the optional execution policy. + ExecutionPolicy fn.Option[ExecutionPolicy] +} + // Request represents an RFQ quote request. type Request interface { IncomingMsg OutgoingMsg + // Constraints returns a normalised view of the limit-order + // constraint fields for this request. + Constraints() RequestConstraints + // requestMarker is an unexported marker method that ensures only rfqmsg // package types may satisfy this interface. requestMarker() diff --git a/rfqmsg/request_property_test.go b/rfqmsg/request_property_test.go new file mode 100644 index 0000000000..4678c180f5 --- /dev/null +++ b/rfqmsg/request_property_test.go @@ -0,0 +1,698 @@ +package rfqmsg + +import ( + "bytes" + "fmt" + "math/big" + "testing" + "time" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// optionalUint64Gen draws an fn.Option[uint64] that is None half the +// time and Some(v) otherwise, where v is drawn from [1, bound]. +func optionalUint64Gen(bound uint64) *rapid.Generator[fn.Option[uint64]] { + return rapid.Custom(func(t *rapid.T) fn.Option[uint64] { + if rapid.Bool().Draw(t, "present") { + // Start from 1: zero is treated as unset + // on the wire. + v := rapid.Uint64Range(1, bound).Draw( + t, "value", + ) + return fn.Some(v) + } + return fn.None[uint64]() + }) +} + +// optionalMsatGen draws an fn.Option[lnwire.MilliSatoshi] that is +// None half the time and Some(v) otherwise, where v <= bound. +func optionalMsatGen( + bound uint64) *rapid.Generator[fn.Option[lnwire.MilliSatoshi]] { + + return rapid.Custom( + func(t *rapid.T) fn.Option[lnwire.MilliSatoshi] { + if rapid.Bool().Draw(t, "present") { + // Start from 1: zero is treated as + // unset on the wire. + v := rapid.Uint64Range(1, bound).Draw( + t, "value", + ) + return fn.Some( + lnwire.MilliSatoshi(v), + ) + } + return fn.None[lnwire.MilliSatoshi]() + }, + ) +} + +// fixedPointGen draws a BigIntFixedPoint with coefficient in [1,1e12] +// and scale in [0,11]. +func fixedPointGen() *rapid.Generator[rfqmath.BigIntFixedPoint] { + return rapid.Custom( + func(t *rapid.T) rfqmath.BigIntFixedPoint { + coeff := rapid.Uint64Range(1, 1_000_000_000_000). + Draw(t, "coeff") + scale := rapid.Uint8Range(0, 11).Draw(t, "scale") + return rfqmath.NewBigIntFixedPoint( + coeff, scale, + ) + }, + ) +} + +// optionalFixedPointGen draws an optional BigIntFixedPoint, None half +// the time. +type optFP = fn.Option[rfqmath.BigIntFixedPoint] + +func optionalFixedPointGen() *rapid.Generator[optFP] { + return rapid.Custom( + func(t *rapid.T) fn.Option[rfqmath.BigIntFixedPoint] { + if rapid.Bool().Draw(t, "present") { + return fn.Some( + fixedPointGen().Draw(t, "fp"), + ) + } + return fn.None[rfqmath.BigIntFixedPoint]() + }, + ) +} + +// optionalExecutionPolicyGen draws an +// fn.Option[ExecutionPolicy] that is None one-third of the time, +// IOC one-third, and FOK one-third. +func optionalExecutionPolicyGen() *rapid.Generator[fn.Option[ExecutionPolicy]] { + return rapid.Custom( + func(t *rapid.T) fn.Option[ExecutionPolicy] { + v := rapid.IntRange(0, 2).Draw( + t, "execPolicy", + ) + switch v { + case 0: + return fn.None[ExecutionPolicy]() + case 1: + return fn.Some(ExecutionPolicyIOC) + default: + return fn.Some(ExecutionPolicyFOK) + } + }, + ) +} + +// assetIDGen draws a random 32-byte asset.ID. +func assetIDGen() *rapid.Generator[asset.ID] { + return rapid.Custom(func(t *rapid.T) asset.ID { + var id asset.ID + for i := range id { + id[i] = rapid.Byte().Draw(t, "byte") + } + return id + }) +} + +// peerGen draws a random 33-byte route.Vertex. +func peerGen() *rapid.Generator[route.Vertex] { + return rapid.Custom(func(t *rapid.T) route.Vertex { + var v route.Vertex + for i := range v { + v[i] = rapid.Byte().Draw(t, "byte") + } + return v + }) +} + +// TestBuyRequestWireRoundtripProperty checks that any valid +// BuyRequest survives a wire encode/decode roundtrip. +func TestBuyRequestWireRoundtripProperty(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + peer := peerGen().Draw(t, "peer") + id := assetIDGen().Draw(t, "id") + spec := asset.NewSpecifierFromId(id) + + maxAmt := rapid.Uint64Range(1, 1_000_000).Draw( + t, "maxAmt", + ) + minAmt := optionalUint64Gen(maxAmt).Draw( + t, "minAmt", + ) + rateLimit := optionalFixedPointGen().Draw( + t, "rateLimit", + ) + execPolicy := optionalExecutionPolicyGen().Draw( + t, "execPolicy", + ) + + req, err := NewBuyRequest( + peer, spec, maxAmt, minAmt, + rateLimit, fn.None[AssetRate](), "", + execPolicy, + ) + require.NoError(t, err) + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode( + bytes.NewReader(wireMsg.Data), + ) + require.NoError(t, err) + + decoded, err := NewBuyRequestFromWire( + wireMsg, msgData, + ) + require.NoError(t, err) + + // Max amount must be preserved. + require.Equal(t, maxAmt, decoded.AssetMaxAmt) + + // Min amount must match. + requireOptEq(t, minAmt, decoded.AssetMinAmt) + + // Rate limit must match via Cmp. + requireOptFpEq( + t, rateLimit, decoded.AssetRateLimit, + ) + requireOptExecPolicyEq( + t, execPolicy, decoded.ExecutionPolicy, + ) + }) +} + +// TestSellRequestWireRoundtripProperty checks that any valid +// SellRequest survives a wire encode/decode roundtrip. +func TestSellRequestWireRoundtripProperty(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + peer := peerGen().Draw(t, "peer") + id := assetIDGen().Draw(t, "id") + spec := asset.NewSpecifierFromId(id) + + maxAmt := rapid.Uint64Range(1, 1_000_000).Draw( + t, "maxAmt", + ) + minAmt := optionalMsatGen(maxAmt).Draw( + t, "minAmt", + ) + rateLimit := optionalFixedPointGen().Draw( + t, "rateLimit", + ) + execPolicy := optionalExecutionPolicyGen().Draw( + t, "execPolicy", + ) + + req, err := NewSellRequest( + peer, spec, + lnwire.MilliSatoshi(maxAmt), minAmt, + rateLimit, fn.None[AssetRate](), "", + execPolicy, + ) + require.NoError(t, err) + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode( + bytes.NewReader(wireMsg.Data), + ) + require.NoError(t, err) + + decoded, err := NewSellRequestFromWire( + wireMsg, msgData, + ) + require.NoError(t, err) + + require.Equal( + t, lnwire.MilliSatoshi(maxAmt), + decoded.PaymentMaxAmt, + ) + + requireOptMsatEq( + t, minAmt, decoded.PaymentMinAmt, + ) + + requireOptFpEq( + t, rateLimit, decoded.AssetRateLimit, + ) + requireOptExecPolicyEq( + t, execPolicy, decoded.ExecutionPolicy, + ) + }) +} + +// TestMinMaxConstraintProperty verifies that Validate accepts +// min <= max and rejects min > max for both buy and sell requests. +func TestMinMaxConstraintProperty(t *testing.T) { + t.Parallel() + + t.Run("buy_valid", func(t *testing.T) { + t.Parallel() + rapid.Check(t, func(t *rapid.T) { + maxAmt := rapid.Uint64Range(1, 1_000_000). + Draw(t, "max") + minAmt := rapid.Uint64Range(0, maxAmt). + Draw(t, "min") + + req := &BuyRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId( + asset.ID{1}, + ), + AssetMaxAmt: maxAmt, + AssetMinAmt: fn.Some(minAmt), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), //nolint:lll + AssetRateHint: fn.None[AssetRate](), + } + require.NoError(t, req.Validate()) + }) + }) + + t.Run("buy_invalid", func(t *testing.T) { + t.Parallel() + rapid.Check(t, func(t *rapid.T) { + maxAmt := rapid.Uint64Range( + 0, 1_000_000-1, + ).Draw(t, "max") + minAmt := rapid.Uint64Range( + maxAmt+1, 1_000_000, + ).Draw(t, "min") + + req := &BuyRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId( + asset.ID{1}, + ), + AssetMaxAmt: maxAmt, + AssetMinAmt: fn.Some(minAmt), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), //nolint:lll + AssetRateHint: fn.None[AssetRate](), + } + require.Error(t, req.Validate()) + }) + }) + + t.Run("sell_valid", func(t *testing.T) { + t.Parallel() + rapid.Check(t, func(t *rapid.T) { + maxAmt := rapid.Uint64Range(1, 1_000_000). + Draw(t, "max") + minAmt := rapid.Uint64Range(0, maxAmt). + Draw(t, "min") + + req := &SellRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId( + asset.ID{1}, + ), + PaymentMaxAmt: lnwire.MilliSatoshi( + maxAmt, + ), + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(minAmt), + ), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), //nolint:lll + AssetRateHint: fn.None[AssetRate](), + } + require.NoError(t, req.Validate()) + }) + }) + + t.Run("sell_invalid", func(t *testing.T) { + t.Parallel() + rapid.Check(t, func(t *rapid.T) { + maxAmt := rapid.Uint64Range( + 0, 1_000_000-1, + ).Draw(t, "max") + minAmt := rapid.Uint64Range( + maxAmt+1, 1_000_000, + ).Draw(t, "min") + + req := &SellRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId( + asset.ID{1}, + ), + PaymentMaxAmt: lnwire.MilliSatoshi( + maxAmt, + ), + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(minAmt), + ), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), //nolint:lll + AssetRateHint: fn.None[AssetRate](), + } + require.Error(t, req.Validate()) + }) + }) +} + +// TestNegativeRateLimitRejected verifies that Validate rejects a +// rate limit with a non-positive coefficient for both buy and sell +// requests. +func TestNegativeRateLimitRejected(t *testing.T) { + t.Parallel() + + spec := asset.NewSpecifierFromId(asset.ID{1}) + negLimit := rfqmath.FixedPoint[rfqmath.BigInt]{ + Coefficient: rfqmath.NewBigInt( + big.NewInt(-5), + ), + Scale: 0, + } + zeroLimit := rfqmath.FixedPoint[rfqmath.BigInt]{ + Coefficient: rfqmath.NewBigIntFromUint64(0), + Scale: 0, + } + + t.Run("buy_negative", func(t *testing.T) { + t.Parallel() + req := &BuyRequest{ + Version: V1, + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetRateLimit: fn.Some(negLimit), + AssetRateHint: fn.None[AssetRate](), + } + err := req.Validate() + require.ErrorContains( + t, err, "coefficient must be positive", + ) + }) + + t.Run("buy_zero", func(t *testing.T) { + t.Parallel() + req := &BuyRequest{ + Version: V1, + AssetSpecifier: spec, + AssetMaxAmt: 100, + AssetRateLimit: fn.Some(zeroLimit), + AssetRateHint: fn.None[AssetRate](), + } + err := req.Validate() + require.ErrorContains( + t, err, "coefficient must be positive", + ) + }) + + t.Run("sell_negative", func(t *testing.T) { + t.Parallel() + req := &SellRequest{ + Version: V1, + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + AssetRateLimit: fn.Some(negLimit), + AssetRateHint: fn.None[AssetRate](), + } + err := req.Validate() + require.ErrorContains( + t, err, "coefficient must be positive", + ) + }) + + t.Run("sell_zero", func(t *testing.T) { + t.Parallel() + req := &SellRequest{ + Version: V1, + AssetSpecifier: spec, + PaymentMaxAmt: 1000, + AssetRateLimit: fn.Some(zeroLimit), + AssetRateHint: fn.None[AssetRate](), + } + err := req.Validate() + require.ErrorContains( + t, err, "coefficient must be positive", + ) + }) +} + +// TestRateBoundEnforcementProperty verifies that rate limit fields +// survive a wire roundtrip and preserve the ordering relationship +// that checkRateBound relies on. For each request type we draw a +// random rate limit, encode/decode the request, then confirm that +// Cmp between an independently drawn accepted rate and the decoded +// limit yields the same result as comparing against the original. +func TestRateBoundEnforcementProperty(t *testing.T) { + t.Parallel() + + t.Run("buy", func(t *testing.T) { + t.Parallel() + rapid.Check(t, func(t *rapid.T) { + peer := peerGen().Draw(t, "peer") + id := assetIDGen().Draw(t, "id") + spec := asset.NewSpecifierFromId(id) + + maxAmt := rapid.Uint64Range(1, 1_000_000). + Draw(t, "maxAmt") + limit := fixedPointGen().Draw(t, "limit") + + req, err := NewBuyRequest( + peer, spec, maxAmt, + fn.None[uint64](), + fn.Some(limit), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode( + bytes.NewReader(wireMsg.Data), + ) + require.NoError(t, err) + + decoded, err := NewBuyRequestFromWire( + wireMsg, msgData, + ) + require.NoError(t, err) + + decodedLimit, err := decoded.AssetRateLimit. + UnwrapOrErr( + errMissingRateLimit, + ) + require.NoError(t, err) + + // Ordering vs an independent rate must + // be identical before and after roundtrip. + accepted := fixedPointGen().Draw( + t, "accepted", + ) + require.Equal( + t, accepted.Cmp(limit), + accepted.Cmp(decodedLimit), + "buy Cmp mismatch after roundtrip", + ) + }) + }) + + t.Run("sell", func(t *testing.T) { + t.Parallel() + rapid.Check(t, func(t *rapid.T) { + peer := peerGen().Draw(t, "peer") + id := assetIDGen().Draw(t, "id") + spec := asset.NewSpecifierFromId(id) + + maxAmt := rapid.Uint64Range( + 1, 1_000_000, + ).Draw(t, "maxAmt") + limit := fixedPointGen().Draw(t, "limit") + + req, err := NewSellRequest( + peer, spec, + lnwire.MilliSatoshi(maxAmt), + fn.None[lnwire.MilliSatoshi](), + fn.Some(limit), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode( + bytes.NewReader(wireMsg.Data), + ) + require.NoError(t, err) + + decoded, err := NewSellRequestFromWire( + wireMsg, msgData, + ) + require.NoError(t, err) + + decodedLimit, err := decoded.AssetRateLimit. + UnwrapOrErr( + errMissingRateLimit, + ) + require.NoError(t, err) + + accepted := fixedPointGen().Draw( + t, "accepted", + ) + require.Equal( + t, accepted.Cmp(limit), + accepted.Cmp(decodedLimit), + "sell Cmp mismatch after roundtrip", + ) + }) + }) +} + +// errMissingRateLimit is returned when a rate limit is expected +// but not present after wire roundtrip. +var errMissingRateLimit = fmt.Errorf("rate limit missing") + +// TestBuyRequestRoundtripWithHintProperty verifies that a BuyRequest +// with all fields (including AssetRateHint) survives a roundtrip. +func TestBuyRequestRoundtripWithHintProperty(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(t *rapid.T) { + peer := peerGen().Draw(t, "peer") + id := assetIDGen().Draw(t, "id") + spec := asset.NewSpecifierFromId(id) + + maxAmt := rapid.Uint64Range(1, 1_000_000).Draw( + t, "maxAmt", + ) + minAmt := optionalUint64Gen(maxAmt).Draw( + t, "minAmt", + ) + rateLimit := optionalFixedPointGen().Draw( + t, "rateLimit", + ) + + // Always include a rate hint so we test the full + // field set. + expiry := time.Now().Add(5 * time.Minute).UTC() + fp := fixedPointGen().Draw(t, "hintRate") + hint := fn.Some(NewAssetRate(fp, expiry)) + + execPolicy := optionalExecutionPolicyGen().Draw( + t, "execPolicy", + ) + + req, err := NewBuyRequest( + peer, spec, maxAmt, minAmt, + rateLimit, hint, "", + execPolicy, + ) + require.NoError(t, err) + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode( + bytes.NewReader(wireMsg.Data), + ) + require.NoError(t, err) + + decoded, err := NewBuyRequestFromWire( + wireMsg, msgData, + ) + require.NoError(t, err) + + require.Equal(t, maxAmt, decoded.AssetMaxAmt) + requireOptEq(t, minAmt, decoded.AssetMinAmt) + requireOptFpEq( + t, rateLimit, decoded.AssetRateLimit, + ) + require.True(t, decoded.AssetRateHint.IsSome()) + requireOptExecPolicyEq( + t, execPolicy, decoded.ExecutionPolicy, + ) + }) +} + +// --- helpers --- + +// requireOptEq asserts two fn.Option[uint64] values are equal. +func requireOptEq(t require.TestingT, + want, got fn.Option[uint64]) { + + t.(*rapid.T).Helper() + + if want.IsNone() { + require.True(t, got.IsNone()) + return + } + + require.True(t, got.IsSome()) + + wantVal := want.UnwrapOr(0) + gotVal := got.UnwrapOr(0) + require.Equal(t, wantVal, gotVal) +} + +// requireOptMsatEq asserts two fn.Option[lnwire.MilliSatoshi] +// values are equal. +func requireOptMsatEq(t require.TestingT, + want, got fn.Option[lnwire.MilliSatoshi]) { + + t.(*rapid.T).Helper() + + if want.IsNone() { + require.True(t, got.IsNone()) + return + } + + require.True(t, got.IsSome()) + + wantVal := want.UnwrapOr(0) + gotVal := got.UnwrapOr(0) + require.Equal(t, wantVal, gotVal) +} + +// requireOptFpEq asserts two optional BigIntFixedPoint values are +// equal via Cmp. +func requireOptFpEq(t require.TestingT, + want, got fn.Option[rfqmath.BigIntFixedPoint]) { + + if want.IsNone() { + require.True(t, got.IsNone()) + return + } + + require.True(t, got.IsSome()) + + wantVal := want.UnwrapOr( + rfqmath.NewBigIntFixedPoint(0, 0), + ) + gotVal := got.UnwrapOr( + rfqmath.NewBigIntFixedPoint(0, 0), + ) + require.Equal(t, 0, gotVal.Cmp(wantVal)) +} + +// requireOptExecPolicyEq asserts two optional ExecutionPolicy +// values are equal. +func requireOptExecPolicyEq(t require.TestingT, + want, got fn.Option[ExecutionPolicy]) { + + if want.IsNone() { + require.True(t, got.IsNone()) + return + } + + require.True(t, got.IsSome()) + + wantVal := want.UnwrapOr(ExecutionPolicyIOC) + gotVal := got.UnwrapOr(ExecutionPolicyIOC) + require.Equal(t, wantVal, gotVal) +} diff --git a/rfqmsg/request_test.go b/rfqmsg/request_test.go index 017e3e5834..39c98fa32e 100644 --- a/rfqmsg/request_test.go +++ b/rfqmsg/request_test.go @@ -34,6 +34,8 @@ type testCaseEncodeDecode struct { minInAsset *uint64 minOutAsset *uint64 + assetRateLimit *uint64 + oracleMetadata string } @@ -109,6 +111,16 @@ func (tc testCaseEncodeDecode) Request() requestWireMsgData { ) } + var assetRateLimit requestAssetRateLimit + if tc.assetRateLimit != nil { + rate := NewTlvFixedPointFromUint64( + *tc.assetRateLimit, 3, + ) + assetRateLimit = tlv.SomeRecordT[tlv.TlvType29]( + tlv.NewRecordT[tlv.TlvType29](rate), + ) + } + var oracleMetadata requestOracleMetadata if tc.oracleMetadata != "" { oracleMetadata = tlv.SomeRecordT[tlv.TlvType27]( @@ -131,6 +143,7 @@ func (tc testCaseEncodeDecode) Request() requestWireMsgData { OutAssetRateHint: outAssetRateHint, MinInAsset: minInAsset, MinOutAsset: minOutAsset, + AssetRateLimit: assetRateLimit, PriceOracleMetadata: oracleMetadata, } } @@ -162,6 +175,7 @@ func TestRequestMsgDataEncodeDecode(t *testing.T) { minInAsset := uint64(1) minOutAsset := uint64(10) + assetRateLimit := uint64(50000) testCases := []testCaseEncodeDecode{ { @@ -180,6 +194,7 @@ func TestRequestMsgDataEncodeDecode(t *testing.T) { outAssetRateHint: &outAssetRateHint, minInAsset: &minInAsset, minOutAsset: &minOutAsset, + assetRateLimit: &assetRateLimit, oracleMetadata: "this could be a JSON string", }, { diff --git a/rfqmsg/sell_accept.go b/rfqmsg/sell_accept.go index c86c85e505..3796caa99e 100644 --- a/rfqmsg/sell_accept.go +++ b/rfqmsg/sell_accept.go @@ -4,7 +4,9 @@ import ( "fmt" "time" + "github.com/lightninglabs/taproot-assets/fn" "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/tlv" ) const ( @@ -32,6 +34,11 @@ type SellAccept struct { // AssetRate is the accepted asset to BTC rate. AssetRate AssetRate + // AcceptedMaxAmount is an optional fill quantity that caps the + // amount the responder is willing to accept. When None the full + // request max is implied. + AcceptedMaxAmount fn.Option[uint64] + // sig is a signature over the serialized contents of the message. sig [64]byte @@ -44,17 +51,19 @@ type SellAccept struct { // message given an asset sell quote request message. Note that this function // sets the AgreedAt timestamp to the current time. If callers need to preserve // an existing AgreedAt value (e.g., when reconstructing from storage), -// they should manually construct the BuyAccept. +// they should manually construct the SellAccept. func NewSellAcceptFromRequest(request SellRequest, - assetRate AssetRate) *SellAccept { + assetRate AssetRate, + fillAmount fn.Option[uint64]) *SellAccept { return &SellAccept{ - Peer: request.Peer, - Request: request, - Version: latestSellAcceptVersion, - ID: request.ID, - AssetRate: assetRate, - AgreedAt: time.Now().UTC(), + Peer: request.Peer, + Request: request, + Version: latestSellAcceptVersion, + ID: request.ID, + AssetRate: assetRate, + AcceptedMaxAmount: fillAmount, + AgreedAt: time.Now().UTC(), } } @@ -77,16 +86,25 @@ func newSellAcceptFromWireMsg(wireMsg WireMessage, // Convert the unix timestamp in seconds to a time.Time. expiry := time.Unix(int64(msgData.Expiry.Val), 0).UTC() + // Extract the optional fill quantity. + var acceptedMax fn.Option[uint64] + msgData.MaxInAsset.WhenSome( + func(r tlv.RecordT[tlv.TlvType11, uint64]) { + acceptedMax = fn.Some(r.Val) + }, + ) + // Note that the `Request` field is populated later in the RFQ stream // service. return &SellAccept{ - Peer: wireMsg.Peer, - Request: request, - Version: msgData.Version.Val, - ID: msgData.ID.Val, - AssetRate: NewAssetRate(assetRate, expiry), - sig: msgData.Sig.Val, - AgreedAt: time.Now().UTC(), + Peer: wireMsg.Peer, + Request: request, + Version: msgData.Version.Val, + ID: msgData.ID.Val, + AssetRate: NewAssetRate(assetRate, expiry), + AcceptedMaxAmount: acceptedMax, + sig: msgData.Sig.Val, + AgreedAt: time.Now().UTC(), }, nil } @@ -147,15 +165,28 @@ func (q *SellAccept) OriginalRequest() Request { return &q.Request } +// AcceptedFillAmount returns the optional negotiated fill quantity. +func (q *SellAccept) AcceptedFillAmount() fn.Option[uint64] { + return q.AcceptedMaxAmount +} + // acceptMarker makes SellAccept satisfy the Accept interface while keeping // implementations local to this package. func (q *SellAccept) acceptMarker() {} // String returns a human-readable string representation of the message. func (q *SellAccept) String() string { - return fmt.Sprintf("SellAccept(peer=%x, id=%x, asset_rate=%s, "+ - "scid=%d)", q.Peer[:], q.ID[:], q.AssetRate.String(), - q.ShortChannelId()) + fillStr := "" + q.AcceptedMaxAmount.WhenSome(func(amt uint64) { + fillStr = fmt.Sprintf(", fill=%d", amt) + }) + + return fmt.Sprintf( + "SellAccept(peer=%x, id=%x, asset_rate=%s, "+ + "scid=%d%s)", + q.Peer[:], q.ID[:], q.AssetRate.String(), + q.ShortChannelId(), fillStr, + ) } // Ensure that the message type implements the OutgoingMsg interface. diff --git a/rfqmsg/sell_request.go b/rfqmsg/sell_request.go index 4a581e71c4..b92dfa7cdd 100644 --- a/rfqmsg/sell_request.go +++ b/rfqmsg/sell_request.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/tlv" @@ -52,6 +53,21 @@ type SellRequest struct { // must agree to pay. PaymentMaxAmt lnwire.MilliSatoshi + // PaymentMinAmt is an optional minimum msat amount for the quote. + // When set, the responding peer must agree to pay at least this + // much. + PaymentMinAmt fn.Option[lnwire.MilliSatoshi] + + // AssetRateLimit is an optional maximum acceptable rate (asset + // units per BTC). The seller sets a ceiling: "I won't give more + // than X units per BTC." + AssetRateLimit fn.Option[rfqmath.BigIntFixedPoint] + + // ExecutionPolicy is an optional execution policy for the + // quote request. IOC (default) accepts partial fills; FOK + // requires the full max amount to be viable. + ExecutionPolicy fn.Option[ExecutionPolicy] + // AssetRateHint represents a proposed conversion rate between the // subject asset and BTC. This rate is an initial suggestion intended to // initiate the RFQ negotiation process and may differ from the final @@ -69,8 +85,12 @@ type SellRequest struct { // NewSellRequest creates a new asset sell quote request. func NewSellRequest(peer route.Vertex, assetSpecifier asset.Specifier, - paymentMaxAmt lnwire.MilliSatoshi, assetRateHint fn.Option[AssetRate], - oracleMetadata string) (*SellRequest, error) { + paymentMaxAmt lnwire.MilliSatoshi, + paymentMinAmt fn.Option[lnwire.MilliSatoshi], + assetRateLimit fn.Option[rfqmath.BigIntFixedPoint], + assetRateHint fn.Option[AssetRate], + oracleMetadata string, + execPolicy fn.Option[ExecutionPolicy]) (*SellRequest, error) { id, err := NewID() if err != nil { @@ -83,15 +103,25 @@ func NewSellRequest(peer route.Vertex, assetSpecifier asset.Specifier, "length of %d bytes", MaxOracleMetadataLength) } - return &SellRequest{ + req := &SellRequest{ Peer: peer, Version: latestSellRequestVersion, ID: id, AssetSpecifier: assetSpecifier, PaymentMaxAmt: paymentMaxAmt, + PaymentMinAmt: paymentMinAmt, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, AssetRateHint: assetRateHint, PriceOracleMetadata: oracleMetadata, - }, nil + } + + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("unable to validate sell "+ + "request: %w", err) + } + + return req, nil } // NewSellRequestFromWire instantiates a new instance from a wire message. @@ -147,6 +177,35 @@ func NewSellRequestFromWire(wireMsg WireMessage, }, ) + // Extract optional min payment amount. + var paymentMinAmt fn.Option[lnwire.MilliSatoshi] + msgData.MinOutAsset.WhenSome( + func(r tlv.RecordT[tlv.TlvType25, uint64]) { + if r.Val > 0 { + paymentMinAmt = fn.Some( + lnwire.MilliSatoshi(r.Val), + ) + } + }, + ) + + // Extract optional rate limit. + var assetRateLimit fn.Option[rfqmath.BigIntFixedPoint] + msgData.AssetRateLimit.WhenSome( + func(r tlv.RecordT[tlv.TlvType29, TlvFixedPoint]) { + fp := r.Val.IntoBigIntFixedPoint() + assetRateLimit = fn.Some(fp) + }, + ) + + // Extract optional execution policy. + var execPolicy fn.Option[ExecutionPolicy] + msgData.ExecutionPolicy.WhenSome( + func(r tlv.RecordT[tlv.TlvType31, uint8]) { + execPolicy = fn.Some(ExecutionPolicy(r.Val)) + }, + ) + req := SellRequest{ Peer: wireMsg.Peer, Version: msgData.Version.Val, @@ -155,7 +214,10 @@ func NewSellRequestFromWire(wireMsg WireMessage, PaymentMaxAmt: lnwire.MilliSatoshi( msgData.MaxInAsset.Val, ), - AssetRateHint: assetRateHint, + PaymentMinAmt: paymentMinAmt, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, + AssetRateHint: assetRateHint, } msgData.PriceOracleMetadata.ValOpt().WhenSome(func(metaBytes []byte) { @@ -191,6 +253,34 @@ func (q *SellRequest) Validate() error { "length of %d bytes", MaxOracleMetadataLength) } + // Ensure min <= max when min is set. + err = fn.MapOptionZ( + q.PaymentMinAmt, + func(minAmt lnwire.MilliSatoshi) error { + if minAmt > q.PaymentMaxAmt { + return fmt.Errorf("payment min amount "+ + "(%d) exceeds max amount (%d)", + minAmt, q.PaymentMaxAmt) + } + return nil + }, + ) + if err != nil { + return err + } + + // Ensure execution policy is valid when set. + if err := validateExecutionPolicy( + q.ExecutionPolicy, + ); err != nil { + return err + } + + // Ensure rate limit is strictly positive when set. + if err := validateRateLimit(q.AssetRateLimit); err != nil { + return err + } + return nil } @@ -231,6 +321,22 @@ func (q *SellRequest) MsgID() ID { return q.ID } +// Constraints returns the normalised limit-order constraints for +// this sell request. +func (q *SellRequest) Constraints() RequestConstraints { + return RequestConstraints{ + MaxAmount: uint64(q.PaymentMaxAmt), + MinAmount: fn.MapOption( + func(v lnwire.MilliSatoshi) uint64 { + return uint64(v) + }, + )(q.PaymentMinAmt), + RateLimit: q.AssetRateLimit, + RateBoundCmp: 1, + ExecutionPolicy: q.ExecutionPolicy, + } +} + // requestMarker makes SellRequest satisfy the Request interface while keeping // implementations local to this package. func (q *SellRequest) requestMarker() {} @@ -246,10 +352,32 @@ func (q *SellRequest) String() string { }, ) + minAmtStr := fn.MapOptionZ( + q.PaymentMinAmt, + func(v lnwire.MilliSatoshi) string { + return fmt.Sprintf(", payment_min_amt=%d", v) + }, + ) + + rateLimitStr := fn.MapOptionZ( + q.AssetRateLimit, + func(v rfqmath.BigIntFixedPoint) string { + return fmt.Sprintf(", asset_rate_limit=%s", + v.String()) + }, + ) + + execPolicyStr := fn.MapOptionZ( + q.ExecutionPolicy, + func(p ExecutionPolicy) string { + return fmt.Sprintf(", exec_policy=%d", p) + }, + ) + return fmt.Sprintf("SellRequest(peer=%x, id=%x, asset=%s, "+ - "payment_max_amt=%d, asset_rate_hint=%s)", + "payment_max_amt=%d%s%s%s, asset_rate_hint=%s)", q.Peer[:], q.ID[:], q.AssetSpecifier.String(), q.PaymentMaxAmt, - assetRateHintStr) + minAmtStr, rateLimitStr, execPolicyStr, assetRateHintStr) } // Ensure that the message type implements the OutgoingMsg interface. diff --git a/rfqmsg/sell_request_test.go b/rfqmsg/sell_request_test.go new file mode 100644 index 0000000000..09d25ff711 --- /dev/null +++ b/rfqmsg/sell_request_test.go @@ -0,0 +1,284 @@ +package rfqmsg + +import ( + "bytes" + "testing" + "time" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// sellRequestRoundtrip encodes a SellRequest to wire and decodes it +// back, returning the decoded request. +func sellRequestRoundtrip(t *testing.T, + req *SellRequest) *SellRequest { + + t.Helper() + + wireMsg, err := req.ToWire() + require.NoError(t, err) + + var msgData requestWireMsgData + err = msgData.Decode(bytes.NewReader(wireMsg.Data)) + require.NoError(t, err) + + decoded, err := NewSellRequestFromWire(wireMsg, msgData) + require.NoError(t, err) + + return decoded +} + +// TestSellRequestMinAmtRoundtrip verifies that PaymentMinAmt survives +// a wire roundtrip. +func TestSellRequestMinAmtRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x01} + spec := asset.NewSpecifierFromId(asset.ID{0xAA}) + + req, err := NewSellRequest( + peer, spec, lnwire.MilliSatoshi(5000), + fn.Some(lnwire.MilliSatoshi(1000)), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := sellRequestRoundtrip(t, req) + + require.True(t, decoded.PaymentMinAmt.IsSome()) + decoded.PaymentMinAmt.WhenSome(func(v lnwire.MilliSatoshi) { + require.Equal(t, lnwire.MilliSatoshi(1000), v) + }) + require.Equal( + t, lnwire.MilliSatoshi(5000), decoded.PaymentMaxAmt, + ) +} + +// TestSellRequestRateLimitRoundtrip verifies that AssetRateLimit +// survives a wire roundtrip. +func TestSellRequestRateLimitRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x02} + spec := asset.NewSpecifierFromId(asset.ID{0xBB}) + limit := rfqmath.NewBigIntFixedPoint(75000, 2) + + req, err := NewSellRequest( + peer, spec, lnwire.MilliSatoshi(10000), + fn.None[lnwire.MilliSatoshi](), + fn.Some(limit), fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := sellRequestRoundtrip(t, req) + + require.True(t, decoded.AssetRateLimit.IsSome()) + decoded.AssetRateLimit.WhenSome( + func(v rfqmath.BigIntFixedPoint) { + require.Equal(t, 0, v.Cmp(limit)) + }, + ) +} + +// TestSellRequestNoOptionalFieldsRoundtrip verifies backward +// compatibility when no optional fields are set. +func TestSellRequestNoOptionalFieldsRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x03} + spec := asset.NewSpecifierFromId(asset.ID{0xCC}) + + req, err := NewSellRequest( + peer, spec, lnwire.MilliSatoshi(3000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := sellRequestRoundtrip(t, req) + + require.True(t, decoded.PaymentMinAmt.IsNone()) + require.True(t, decoded.AssetRateLimit.IsNone()) + require.Equal( + t, lnwire.MilliSatoshi(3000), decoded.PaymentMaxAmt, + ) +} + +// TestSellRequestAllFieldsRoundtrip verifies that all optional fields +// survive a roundtrip together. +func TestSellRequestAllFieldsRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x04} + spec := asset.NewSpecifierFromId(asset.ID{0xDD}) + limit := rfqmath.NewBigIntFixedPoint(88000, 3) + expiry := time.Now().Add(5 * time.Minute).UTC() + rateHint := NewAssetRate( + rfqmath.NewBigIntFixedPoint(100000, 0), expiry, + ) + + req, err := NewSellRequest( + peer, spec, lnwire.MilliSatoshi(9000), + fn.Some(lnwire.MilliSatoshi(2000)), + fn.Some(limit), fn.Some(rateHint), "sell-meta", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := sellRequestRoundtrip(t, req) + + require.True(t, decoded.PaymentMinAmt.IsSome()) + decoded.PaymentMinAmt.WhenSome(func(v lnwire.MilliSatoshi) { + require.Equal(t, lnwire.MilliSatoshi(2000), v) + }) + + require.True(t, decoded.AssetRateLimit.IsSome()) + decoded.AssetRateLimit.WhenSome( + func(v rfqmath.BigIntFixedPoint) { + require.Equal(t, 0, v.Cmp(limit)) + }, + ) + + require.True(t, decoded.AssetRateHint.IsSome()) + require.Equal(t, "sell-meta", decoded.PriceOracleMetadata) +} + +// TestSellRequestValidateMinGtMax ensures Validate rejects min > max. +func TestSellRequestValidateMinGtMax(t *testing.T) { + t.Parallel() + + req := &SellRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + PaymentMaxAmt: lnwire.MilliSatoshi(100), + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(200), + ), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), + AssetRateHint: fn.None[AssetRate](), + } + + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds max amount") +} + +// TestSellRequestValidateMinEqMax ensures min == max is valid. +func TestSellRequestValidateMinEqMax(t *testing.T) { + t.Parallel() + + req := &SellRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + PaymentMaxAmt: lnwire.MilliSatoshi(500), + PaymentMinAmt: fn.Some( + lnwire.MilliSatoshi(500), + ), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), + AssetRateHint: fn.None[AssetRate](), + } + + err := req.Validate() + require.NoError(t, err) +} + +// TestSellRequestValidateZeroRateLimit ensures a zero rate limit +// coefficient is rejected. +func TestSellRequestValidateZeroRateLimit(t *testing.T) { + t.Parallel() + + zeroLimit := rfqmath.NewBigIntFixedPoint(0, 0) + req := &SellRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + PaymentMaxAmt: lnwire.MilliSatoshi(1000), + PaymentMinAmt: fn.None[lnwire.MilliSatoshi](), + AssetRateLimit: fn.Some(zeroLimit), + AssetRateHint: fn.None[AssetRate](), + } + + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "must be positive") +} + +// TestSellRequestExecutionPolicyRoundtrip verifies that a FOK +// execution policy survives a wire roundtrip. +func TestSellRequestExecutionPolicyRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x05} + spec := asset.NewSpecifierFromId(asset.ID{0xEE}) + + req, err := NewSellRequest( + peer, spec, lnwire.MilliSatoshi(7000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.Some(ExecutionPolicyFOK), + ) + require.NoError(t, err) + + decoded := sellRequestRoundtrip(t, req) + + require.True(t, decoded.ExecutionPolicy.IsSome()) + decoded.ExecutionPolicy.WhenSome( + func(v ExecutionPolicy) { + require.Equal(t, ExecutionPolicyFOK, v) + }, + ) +} + +// TestSellRequestNoExecutionPolicyRoundtrip verifies that an +// absent execution policy stays None after a wire roundtrip. +func TestSellRequestNoExecutionPolicyRoundtrip(t *testing.T) { + t.Parallel() + + peer := route.Vertex{0x06} + spec := asset.NewSpecifierFromId(asset.ID{0xFF}) + + req, err := NewSellRequest( + peer, spec, lnwire.MilliSatoshi(8000), + fn.None[lnwire.MilliSatoshi](), + fn.None[rfqmath.BigIntFixedPoint](), + fn.None[AssetRate](), "", + fn.None[ExecutionPolicy](), + ) + require.NoError(t, err) + + decoded := sellRequestRoundtrip(t, req) + + require.True(t, decoded.ExecutionPolicy.IsNone()) +} + +// TestSellRequestInvalidExecutionPolicy ensures Validate rejects +// an unknown execution policy value. +func TestSellRequestInvalidExecutionPolicy(t *testing.T) { + t.Parallel() + + req := &SellRequest{ + Version: V1, + AssetSpecifier: asset.NewSpecifierFromId(asset.ID{1}), + PaymentMaxAmt: lnwire.MilliSatoshi(1000), + PaymentMinAmt: fn.None[lnwire.MilliSatoshi](), + AssetRateLimit: fn.None[rfqmath.BigIntFixedPoint](), + AssetRateHint: fn.None[AssetRate](), + ExecutionPolicy: fn.Some( + ExecutionPolicy(2), + ), + } + + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "execution policy") +} diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 77e688a048..69e6d0b335 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "math" + "math/big" "net/http" "net/url" "sort" @@ -8324,7 +8325,55 @@ func parseAssetSpecifier(reqAssetID []byte, reqAssetIDStr string, return assetID, groupKey, nil } -// unmarshalAssetBuyOrder unmarshals an asset buy order from the RPC form. +// unmarshalOptionalFixedPoint converts an optional RPC FixedPoint into +// an fn.Option[rfqmath.BigIntFixedPoint]. A nil input returns fn.None. +func unmarshalOptionalFixedPoint( + fp *rfqrpc.FixedPoint) (fn.Option[rfqmath.BigIntFixedPoint], error) { + + if fp == nil { + return fn.None[rfqmath.BigIntFixedPoint](), nil + } + + if fp.Scale > 255 { + return fn.None[rfqmath.BigIntFixedPoint](), + fmt.Errorf("scale value overflow: %v", fp.Scale) + } + + cBigInt := new(big.Int) + if _, ok := cBigInt.SetString(fp.Coefficient, 10); !ok { + return fn.None[rfqmath.BigIntFixedPoint](), + fmt.Errorf("invalid coefficient: %s", + fp.Coefficient) + } + + result := rfqmath.BigIntFixedPoint{ + Coefficient: rfqmath.NewBigInt(cBigInt), + Scale: uint8(fp.Scale), + } + + return fn.Some(result), nil +} + +// unmarshalExecutionPolicy converts the RPC execution policy enum to +// the internal optional type. IOC (0/default) maps to fn.None. FOK +// maps to fn.Some(ExecutionPolicyFOK). Unknown values are rejected. +func unmarshalExecutionPolicy( + p rfqrpc.ExecutionPolicy, +) (fn.Option[rfqmsg.ExecutionPolicy], error) { + + switch p { + case rfqrpc.ExecutionPolicy_EXECUTION_POLICY_IOC: + return fn.None[rfqmsg.ExecutionPolicy](), nil + + case rfqrpc.ExecutionPolicy_EXECUTION_POLICY_FOK: + return fn.Some(rfqmsg.ExecutionPolicyFOK), nil + + default: + return fn.None[rfqmsg.ExecutionPolicy](), + fmt.Errorf("unknown execution policy: %v", p) + } +} + func unmarshalAssetBuyOrder( req *rfqrpc.AddAssetBuyOrderRequest) (*rfq.BuyOrder, error) { @@ -8370,9 +8419,35 @@ func unmarshalAssetBuyOrder( len(req.PriceOracleMetadata)) } + // Unmarshal optional min amount. + var assetMinAmt fn.Option[uint64] + if req.AssetMinAmt > 0 { + assetMinAmt = fn.Some(req.AssetMinAmt) + } + + // Unmarshal optional rate limit. + assetRateLimit, err := unmarshalOptionalFixedPoint( + req.AssetRateLimit, + ) + if err != nil { + return nil, fmt.Errorf("error unmarshalling asset rate "+ + "limit: %w", err) + } + + // Unmarshal optional execution policy. + execPolicy, err := unmarshalExecutionPolicy( + req.ExecutionPolicy, + ) + if err != nil { + return nil, err + } + return &rfq.BuyOrder{ AssetSpecifier: assetSpecifier, AssetMaxAmt: req.AssetMaxAmt, + AssetMinAmt: assetMinAmt, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, Expiry: expiry, Peer: fn.MaybeSome(peer), PriceOracleMetadata: req.PriceOracleMetadata, @@ -8441,6 +8516,7 @@ func (r *RPCServer) AddAssetBuyOrder(ctx context.Context, type ( targetEventType = *rfq.PeerAcceptedBuyQuoteEvent rejectEventType = *rfq.InvalidQuoteRespEvent + peerRejectType = *rfq.IncomingRejectQuoteEvent ) for { @@ -8455,6 +8531,16 @@ func (r *RPCServer) AddAssetBuyOrder(ctx context.Context, e.QuoteResponse.String()) } + case peerRejectType: + if e.MsgID() == id { + return nil, fmt.Errorf( + "peer %s rejected "+ + "quote %v", + peer.String(), + e.String(), + ) + } + case targetEventType: if !e.MatchesOrder(*buyOrder) { rpcsLog.Debugf("Received event of "+ @@ -8586,9 +8672,37 @@ func unmarshalAssetSellOrder( len(req.PriceOracleMetadata)) } + // Unmarshal optional min payment amount. + var paymentMinAmt fn.Option[lnwire.MilliSatoshi] + if req.PaymentMinAmt > 0 { + paymentMinAmt = fn.Some( + lnwire.MilliSatoshi(req.PaymentMinAmt), + ) + } + + // Unmarshal optional rate limit. + assetRateLimit, err := unmarshalOptionalFixedPoint( + req.AssetRateLimit, + ) + if err != nil { + return nil, fmt.Errorf("error unmarshalling asset rate "+ + "limit: %w", err) + } + + // Unmarshal optional execution policy. + execPolicy, err := unmarshalExecutionPolicy( + req.ExecutionPolicy, + ) + if err != nil { + return nil, err + } + return &rfq.SellOrder{ AssetSpecifier: assetSpecifier, PaymentMaxAmt: lnwire.MilliSatoshi(req.PaymentMaxAmt), + PaymentMinAmt: paymentMinAmt, + AssetRateLimit: assetRateLimit, + ExecutionPolicy: execPolicy, Expiry: expiry, Peer: peer, PriceOracleMetadata: req.PriceOracleMetadata, @@ -8659,6 +8773,7 @@ func (r *RPCServer) AddAssetSellOrder(ctx context.Context, type ( targetEventType = *rfq.PeerAcceptedSellQuoteEvent rejectEventType = *rfq.InvalidQuoteRespEvent + peerRejectType = *rfq.IncomingRejectQuoteEvent ) for { @@ -8673,6 +8788,16 @@ func (r *RPCServer) AddAssetSellOrder(ctx context.Context, e.QuoteResponse.String()) } + case peerRejectType: + if e.MsgID() == id { + return nil, fmt.Errorf( + "peer %s rejected "+ + "quote %v", + peer.String(), + e.String(), + ) + } + case targetEventType: if !e.MatchesOrder(*sellOrder) { rpcsLog.Debugf("Received event of "+ diff --git a/tapdb/migrations.go b/tapdb/migrations.go index 0f0981e7be..b316711de4 100644 --- a/tapdb/migrations.go +++ b/tapdb/migrations.go @@ -24,7 +24,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion = 54 + LatestMigrationVersion = 55 ) // DatabaseBackend is an interface that contains all methods our different diff --git a/tapdb/rfq_policies.go b/tapdb/rfq_policies.go index 4ed06dcfbe..d74ab2dc50 100644 --- a/tapdb/rfq_policies.go +++ b/tapdb/rfq_policies.go @@ -64,6 +64,11 @@ type rfqPolicy struct { // RequestVersion is the version of the RFQ request. RequestVersion *uint32 + // AcceptedMaxAmount is the optional fill cap from the accept + // message. For sales it is asset units; for purchases it is + // msat. NULL means the full request max applies. + AcceptedMaxAmount *uint64 + // AgreedAt is the timestamp when the policy was agreed upon. AgreedAt time.Time } @@ -109,6 +114,11 @@ func (s *PersistedPolicyStore) StoreSalePolicy(ctx context.Context, rateBytes := coefficientBytes(acpt.AssetRate.Rate) expiry := acpt.AssetRate.Expiry.UTC() + var acceptedMax *uint64 + acpt.AcceptedMaxAmount.WhenSome(func(v uint64) { + acceptedMax = fn.Ptr(v) + }) + record := rfqPolicy{ PolicyType: rfq.RfqPolicyTypeAssetSale, Scid: uint64(acpt.ShortChannelId()), @@ -121,6 +131,7 @@ func (s *PersistedPolicyStore) StoreSalePolicy(ctx context.Context, ExpiryUnix: uint64(expiry.Unix()), MaxOutAssetAmt: fn.Ptr(acpt.Request.AssetMaxAmt), RequestAssetMaxAmt: fn.Ptr(acpt.Request.AssetMaxAmt), + AcceptedMaxAmount: acceptedMax, PriceOracleMetadata: acpt.Request.PriceOracleMetadata, RequestVersion: fn.Ptr(uint32(acpt.Request.Version)), AgreedAt: acpt.AgreedAt.UTC(), @@ -138,6 +149,11 @@ func (s *PersistedPolicyStore) StorePurchasePolicy(ctx context.Context, expiry := acpt.AssetRate.Expiry.UTC() paymentMax := int64(acpt.Request.PaymentMaxAmt) + var acceptedMax *uint64 + acpt.AcceptedMaxAmount.WhenSome(func(v uint64) { + acceptedMax = fn.Ptr(v) + }) + record := rfqPolicy{ PolicyType: rfq.RfqPolicyTypeAssetPurchase, Scid: uint64(acpt.ShortChannelId()), @@ -150,6 +166,7 @@ func (s *PersistedPolicyStore) StorePurchasePolicy(ctx context.Context, ExpiryUnix: uint64(expiry.Unix()), PaymentMaxMsat: fn.Ptr(paymentMax), RequestPaymentMaxMsat: fn.Ptr(paymentMax), + AcceptedMaxAmount: acceptedMax, PriceOracleMetadata: acpt.Request.PriceOracleMetadata, RequestVersion: fn.Ptr(uint32(acpt.Request.Version)), AgreedAt: acpt.AgreedAt.UTC(), @@ -247,6 +264,11 @@ func (s *PersistedPolicyStore) StorePeerAcceptedBuyQuote(ctx context.Context, rateBytes := coefficientBytes(acpt.AssetRate.Rate) expiry := acpt.AssetRate.Expiry.UTC() + var acceptedMax *uint64 + acpt.AcceptedMaxAmount.WhenSome(func(v uint64) { + acceptedMax = fn.Ptr(v) + }) + record := rfqPolicy{ PolicyType: rfq.RfqPolicyTypeAssetPeerAcceptedBuy, Scid: uint64(acpt.ShortChannelId()), @@ -259,6 +281,7 @@ func (s *PersistedPolicyStore) StorePeerAcceptedBuyQuote(ctx context.Context, ExpiryUnix: uint64(expiry.Unix()), MaxOutAssetAmt: fn.Ptr(acpt.Request.AssetMaxAmt), RequestAssetMaxAmt: fn.Ptr(acpt.Request.AssetMaxAmt), + AcceptedMaxAmount: acceptedMax, PriceOracleMetadata: acpt.Request.PriceOracleMetadata, RequestVersion: fn.Ptr(uint32(acpt.Request.Version)), AgreedAt: acpt.AgreedAt.UTC(), @@ -347,6 +370,7 @@ func newInsertParams(policy rfqPolicy) sqlc.InsertRfqPolicyParams { params.PriceOracleMetadata = sqlStr(policy.PriceOracleMetadata) params.RequestVersion = sqlPtrInt32(policy.RequestVersion) + params.AcceptedMaxAmount = sqlPtrInt64(policy.AcceptedMaxAmount) return params } @@ -400,6 +424,9 @@ func policyFromRow(row sqlc.RfqPolicy) rfqPolicy { policy.RequestPaymentMaxMsat = extractSqlInt64Ptr[int64]( row.RequestPaymentMaxMsat, ) + policy.AcceptedMaxAmount = extractSqlInt64Ptr[uint64]( + row.AcceptedMaxAmount, + ) return policy } @@ -482,13 +509,19 @@ func buyAcceptFromStored(row rfqPolicy) (rfqmsg.BuyAccept, error) { PriceOracleMetadata: row.PriceOracleMetadata, } + var fillOpt fn.Option[uint64] + if row.AcceptedMaxAmount != nil { + fillOpt = fn.Some(*row.AcceptedMaxAmount) + } + return rfqmsg.BuyAccept{ - Peer: vertex, - Request: request, - Version: rfqmsg.V1, - ID: id, - AssetRate: rfqmsg.NewAssetRate(rate, expiry), - AgreedAt: row.AgreedAt, + Peer: vertex, + Request: request, + Version: rfqmsg.V1, + ID: id, + AssetRate: rfqmsg.NewAssetRate(rate, expiry), + AcceptedMaxAmount: fillOpt, + AgreedAt: row.AgreedAt, }, nil } @@ -526,13 +559,19 @@ func sellAcceptFromStored(row rfqPolicy) (rfqmsg.SellAccept, error) { PriceOracleMetadata: row.PriceOracleMetadata, } + var fillOpt fn.Option[uint64] + if row.AcceptedMaxAmount != nil { + fillOpt = fn.Some(*row.AcceptedMaxAmount) + } + return rfqmsg.SellAccept{ - Peer: vertex, - Request: request, - Version: rfqmsg.V1, - ID: id, - AssetRate: rfqmsg.NewAssetRate(rate, expiry), - AgreedAt: row.AgreedAt, + Peer: vertex, + Request: request, + Version: rfqmsg.V1, + ID: id, + AssetRate: rfqmsg.NewAssetRate(rate, expiry), + AcceptedMaxAmount: fillOpt, + AgreedAt: row.AgreedAt, }, nil } diff --git a/tapdb/rfq_policies_test.go b/tapdb/rfq_policies_test.go index 542a0e43c1..8e57e4b614 100644 --- a/tapdb/rfq_policies_test.go +++ b/tapdb/rfq_policies_test.go @@ -252,6 +252,65 @@ func TestFetchAcceptedQuotesAllThreeTypes(t *testing.T) { require.Equal(t, peerBuy.ID, peerBuys[0].ID) } +// TestAcceptedMaxAmountRoundTrip verifies that the AcceptedMaxAmount +// field survives a store-then-fetch cycle for all three policy types. +func TestAcceptedMaxAmountRoundTrip(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newPolicyStore(t) + + // Sale policy with a fill cap. + sale := testBuyAccept(t) + sale.AcceptedMaxAmount = fn.Some[uint64](500_000) + err := store.StoreSalePolicy(ctx, sale) + require.NoError(t, err) + + // Purchase policy with a fill cap. + purchase := testSellAccept(t) + purchase.AcceptedMaxAmount = fn.Some[uint64](250_000) + err = store.StorePurchasePolicy(ctx, purchase) + require.NoError(t, err) + + // Peer-accepted buy quote with a fill cap. + peerBuy := testBuyAccept(t) + peerBuy.AcceptedMaxAmount = fn.Some[uint64](750_000) + err = store.StorePeerAcceptedBuyQuote(ctx, peerBuy) + require.NoError(t, err) + + buys, sells, peerBuys, err := store.FetchAcceptedQuotes(ctx) + require.NoError(t, err) + + require.Len(t, buys, 1) + require.Len(t, sells, 1) + require.Len(t, peerBuys, 1) + + require.Equal(t, fn.Some[uint64](500_000), + buys[0].AcceptedMaxAmount) + require.Equal(t, fn.Some[uint64](250_000), + sells[0].AcceptedMaxAmount) + require.Equal(t, fn.Some[uint64](750_000), + peerBuys[0].AcceptedMaxAmount) +} + +// TestAcceptedMaxAmountNilRoundTrip verifies that a policy stored +// without AcceptedMaxAmount restores with None. +func TestAcceptedMaxAmountNilRoundTrip(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newPolicyStore(t) + + sale := testBuyAccept(t) + err := store.StoreSalePolicy(ctx, sale) + require.NoError(t, err) + + buys, _, _, err := store.FetchAcceptedQuotes(ctx) + require.NoError(t, err) + require.Len(t, buys, 1) + require.True(t, buys[0].AcceptedMaxAmount.IsNone()) +} + // TestLookUpScidIgnoresSalePolicy verifies that a sale policy stored in the // database is not returned by LookUpScid, which is scoped to peer-accepted // buy quotes only. diff --git a/tapdb/sqlc/migrations/000055_rfq_accepted_max.down.sql b/tapdb/sqlc/migrations/000055_rfq_accepted_max.down.sql new file mode 100644 index 0000000000..3164e956b9 --- /dev/null +++ b/tapdb/sqlc/migrations/000055_rfq_accepted_max.down.sql @@ -0,0 +1,93 @@ +-- Drop the accepted_max_amount column from rfq_policies. +-- SQLite < 3.35 does not support DROP COLUMN, so we use the +-- table-recreation pattern. The forwards table has a FK to +-- rfq_policies(rfq_id), so it must be handled as well. + +-- 1. Save forwards data and drop the table. +CREATE TEMP TABLE IF NOT EXISTS forwards_backup + AS SELECT * FROM forwards; +DROP TABLE IF EXISTS forwards; + +-- 2. Rename old rfq_policies and recreate without the column. +ALTER TABLE rfq_policies RENAME TO rfq_policies_old; + +CREATE TABLE IF NOT EXISTS rfq_policies ( + id INTEGER PRIMARY KEY, + + policy_type TEXT NOT NULL CHECK ( + policy_type IN ( + 'RFQ_POLICY_TYPE_SALE', + 'RFQ_POLICY_TYPE_PURCHASE', + 'RFQ_POLICY_TYPE_PEER_ACCEPTED_BUY' + ) + ), + + scid BIGINT NOT NULL, + rfq_id BLOB NOT NULL CHECK (length(rfq_id) = 32), + peer BLOB NOT NULL CHECK (length(peer) = 33), + asset_id BLOB CHECK (length(asset_id) = 32), + asset_group_key BLOB CHECK (length(asset_group_key) = 33), + rate_coefficient BLOB NOT NULL, + rate_scale INTEGER NOT NULL, + expiry BIGINT NOT NULL, + max_out_asset_amt BIGINT, + payment_max_msat BIGINT, + request_asset_max_amt BIGINT, + request_payment_max_msat BIGINT, + price_oracle_metadata TEXT, + request_version INTEGER, + agreed_at BIGINT NOT NULL +); + +INSERT INTO rfq_policies ( + policy_type, scid, rfq_id, peer, asset_id, asset_group_key, + rate_coefficient, rate_scale, expiry, max_out_asset_amt, + payment_max_msat, request_asset_max_amt, + request_payment_max_msat, price_oracle_metadata, + request_version, agreed_at +) SELECT + policy_type, scid, rfq_id, peer, asset_id, asset_group_key, + rate_coefficient, rate_scale, expiry, max_out_asset_amt, + payment_max_msat, request_asset_max_amt, + request_payment_max_msat, price_oracle_metadata, + request_version, agreed_at +FROM rfq_policies_old; +DROP TABLE rfq_policies_old; + +CREATE UNIQUE INDEX IF NOT EXISTS rfq_policies_rfq_id_idx + ON rfq_policies (rfq_id); +CREATE INDEX IF NOT EXISTS rfq_policies_scid_idx + ON rfq_policies (scid); + +-- 3. Recreate forwards table and restore data. +CREATE TABLE IF NOT EXISTS forwards ( + id INTEGER PRIMARY KEY, + opened_at TIMESTAMP NOT NULL, + settled_at TIMESTAMP, + failed_at TIMESTAMP, + rfq_id BLOB NOT NULL CHECK (length(rfq_id) = 32) + REFERENCES rfq_policies(rfq_id), + chan_id_in BIGINT NOT NULL, + chan_id_out BIGINT NOT NULL, + htlc_id BIGINT NOT NULL, + asset_amt BIGINT NOT NULL, + amt_in_msat BIGINT NOT NULL, + amt_out_msat BIGINT NOT NULL, + UNIQUE(chan_id_in, htlc_id) +); + +INSERT INTO forwards ( + opened_at, settled_at, failed_at, rfq_id, chan_id_in, + chan_id_out, htlc_id, asset_amt, amt_in_msat, amt_out_msat +) SELECT + opened_at, settled_at, failed_at, rfq_id, chan_id_in, + chan_id_out, htlc_id, asset_amt, amt_in_msat, amt_out_msat +FROM forwards_backup; +DROP TABLE forwards_backup; + +CREATE INDEX IF NOT EXISTS forwards_opened_at_idx + ON forwards(opened_at); +CREATE INDEX IF NOT EXISTS forwards_settled_at_idx + ON forwards(settled_at); +CREATE INDEX IF NOT EXISTS forwards_rfq_id_idx + ON forwards(rfq_id); diff --git a/tapdb/sqlc/migrations/000055_rfq_accepted_max.up.sql b/tapdb/sqlc/migrations/000055_rfq_accepted_max.up.sql new file mode 100644 index 0000000000..d0a7ba569b --- /dev/null +++ b/tapdb/sqlc/migrations/000055_rfq_accepted_max.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE rfq_policies + ADD COLUMN accepted_max_amount BIGINT; diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index edaff94d38..3b2a9e0289 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -398,6 +398,7 @@ type RfqPolicy struct { PriceOracleMetadata sql.NullString RequestVersion sql.NullInt32 AgreedAt int64 + AcceptedMaxAmount sql.NullInt64 } type ScriptKey struct { diff --git a/tapdb/sqlc/queries/rfq.sql b/tapdb/sqlc/queries/rfq.sql index 7b0102b79a..805da21d2f 100644 --- a/tapdb/sqlc/queries/rfq.sql +++ b/tapdb/sqlc/queries/rfq.sql @@ -1,22 +1,24 @@ -- name: InsertRfqPolicy :one INSERT INTO rfq_policies ( policy_type, scid, rfq_id, peer, asset_id, asset_group_key, - rate_coefficient, rate_scale, expiry, max_out_asset_amt, payment_max_msat, - request_asset_max_amt, request_payment_max_msat, price_oracle_metadata, - request_version, agreed_at + rate_coefficient, rate_scale, expiry, max_out_asset_amt, + payment_max_msat, request_asset_max_amt, + request_payment_max_msat, price_oracle_metadata, + request_version, agreed_at, accepted_max_amount ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, $15, $16 + $10, $11, $12, $13, $14, $15, $16, $17 ) RETURNING id; -- name: FetchActiveRfqPolicies :many SELECT id, policy_type, scid, rfq_id, peer, asset_id, asset_group_key, - rate_coefficient, rate_scale, expiry, max_out_asset_amt, payment_max_msat, - request_asset_max_amt, request_payment_max_msat, price_oracle_metadata, - request_version, agreed_at + rate_coefficient, rate_scale, expiry, max_out_asset_amt, + payment_max_msat, request_asset_max_amt, + request_payment_max_msat, price_oracle_metadata, + request_version, agreed_at, accepted_max_amount FROM rfq_policies WHERE expiry >= sqlc.arg('min_expiry'); diff --git a/tapdb/sqlc/rfq.sql.go b/tapdb/sqlc/rfq.sql.go index 05a8667270..e30888c4bf 100644 --- a/tapdb/sqlc/rfq.sql.go +++ b/tapdb/sqlc/rfq.sql.go @@ -47,9 +47,10 @@ func (q *Queries) CountForwards(ctx context.Context, arg CountForwardsParams) (i const FetchActiveRfqPolicies = `-- name: FetchActiveRfqPolicies :many SELECT id, policy_type, scid, rfq_id, peer, asset_id, asset_group_key, - rate_coefficient, rate_scale, expiry, max_out_asset_amt, payment_max_msat, - request_asset_max_amt, request_payment_max_msat, price_oracle_metadata, - request_version, agreed_at + rate_coefficient, rate_scale, expiry, max_out_asset_amt, + payment_max_msat, request_asset_max_amt, + request_payment_max_msat, price_oracle_metadata, + request_version, agreed_at, accepted_max_amount FROM rfq_policies WHERE expiry >= $1 ` @@ -81,6 +82,7 @@ func (q *Queries) FetchActiveRfqPolicies(ctx context.Context, minExpiry int64) ( &i.PriceOracleMetadata, &i.RequestVersion, &i.AgreedAt, + &i.AcceptedMaxAmount, ); err != nil { return nil, err } @@ -112,13 +114,14 @@ func (q *Queries) FetchPeerAcceptedBuyPeerByScid(ctx context.Context, scid int64 const InsertRfqPolicy = `-- name: InsertRfqPolicy :one INSERT INTO rfq_policies ( policy_type, scid, rfq_id, peer, asset_id, asset_group_key, - rate_coefficient, rate_scale, expiry, max_out_asset_amt, payment_max_msat, - request_asset_max_amt, request_payment_max_msat, price_oracle_metadata, - request_version, agreed_at + rate_coefficient, rate_scale, expiry, max_out_asset_amt, + payment_max_msat, request_asset_max_amt, + request_payment_max_msat, price_oracle_metadata, + request_version, agreed_at, accepted_max_amount ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14, $15, $16 + $10, $11, $12, $13, $14, $15, $16, $17 ) RETURNING id ` @@ -140,6 +143,7 @@ type InsertRfqPolicyParams struct { PriceOracleMetadata sql.NullString RequestVersion sql.NullInt32 AgreedAt int64 + AcceptedMaxAmount sql.NullInt64 } func (q *Queries) InsertRfqPolicy(ctx context.Context, arg InsertRfqPolicyParams) (int64, error) { @@ -160,6 +164,7 @@ func (q *Queries) InsertRfqPolicy(ctx context.Context, arg InsertRfqPolicyParams arg.PriceOracleMetadata, arg.RequestVersion, arg.AgreedAt, + arg.AcceptedMaxAmount, ) var id int64 err := row.Scan(&id) diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index 17bc919e45..646d680cf7 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -882,7 +882,7 @@ CREATE TABLE rfq_policies ( -- agreed_at is the timestamp when the policy was agreed upon. agreed_at BIGINT NOT NULL -); +, accepted_max_amount BIGINT); CREATE UNIQUE INDEX rfq_policies_rfq_id_idx ON rfq_policies (rfq_id); diff --git a/taprpc/portfoliopilotrpc/portfolio_pilot.pb.go b/taprpc/portfoliopilotrpc/portfolio_pilot.pb.go index fabfd1afde..6d50368720 100644 --- a/taprpc/portfoliopilotrpc/portfolio_pilot.pb.go +++ b/taprpc/portfoliopilotrpc/portfolio_pilot.pb.go @@ -187,6 +187,15 @@ const ( // REJECT_CODE_PRICE_ORACLE_UNAVAILABLE indicates that pricing could not be // provided due to an unavailable oracle. RejectCode_REJECT_CODE_PRICE_ORACLE_UNAVAILABLE RejectCode = 1 + // REJECT_CODE_MIN_FILL_NOT_MET indicates that the minimum fill + // constraint was not satisfiable at the accepted rate. + RejectCode_REJECT_CODE_MIN_FILL_NOT_MET RejectCode = 2 + // REJECT_CODE_PRICE_BOUND_MISS indicates that the accepted rate + // violated the requester's rate limit constraint. + RejectCode_REJECT_CODE_PRICE_BOUND_MISS RejectCode = 3 + // REJECT_CODE_FOK_NOT_VIABLE indicates that the FOK execution + // policy could not be satisfied at the accepted rate. + RejectCode_REJECT_CODE_FOK_NOT_VIABLE RejectCode = 4 ) // Enum value maps for RejectCode. @@ -194,10 +203,16 @@ var ( RejectCode_name = map[int32]string{ 0: "REJECT_CODE_UNSPECIFIED", 1: "REJECT_CODE_PRICE_ORACLE_UNAVAILABLE", + 2: "REJECT_CODE_MIN_FILL_NOT_MET", + 3: "REJECT_CODE_PRICE_BOUND_MISS", + 4: "REJECT_CODE_FOK_NOT_VIABLE", } RejectCode_value = map[string]int32{ "REJECT_CODE_UNSPECIFIED": 0, "REJECT_CODE_PRICE_ORACLE_UNAVAILABLE": 1, + "REJECT_CODE_MIN_FILL_NOT_MET": 2, + "REJECT_CODE_PRICE_BOUND_MISS": 3, + "REJECT_CODE_FOK_NOT_VIABLE": 4, } ) @@ -247,6 +262,15 @@ const ( // VALID_ACCEPT_QUOTE indicates that the accepted quote passed all // validation checks successfully. QuoteRespStatus_VALID_ACCEPT_QUOTE QuoteRespStatus = 4 + // MIN_FILL_NOT_MET indicates that the minimum fill constraint was + // not satisfiable at the accepted rate. + QuoteRespStatus_MIN_FILL_NOT_MET QuoteRespStatus = 5 + // RATE_BOUND_MISS indicates that the accepted rate violated the + // requester's rate limit constraint. + QuoteRespStatus_RATE_BOUND_MISS QuoteRespStatus = 6 + // FOK_NOT_VIABLE indicates that the FOK execution policy could + // not be satisfied at the accepted rate. + QuoteRespStatus_FOK_NOT_VIABLE QuoteRespStatus = 7 ) // Enum value maps for QuoteRespStatus. @@ -257,6 +281,9 @@ var ( 2: "PRICE_ORACLE_QUERY_ERR", 3: "PORTFOLIO_PILOT_ERR", 4: "VALID_ACCEPT_QUOTE", + 5: "MIN_FILL_NOT_MET", + 6: "RATE_BOUND_MISS", + 7: "FOK_NOT_VIABLE", } QuoteRespStatus_value = map[string]int32{ "INVALID_ASSET_RATES": 0, @@ -264,6 +291,9 @@ var ( "PRICE_ORACLE_QUERY_ERR": 2, "PORTFOLIO_PILOT_ERR": 3, "VALID_ACCEPT_QUOTE": 4, + "MIN_FILL_NOT_MET": 5, + "RATE_BOUND_MISS": 6, + "FOK_NOT_VIABLE": 7, } ) @@ -626,6 +656,12 @@ type BuyRequest struct { PriceOracleMetadata string `protobuf:"bytes,4,opt,name=price_oracle_metadata,json=priceOracleMetadata,proto3" json:"price_oracle_metadata,omitempty"` // peer_id is the 33-byte public key of the counterparty peer. PeerId []byte `protobuf:"bytes,5,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + // asset_min_amount is an optional minimum asset amount. A value of 0 + // means unset. + AssetMinAmount uint64 `protobuf:"varint,6,opt,name=asset_min_amount,json=assetMinAmount,proto3" json:"asset_min_amount,omitempty"` + // asset_rate_limit is an optional minimum acceptable rate (asset units + // per BTC). + AssetRateLimit *FixedPoint `protobuf:"bytes,7,opt,name=asset_rate_limit,json=assetRateLimit,proto3" json:"asset_rate_limit,omitempty"` } func (x *BuyRequest) Reset() { @@ -695,6 +731,20 @@ func (x *BuyRequest) GetPeerId() []byte { return nil } +func (x *BuyRequest) GetAssetMinAmount() uint64 { + if x != nil { + return x.AssetMinAmount + } + return 0 +} + +func (x *BuyRequest) GetAssetRateLimit() *FixedPoint { + if x != nil { + return x.AssetRateLimit + } + return nil +} + // SellRequest represents a request to sell the subject asset. type SellRequest struct { state protoimpl.MessageState @@ -712,6 +762,12 @@ type SellRequest struct { PriceOracleMetadata string `protobuf:"bytes,4,opt,name=price_oracle_metadata,json=priceOracleMetadata,proto3" json:"price_oracle_metadata,omitempty"` // peer_id is the 33-byte public key of the counterparty peer. PeerId []byte `protobuf:"bytes,5,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + // payment_min_amount is an optional minimum msat amount. A value of 0 + // means unset. + PaymentMinAmount uint64 `protobuf:"varint,6,opt,name=payment_min_amount,json=paymentMinAmount,proto3" json:"payment_min_amount,omitempty"` + // asset_rate_limit is an optional maximum acceptable rate (asset units + // per BTC). + AssetRateLimit *FixedPoint `protobuf:"bytes,7,opt,name=asset_rate_limit,json=assetRateLimit,proto3" json:"asset_rate_limit,omitempty"` } func (x *SellRequest) Reset() { @@ -781,6 +837,20 @@ func (x *SellRequest) GetPeerId() []byte { return nil } +func (x *SellRequest) GetPaymentMinAmount() uint64 { + if x != nil { + return x.PaymentMinAmount + } + return 0 +} + +func (x *SellRequest) GetAssetRateLimit() *FixedPoint { + if x != nil { + return x.AssetRateLimit + } + return nil +} + // ResolveRequestRequest specifies a quote request to resolve. type ResolveRequestRequest struct { state protoimpl.MessageState @@ -876,6 +946,12 @@ type ResolveRequestResponse struct { // *ResolveRequestResponse_Accept // *ResolveRequestResponse_Reject Result isResolveRequestResponse_Result `protobuf_oneof:"result"` + // accepted_max_amount is an optional fill quantity that caps the + // amount the responder is willing to accept. Only meaningful when + // accept is set; 0 means no fill cap (full request max). The unit + // depends on the request type: asset units for a buy request, msat + // for a sell request. + AcceptedMaxAmount uint64 `protobuf:"varint,3,opt,name=accepted_max_amount,json=acceptedMaxAmount,proto3" json:"accepted_max_amount,omitempty"` } func (x *ResolveRequestResponse) Reset() { @@ -931,6 +1007,13 @@ func (x *ResolveRequestResponse) GetReject() *RejectErr { return nil } +func (x *ResolveRequestResponse) GetAcceptedMaxAmount() uint64 { + if x != nil { + return x.AcceptedMaxAmount + } + return 0 +} + type isResolveRequestResponse_Result interface { isResolveRequestResponse_Result() } @@ -964,6 +1047,11 @@ type AcceptedQuote struct { // *AcceptedQuote_BuyRequest // *AcceptedQuote_SellRequest Request isAcceptedQuote_Request `protobuf_oneof:"request"` + // accepted_max_amount is the optional negotiated fill quantity. + // 0 means no fill cap (full request max). The unit depends on + // the request type: asset units for a buy request, msat for a + // sell request. + AcceptedMaxAmount uint64 `protobuf:"varint,5,opt,name=accepted_max_amount,json=acceptedMaxAmount,proto3" json:"accepted_max_amount,omitempty"` } func (x *AcceptedQuote) Reset() { @@ -1033,6 +1121,13 @@ func (x *AcceptedQuote) GetSellRequest() *SellRequest { return nil } +func (x *AcceptedQuote) GetAcceptedMaxAmount() uint64 { + if x != nil { + return x.AcceptedMaxAmount + } + return 0 +} + type isAcceptedQuote_Request interface { isAcceptedQuote_Request() } @@ -1355,7 +1450,7 @@ var file_portfoliopilotrpc_portfolio_pilot_proto_rawDesc = []byte{ 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, 0x04, 0x72, 0x61, 0x74, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x95, 0x02, 0x0a, 0x0a, 0x42, 0x75, 0x79, 0x52, 0x65, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x88, 0x03, 0x0a, 0x0a, 0x42, 0x75, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4a, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, @@ -1372,170 +1467,201 @@ var file_portfoliopilotrpc_portfolio_pilot_proto_rawDesc = []byte{ 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, 0x64, 0x22, 0x9a, - 0x02, 0x0a, 0x0b, 0x53, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4a, - 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, - 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, - 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, - 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x61, - 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x4d, - 0x61, 0x78, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, - 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x68, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, - 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x52, - 0x0d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x48, 0x69, 0x6e, 0x74, 0x12, 0x32, - 0x0a, 0x15, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x6f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x5f, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, - 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, 0x64, 0x22, 0xa9, 0x01, 0x0a, 0x15, - 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x40, 0x0a, 0x0b, 0x62, 0x75, 0x79, 0x5f, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6f, 0x72, - 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x42, - 0x75, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x62, 0x75, 0x79, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x43, 0x0a, 0x0c, 0x73, 0x65, 0x6c, 0x6c, 0x5f, - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, - 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, - 0x63, 0x2e, 0x53, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, - 0x0b, 0x73, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x42, 0x09, 0x0a, 0x07, - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x92, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, - 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x36, 0x0a, 0x06, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, - 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, - 0x48, 0x00, 0x52, 0x06, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x12, 0x36, 0x0a, 0x06, 0x72, 0x65, - 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, - 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x52, - 0x65, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x72, 0x72, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x6a, 0x65, - 0x63, 0x74, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0xfd, 0x01, 0x0a, - 0x0d, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x17, - 0x0a, 0x07, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, 0x64, 0x12, 0x41, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x70, - 0x74, 0x65, 0x64, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, - 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, - 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x52, 0x0c, 0x61, 0x63, - 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x52, 0x61, 0x74, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x62, 0x75, - 0x79, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x1d, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, - 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, - 0x52, 0x0a, 0x62, 0x75, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x43, 0x0a, 0x0c, - 0x73, 0x65, 0x6c, 0x6c, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, - 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x73, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x54, 0x0a, 0x18, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, 0x64, 0x12, 0x28, + 0x0a, 0x10, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x5f, 0x61, 0x6d, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x4d, + 0x69, 0x6e, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x47, 0x0a, 0x10, 0x61, 0x73, 0x73, 0x65, + 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, + 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x78, 0x65, 0x64, 0x50, 0x6f, 0x69, 0x6e, + 0x74, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x6d, 0x69, + 0x74, 0x22, 0x91, 0x03, 0x0a, 0x0b, 0x53, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x4a, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, + 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x70, 0x6f, 0x72, + 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, + 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0e, 0x61, + 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x2c, 0x0a, + 0x12, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x70, 0x61, 0x79, 0x6d, 0x65, + 0x6e, 0x74, 0x4d, 0x61, 0x78, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0f, 0x61, + 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x68, 0x69, 0x6e, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, + 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, + 0x74, 0x65, 0x52, 0x0d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x48, 0x69, 0x6e, + 0x74, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x6f, 0x72, 0x61, 0x63, 0x6c, + 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x13, 0x70, 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2c, + 0x0a, 0x12, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x5f, 0x61, 0x6d, + 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x10, 0x70, 0x61, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x4d, 0x69, 0x6e, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x47, 0x0a, 0x10, + 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, + 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x78, 0x65, 0x64, + 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, + 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0xa9, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x40, 0x0a, 0x0b, 0x62, 0x75, 0x79, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, + 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x62, 0x75, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x43, 0x0a, 0x0c, 0x73, 0x65, 0x6c, 0x6c, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, + 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x65, 0x6c, 0x6c, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x73, 0x65, 0x6c, 0x6c, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x22, 0xc2, 0x01, 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x36, 0x0a, 0x06, + 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, + 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, + 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x48, 0x00, 0x52, 0x06, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x12, 0x36, 0x0a, 0x06, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, + 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x45, + 0x72, 0x72, 0x48, 0x00, 0x52, 0x06, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x2e, 0x0a, 0x13, + 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x11, 0x61, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x61, 0x78, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x08, 0x0a, 0x06, + 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0xad, 0x02, 0x0a, 0x0d, 0x41, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x65, 0x65, 0x72, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x41, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x72, 0x61, + 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, + 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, + 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x52, 0x61, 0x74, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x62, 0x75, 0x79, 0x5f, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x70, 0x6f, 0x72, 0x74, + 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x42, 0x75, + 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x62, 0x75, 0x79, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x43, 0x0a, 0x0c, 0x73, 0x65, 0x6c, 0x6c, 0x5f, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, + 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, + 0x2e, 0x53, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0b, + 0x73, 0x65, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x61, + 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x6d, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x11, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x65, 0x64, 0x4d, 0x61, 0x78, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x09, 0x0a, 0x07, 0x72, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x54, 0x0a, 0x18, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x38, 0x0a, 0x06, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, + 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, + 0x75, 0x6f, 0x74, 0x65, 0x52, 0x06, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x22, 0x57, 0x0a, 0x19, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x38, 0x0a, 0x06, 0x61, 0x63, 0x63, 0x65, - 0x70, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, - 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x63, 0x63, - 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x06, 0x61, 0x63, 0x63, 0x65, - 0x70, 0x74, 0x22, 0x57, 0x0a, 0x19, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x41, 0x63, 0x63, 0x65, - 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x22, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, - 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x53, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xe8, 0x03, 0x0a, 0x16, - 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4a, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, - 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x21, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, - 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, - 0x65, 0x72, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, - 0x65, 0x72, 0x12, 0x47, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, - 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x54, - 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x0a, 0x06, 0x69, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x70, 0x6f, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x06, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x70, 0x6f, 0x72, 0x74, + 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, + 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xe8, 0x03, 0x0a, 0x16, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, + 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x4a, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, + 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x70, 0x6f, 0x72, 0x74, + 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, + 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0e, 0x61, 0x73, + 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x09, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x29, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, + 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, + 0x72, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x0a, 0x06, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, + 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x74, + 0x52, 0x06, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x73, 0x73, 0x65, + 0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, + 0x61, 0x73, 0x73, 0x65, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x70, + 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x04, 0x52, 0x0d, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x41, 0x6d, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, + 0x5f, 0x68, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, - 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x21, - 0x0a, 0x0c, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x61, 0x73, 0x73, 0x65, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x6d, 0x6f, - 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x70, 0x61, 0x79, 0x6d, 0x65, - 0x6e, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x44, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, - 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x5f, 0x68, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, - 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x52, - 0x0d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x48, 0x69, 0x6e, 0x74, 0x12, 0x32, - 0x0a, 0x15, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x6f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x5f, 0x6d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, - 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x65, 0x65, 0x72, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x65, - 0x78, 0x70, 0x69, 0x72, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x56, 0x0a, 0x17, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, - 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x3b, 0x0a, 0x0a, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, - 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, - 0x61, 0x74, 0x65, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x2a, 0x87, - 0x01, 0x0a, 0x16, 0x41, 0x73, 0x73, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, - 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x24, 0x41, 0x53, 0x53, - 0x45, 0x54, 0x5f, 0x54, 0x52, 0x41, 0x4e, 0x53, 0x46, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, - 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, - 0x44, 0x10, 0x00, 0x12, 0x20, 0x0a, 0x1c, 0x41, 0x53, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x52, 0x41, + 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x52, 0x0d, 0x61, 0x73, 0x73, 0x65, 0x74, + 0x52, 0x61, 0x74, 0x65, 0x48, 0x69, 0x6e, 0x74, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x69, 0x63, + 0x65, 0x5f, 0x6f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, + 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, + 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, + 0x65, 0x65, 0x72, 0x49, 0x64, 0x12, 0x29, 0x0a, 0x10, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x0f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x22, 0x56, 0x0a, 0x17, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, + 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x0a, 0x61, + 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, + 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x52, 0x09, 0x61, + 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x2a, 0x87, 0x01, 0x0a, 0x16, 0x41, 0x73, 0x73, + 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x24, 0x41, 0x53, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x52, 0x41, 0x4e, 0x53, 0x46, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, - 0x42, 0x55, 0x59, 0x10, 0x01, 0x12, 0x21, 0x0a, 0x1d, 0x41, 0x53, 0x53, 0x45, 0x54, 0x5f, 0x54, - 0x52, 0x41, 0x4e, 0x53, 0x46, 0x45, 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, - 0x4e, 0x5f, 0x53, 0x45, 0x4c, 0x4c, 0x10, 0x02, 0x2a, 0xcd, 0x01, 0x0a, 0x06, 0x49, 0x6e, 0x74, - 0x65, 0x6e, 0x74, 0x12, 0x16, 0x0a, 0x12, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x55, 0x4e, - 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x49, - 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x41, 0x59, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, - 0x45, 0x5f, 0x48, 0x49, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x49, 0x4e, 0x54, 0x45, - 0x4e, 0x54, 0x5f, 0x50, 0x41, 0x59, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x10, 0x02, - 0x12, 0x1e, 0x0a, 0x1a, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x41, 0x59, 0x5f, 0x49, - 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x51, 0x55, 0x41, 0x4c, 0x49, 0x46, 0x59, 0x10, 0x03, - 0x12, 0x1c, 0x0a, 0x18, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x45, 0x43, 0x56, 0x5f, - 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x48, 0x49, 0x4e, 0x54, 0x10, 0x04, 0x12, 0x17, - 0x0a, 0x13, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x45, 0x43, 0x56, 0x5f, 0x50, 0x41, - 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x05, 0x12, 0x1f, 0x0a, 0x1b, 0x49, 0x4e, 0x54, 0x45, 0x4e, - 0x54, 0x5f, 0x52, 0x45, 0x43, 0x56, 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x51, - 0x55, 0x41, 0x4c, 0x49, 0x46, 0x59, 0x10, 0x06, 0x2a, 0x53, 0x0a, 0x0a, 0x52, 0x65, 0x6a, 0x65, - 0x63, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x4a, 0x45, 0x43, 0x54, - 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, - 0x44, 0x10, 0x00, 0x12, 0x28, 0x0a, 0x24, 0x52, 0x45, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, - 0x44, 0x45, 0x5f, 0x50, 0x52, 0x49, 0x43, 0x45, 0x5f, 0x4f, 0x52, 0x41, 0x43, 0x4c, 0x45, 0x5f, - 0x55, 0x4e, 0x41, 0x56, 0x41, 0x49, 0x4c, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x01, 0x2a, 0x8b, 0x01, - 0x0a, 0x0f, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x12, 0x17, 0x0a, 0x13, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x53, 0x53, - 0x45, 0x54, 0x5f, 0x52, 0x41, 0x54, 0x45, 0x53, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x49, 0x4e, - 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x45, 0x58, 0x50, 0x49, 0x52, 0x59, 0x10, 0x01, 0x12, 0x1a, - 0x0a, 0x16, 0x50, 0x52, 0x49, 0x43, 0x45, 0x5f, 0x4f, 0x52, 0x41, 0x43, 0x4c, 0x45, 0x5f, 0x51, - 0x55, 0x45, 0x52, 0x59, 0x5f, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x4f, - 0x52, 0x54, 0x46, 0x4f, 0x4c, 0x49, 0x4f, 0x5f, 0x50, 0x49, 0x4c, 0x4f, 0x54, 0x5f, 0x45, 0x52, - 0x52, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x43, 0x43, - 0x45, 0x50, 0x54, 0x5f, 0x51, 0x55, 0x4f, 0x54, 0x45, 0x10, 0x04, 0x32, 0xd1, 0x02, 0x0a, 0x0e, - 0x50, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x50, 0x69, 0x6c, 0x6f, 0x74, 0x12, 0x65, - 0x0a, 0x0e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x28, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, - 0x74, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x70, 0x6f, 0x72, - 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x52, - 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6e, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x41, - 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x2b, 0x2e, 0x70, 0x6f, 0x72, - 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x56, - 0x65, 0x72, 0x69, 0x66, 0x79, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, - 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x65, 0x72, 0x69, - 0x66, 0x79, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0f, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, 0x73, - 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x12, 0x29, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, - 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, - 0x72, 0x79, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, - 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, 0x73, 0x73, - 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, - 0x42, 0x5a, 0x40, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, - 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, - 0x6f, 0x6f, 0x74, 0x2d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, 0x70, - 0x63, 0x2f, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, - 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x20, 0x0a, + 0x1c, 0x41, 0x53, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x52, 0x41, 0x4e, 0x53, 0x46, 0x45, 0x52, 0x5f, + 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x42, 0x55, 0x59, 0x10, 0x01, 0x12, + 0x21, 0x0a, 0x1d, 0x41, 0x53, 0x53, 0x45, 0x54, 0x5f, 0x54, 0x52, 0x41, 0x4e, 0x53, 0x46, 0x45, + 0x52, 0x5f, 0x44, 0x49, 0x52, 0x45, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x45, 0x4c, 0x4c, + 0x10, 0x02, 0x2a, 0xcd, 0x01, 0x0a, 0x06, 0x49, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x16, 0x0a, + 0x12, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, + 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, + 0x50, 0x41, 0x59, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x5f, 0x48, 0x49, 0x4e, 0x54, + 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x41, 0x59, + 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, 0x10, 0x02, 0x12, 0x1e, 0x0a, 0x1a, 0x49, 0x4e, + 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x41, 0x59, 0x5f, 0x49, 0x4e, 0x56, 0x4f, 0x49, 0x43, 0x45, + 0x5f, 0x51, 0x55, 0x41, 0x4c, 0x49, 0x46, 0x59, 0x10, 0x03, 0x12, 0x1c, 0x0a, 0x18, 0x49, 0x4e, + 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x45, 0x43, 0x56, 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, + 0x54, 0x5f, 0x48, 0x49, 0x4e, 0x54, 0x10, 0x04, 0x12, 0x17, 0x0a, 0x13, 0x49, 0x4e, 0x54, 0x45, + 0x4e, 0x54, 0x5f, 0x52, 0x45, 0x43, 0x56, 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, + 0x05, 0x12, 0x1f, 0x0a, 0x1b, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x45, 0x43, 0x56, + 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x51, 0x55, 0x41, 0x4c, 0x49, 0x46, 0x59, + 0x10, 0x06, 0x2a, 0xb7, 0x01, 0x0a, 0x0a, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x64, + 0x65, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x44, 0x45, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x28, + 0x0a, 0x24, 0x52, 0x45, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x50, 0x52, + 0x49, 0x43, 0x45, 0x5f, 0x4f, 0x52, 0x41, 0x43, 0x4c, 0x45, 0x5f, 0x55, 0x4e, 0x41, 0x56, 0x41, + 0x49, 0x4c, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x01, 0x12, 0x20, 0x0a, 0x1c, 0x52, 0x45, 0x4a, 0x45, + 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x4d, 0x49, 0x4e, 0x5f, 0x46, 0x49, 0x4c, 0x4c, + 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x4d, 0x45, 0x54, 0x10, 0x02, 0x12, 0x20, 0x0a, 0x1c, 0x52, 0x45, + 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x50, 0x52, 0x49, 0x43, 0x45, 0x5f, + 0x42, 0x4f, 0x55, 0x4e, 0x44, 0x5f, 0x4d, 0x49, 0x53, 0x53, 0x10, 0x03, 0x12, 0x1e, 0x0a, 0x1a, + 0x52, 0x45, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x46, 0x4f, 0x4b, 0x5f, + 0x4e, 0x4f, 0x54, 0x5f, 0x56, 0x49, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x04, 0x2a, 0xca, 0x01, 0x0a, + 0x0f, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x12, 0x17, 0x0a, 0x13, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x53, 0x53, 0x45, + 0x54, 0x5f, 0x52, 0x41, 0x54, 0x45, 0x53, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x49, 0x4e, 0x56, + 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x45, 0x58, 0x50, 0x49, 0x52, 0x59, 0x10, 0x01, 0x12, 0x1a, 0x0a, + 0x16, 0x50, 0x52, 0x49, 0x43, 0x45, 0x5f, 0x4f, 0x52, 0x41, 0x43, 0x4c, 0x45, 0x5f, 0x51, 0x55, + 0x45, 0x52, 0x59, 0x5f, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x4f, 0x52, + 0x54, 0x46, 0x4f, 0x4c, 0x49, 0x4f, 0x5f, 0x50, 0x49, 0x4c, 0x4f, 0x54, 0x5f, 0x45, 0x52, 0x52, + 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x43, 0x43, 0x45, + 0x50, 0x54, 0x5f, 0x51, 0x55, 0x4f, 0x54, 0x45, 0x10, 0x04, 0x12, 0x14, 0x0a, 0x10, 0x4d, 0x49, + 0x4e, 0x5f, 0x46, 0x49, 0x4c, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x4d, 0x45, 0x54, 0x10, 0x05, + 0x12, 0x13, 0x0a, 0x0f, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x42, 0x4f, 0x55, 0x4e, 0x44, 0x5f, 0x4d, + 0x49, 0x53, 0x53, 0x10, 0x06, 0x12, 0x12, 0x0a, 0x0e, 0x46, 0x4f, 0x4b, 0x5f, 0x4e, 0x4f, 0x54, + 0x5f, 0x56, 0x49, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x07, 0x32, 0xd1, 0x02, 0x0a, 0x0e, 0x50, 0x6f, + 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x50, 0x69, 0x6c, 0x6f, 0x74, 0x12, 0x65, 0x0a, 0x0e, + 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x28, + 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, + 0x70, 0x63, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, + 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x73, + 0x6f, 0x6c, 0x76, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x6e, 0x0a, 0x11, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x41, 0x63, 0x63, + 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x2b, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, + 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x65, 0x72, + 0x69, 0x66, 0x79, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, + 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x0f, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, 0x73, 0x73, 0x65, + 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x12, 0x29, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, + 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, + 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2a, 0x2e, 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, + 0x6f, 0x74, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x41, 0x73, 0x73, 0x65, 0x74, + 0x52, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x42, 0x5a, + 0x40, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x67, 0x68, + 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, 0x6f, 0x6f, + 0x74, 0x2d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, 0x70, 0x63, 0x2f, + 0x70, 0x6f, 0x72, 0x74, 0x66, 0x6f, 0x6c, 0x69, 0x6f, 0x70, 0x69, 0x6c, 0x6f, 0x74, 0x72, 0x70, + 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1576,33 +1702,35 @@ var file_portfoliopilotrpc_portfolio_pilot_proto_depIdxs = []int32{ 6, // 1: portfoliopilotrpc.AssetRate.rate:type_name -> portfoliopilotrpc.FixedPoint 5, // 2: portfoliopilotrpc.BuyRequest.asset_specifier:type_name -> portfoliopilotrpc.AssetSpecifier 7, // 3: portfoliopilotrpc.BuyRequest.asset_rate_hint:type_name -> portfoliopilotrpc.AssetRate - 5, // 4: portfoliopilotrpc.SellRequest.asset_specifier:type_name -> portfoliopilotrpc.AssetSpecifier - 7, // 5: portfoliopilotrpc.SellRequest.asset_rate_hint:type_name -> portfoliopilotrpc.AssetRate - 8, // 6: portfoliopilotrpc.ResolveRequestRequest.buy_request:type_name -> portfoliopilotrpc.BuyRequest - 9, // 7: portfoliopilotrpc.ResolveRequestRequest.sell_request:type_name -> portfoliopilotrpc.SellRequest - 7, // 8: portfoliopilotrpc.ResolveRequestResponse.accept:type_name -> portfoliopilotrpc.AssetRate - 4, // 9: portfoliopilotrpc.ResolveRequestResponse.reject:type_name -> portfoliopilotrpc.RejectErr - 7, // 10: portfoliopilotrpc.AcceptedQuote.accepted_rate:type_name -> portfoliopilotrpc.AssetRate - 8, // 11: portfoliopilotrpc.AcceptedQuote.buy_request:type_name -> portfoliopilotrpc.BuyRequest - 9, // 12: portfoliopilotrpc.AcceptedQuote.sell_request:type_name -> portfoliopilotrpc.SellRequest - 12, // 13: portfoliopilotrpc.VerifyAcceptQuoteRequest.accept:type_name -> portfoliopilotrpc.AcceptedQuote - 3, // 14: portfoliopilotrpc.VerifyAcceptQuoteResponse.status:type_name -> portfoliopilotrpc.QuoteRespStatus - 5, // 15: portfoliopilotrpc.QueryAssetRatesRequest.asset_specifier:type_name -> portfoliopilotrpc.AssetSpecifier - 0, // 16: portfoliopilotrpc.QueryAssetRatesRequest.direction:type_name -> portfoliopilotrpc.AssetTransferDirection - 1, // 17: portfoliopilotrpc.QueryAssetRatesRequest.intent:type_name -> portfoliopilotrpc.Intent - 7, // 18: portfoliopilotrpc.QueryAssetRatesRequest.asset_rate_hint:type_name -> portfoliopilotrpc.AssetRate - 7, // 19: portfoliopilotrpc.QueryAssetRatesResponse.asset_rate:type_name -> portfoliopilotrpc.AssetRate - 10, // 20: portfoliopilotrpc.PortfolioPilot.ResolveRequest:input_type -> portfoliopilotrpc.ResolveRequestRequest - 13, // 21: portfoliopilotrpc.PortfolioPilot.VerifyAcceptQuote:input_type -> portfoliopilotrpc.VerifyAcceptQuoteRequest - 15, // 22: portfoliopilotrpc.PortfolioPilot.QueryAssetRates:input_type -> portfoliopilotrpc.QueryAssetRatesRequest - 11, // 23: portfoliopilotrpc.PortfolioPilot.ResolveRequest:output_type -> portfoliopilotrpc.ResolveRequestResponse - 14, // 24: portfoliopilotrpc.PortfolioPilot.VerifyAcceptQuote:output_type -> portfoliopilotrpc.VerifyAcceptQuoteResponse - 16, // 25: portfoliopilotrpc.PortfolioPilot.QueryAssetRates:output_type -> portfoliopilotrpc.QueryAssetRatesResponse - 23, // [23:26] is the sub-list for method output_type - 20, // [20:23] is the sub-list for method input_type - 20, // [20:20] is the sub-list for extension type_name - 20, // [20:20] is the sub-list for extension extendee - 0, // [0:20] is the sub-list for field type_name + 6, // 4: portfoliopilotrpc.BuyRequest.asset_rate_limit:type_name -> portfoliopilotrpc.FixedPoint + 5, // 5: portfoliopilotrpc.SellRequest.asset_specifier:type_name -> portfoliopilotrpc.AssetSpecifier + 7, // 6: portfoliopilotrpc.SellRequest.asset_rate_hint:type_name -> portfoliopilotrpc.AssetRate + 6, // 7: portfoliopilotrpc.SellRequest.asset_rate_limit:type_name -> portfoliopilotrpc.FixedPoint + 8, // 8: portfoliopilotrpc.ResolveRequestRequest.buy_request:type_name -> portfoliopilotrpc.BuyRequest + 9, // 9: portfoliopilotrpc.ResolveRequestRequest.sell_request:type_name -> portfoliopilotrpc.SellRequest + 7, // 10: portfoliopilotrpc.ResolveRequestResponse.accept:type_name -> portfoliopilotrpc.AssetRate + 4, // 11: portfoliopilotrpc.ResolveRequestResponse.reject:type_name -> portfoliopilotrpc.RejectErr + 7, // 12: portfoliopilotrpc.AcceptedQuote.accepted_rate:type_name -> portfoliopilotrpc.AssetRate + 8, // 13: portfoliopilotrpc.AcceptedQuote.buy_request:type_name -> portfoliopilotrpc.BuyRequest + 9, // 14: portfoliopilotrpc.AcceptedQuote.sell_request:type_name -> portfoliopilotrpc.SellRequest + 12, // 15: portfoliopilotrpc.VerifyAcceptQuoteRequest.accept:type_name -> portfoliopilotrpc.AcceptedQuote + 3, // 16: portfoliopilotrpc.VerifyAcceptQuoteResponse.status:type_name -> portfoliopilotrpc.QuoteRespStatus + 5, // 17: portfoliopilotrpc.QueryAssetRatesRequest.asset_specifier:type_name -> portfoliopilotrpc.AssetSpecifier + 0, // 18: portfoliopilotrpc.QueryAssetRatesRequest.direction:type_name -> portfoliopilotrpc.AssetTransferDirection + 1, // 19: portfoliopilotrpc.QueryAssetRatesRequest.intent:type_name -> portfoliopilotrpc.Intent + 7, // 20: portfoliopilotrpc.QueryAssetRatesRequest.asset_rate_hint:type_name -> portfoliopilotrpc.AssetRate + 7, // 21: portfoliopilotrpc.QueryAssetRatesResponse.asset_rate:type_name -> portfoliopilotrpc.AssetRate + 10, // 22: portfoliopilotrpc.PortfolioPilot.ResolveRequest:input_type -> portfoliopilotrpc.ResolveRequestRequest + 13, // 23: portfoliopilotrpc.PortfolioPilot.VerifyAcceptQuote:input_type -> portfoliopilotrpc.VerifyAcceptQuoteRequest + 15, // 24: portfoliopilotrpc.PortfolioPilot.QueryAssetRates:input_type -> portfoliopilotrpc.QueryAssetRatesRequest + 11, // 25: portfoliopilotrpc.PortfolioPilot.ResolveRequest:output_type -> portfoliopilotrpc.ResolveRequestResponse + 14, // 26: portfoliopilotrpc.PortfolioPilot.VerifyAcceptQuote:output_type -> portfoliopilotrpc.VerifyAcceptQuoteResponse + 16, // 27: portfoliopilotrpc.PortfolioPilot.QueryAssetRates:output_type -> portfoliopilotrpc.QueryAssetRatesResponse + 25, // [25:28] is the sub-list for method output_type + 22, // [22:25] is the sub-list for method input_type + 22, // [22:22] is the sub-list for extension type_name + 22, // [22:22] is the sub-list for extension extendee + 0, // [0:22] is the sub-list for field type_name } func init() { file_portfoliopilotrpc_portfolio_pilot_proto_init() } diff --git a/taprpc/portfoliopilotrpc/portfolio_pilot.proto b/taprpc/portfoliopilotrpc/portfolio_pilot.proto index f2184580b0..ed10114006 100644 --- a/taprpc/portfoliopilotrpc/portfolio_pilot.proto +++ b/taprpc/portfoliopilotrpc/portfolio_pilot.proto @@ -107,6 +107,18 @@ enum RejectCode { // REJECT_CODE_PRICE_ORACLE_UNAVAILABLE indicates that pricing could not be // provided due to an unavailable oracle. REJECT_CODE_PRICE_ORACLE_UNAVAILABLE = 1; + + // REJECT_CODE_MIN_FILL_NOT_MET indicates that the minimum fill + // constraint was not satisfiable at the accepted rate. + REJECT_CODE_MIN_FILL_NOT_MET = 2; + + // REJECT_CODE_PRICE_BOUND_MISS indicates that the accepted rate + // violated the requester's rate limit constraint. + REJECT_CODE_PRICE_BOUND_MISS = 3; + + // REJECT_CODE_FOK_NOT_VIABLE indicates that the FOK execution + // policy could not be satisfied at the accepted rate. + REJECT_CODE_FOK_NOT_VIABLE = 4; } // QuoteRespStatus is an enum that represents the status of a quote response. @@ -130,6 +142,18 @@ enum QuoteRespStatus { // VALID_ACCEPT_QUOTE indicates that the accepted quote passed all // validation checks successfully. VALID_ACCEPT_QUOTE = 4; + + // MIN_FILL_NOT_MET indicates that the minimum fill constraint was + // not satisfiable at the accepted rate. + MIN_FILL_NOT_MET = 5; + + // RATE_BOUND_MISS indicates that the accepted rate violated the + // requester's rate limit constraint. + RATE_BOUND_MISS = 6; + + // FOK_NOT_VIABLE indicates that the FOK execution policy could + // not be satisfied at the accepted rate. + FOK_NOT_VIABLE = 7; } // RejectErr captures a rejection reason for a quote request. @@ -220,6 +244,14 @@ message BuyRequest { // peer_id is the 33-byte public key of the counterparty peer. bytes peer_id = 5; + + // asset_min_amount is an optional minimum asset amount. A value of 0 + // means unset. + uint64 asset_min_amount = 6; + + // asset_rate_limit is an optional minimum acceptable rate (asset units + // per BTC). + FixedPoint asset_rate_limit = 7; } // SellRequest represents a request to sell the subject asset. @@ -239,6 +271,14 @@ message SellRequest { // peer_id is the 33-byte public key of the counterparty peer. bytes peer_id = 5; + + // payment_min_amount is an optional minimum msat amount. A value of 0 + // means unset. + uint64 payment_min_amount = 6; + + // asset_rate_limit is an optional maximum acceptable rate (asset units + // per BTC). + FixedPoint asset_rate_limit = 7; } // ResolveRequestRequest specifies a quote request to resolve. @@ -261,6 +301,13 @@ message ResolveRequestResponse { // reject is the rejection reason for the request. RejectErr reject = 2; } + + // accepted_max_amount is an optional fill quantity that caps the + // amount the responder is willing to accept. Only meaningful when + // accept is set; 0 means no fill cap (full request max). The unit + // depends on the request type: asset units for a buy request, msat + // for a sell request. + uint64 accepted_max_amount = 3; } // AcceptedQuote bundles an accepted quote and its original request. @@ -278,6 +325,12 @@ message AcceptedQuote { // sell_request is the original sell request. SellRequest sell_request = 4; } + + // accepted_max_amount is the optional negotiated fill quantity. + // 0 means no fill cap (full request max). The unit depends on + // the request type: asset units for a buy request, msat for a + // sell request. + uint64 accepted_max_amount = 5; } // VerifyAcceptQuoteRequest specifies an accepted quote to verify. diff --git a/taprpc/portfoliopilotrpc/portfolio_pilot.swagger.json b/taprpc/portfoliopilotrpc/portfolio_pilot.swagger.json index 443f9ef2e6..d9027e775c 100644 --- a/taprpc/portfoliopilotrpc/portfolio_pilot.swagger.json +++ b/taprpc/portfoliopilotrpc/portfolio_pilot.swagger.json @@ -139,6 +139,11 @@ "sell_request": { "$ref": "#/definitions/portfoliopilotrpcSellRequest", "description": "sell_request is the original sell request." + }, + "accepted_max_amount": { + "type": "string", + "format": "uint64", + "description": "accepted_max_amount is the optional negotiated fill quantity.\n0 means no fill cap (full request max). The unit depends on\nthe request type: asset units for a buy request, msat for a\nsell request." } }, "description": "AcceptedQuote bundles an accepted quote and its original request." @@ -215,6 +220,15 @@ "type": "string", "format": "byte", "description": "peer_id is the 33-byte public key of the counterparty peer." + }, + "asset_min_amount": { + "type": "string", + "format": "uint64", + "description": "asset_min_amount is an optional minimum asset amount. A value of 0\nmeans unset." + }, + "asset_rate_limit": { + "$ref": "#/definitions/portfoliopilotrpcFixedPoint", + "description": "asset_rate_limit is an optional minimum acceptable rate (asset units\nper BTC)." } }, "description": "BuyRequest represents a request to buy the subject asset." @@ -311,19 +325,25 @@ "INVALID_EXPIRY", "PRICE_ORACLE_QUERY_ERR", "PORTFOLIO_PILOT_ERR", - "VALID_ACCEPT_QUOTE" + "VALID_ACCEPT_QUOTE", + "MIN_FILL_NOT_MET", + "RATE_BOUND_MISS", + "FOK_NOT_VIABLE" ], "default": "INVALID_ASSET_RATES", - "description": "QuoteRespStatus is an enum that represents the status of a quote response.\n\n - INVALID_ASSET_RATES: INVALID_ASSET_RATES indicates that at least one asset rate in the\nquote response is invalid.\n - INVALID_EXPIRY: INVALID_EXPIRY indicates that the expiry in the quote response is\ninvalid.\n - PRICE_ORACLE_QUERY_ERR: PRICE_ORACLE_QUERY_ERR indicates that an error occurred when querying the\nprice oracle whilst evaluating the quote response.\n - PORTFOLIO_PILOT_ERR: PORTFOLIO_PILOT_ERR indicates that an unexpected error occurred in the\nportfolio pilot while evaluating the quote response.\n - VALID_ACCEPT_QUOTE: VALID_ACCEPT_QUOTE indicates that the accepted quote passed all\nvalidation checks successfully." + "description": "QuoteRespStatus is an enum that represents the status of a quote response.\n\n - INVALID_ASSET_RATES: INVALID_ASSET_RATES indicates that at least one asset rate in the\nquote response is invalid.\n - INVALID_EXPIRY: INVALID_EXPIRY indicates that the expiry in the quote response is\ninvalid.\n - PRICE_ORACLE_QUERY_ERR: PRICE_ORACLE_QUERY_ERR indicates that an error occurred when querying the\nprice oracle whilst evaluating the quote response.\n - PORTFOLIO_PILOT_ERR: PORTFOLIO_PILOT_ERR indicates that an unexpected error occurred in the\nportfolio pilot while evaluating the quote response.\n - VALID_ACCEPT_QUOTE: VALID_ACCEPT_QUOTE indicates that the accepted quote passed all\nvalidation checks successfully.\n - MIN_FILL_NOT_MET: MIN_FILL_NOT_MET indicates that the minimum fill constraint was\nnot satisfiable at the accepted rate.\n - RATE_BOUND_MISS: RATE_BOUND_MISS indicates that the accepted rate violated the\nrequester's rate limit constraint.\n - FOK_NOT_VIABLE: FOK_NOT_VIABLE indicates that the FOK execution policy could\nnot be satisfied at the accepted rate." }, "portfoliopilotrpcRejectCode": { "type": "string", "enum": [ "REJECT_CODE_UNSPECIFIED", - "REJECT_CODE_PRICE_ORACLE_UNAVAILABLE" + "REJECT_CODE_PRICE_ORACLE_UNAVAILABLE", + "REJECT_CODE_MIN_FILL_NOT_MET", + "REJECT_CODE_PRICE_BOUND_MISS", + "REJECT_CODE_FOK_NOT_VIABLE" ], "default": "REJECT_CODE_UNSPECIFIED", - "description": "RejectCode represents the possible error codes that can be returned in a\nResolveRequestResponse reject result.\n\n - REJECT_CODE_UNSPECIFIED: REJECT_CODE_UNSPECIFIED indicates an unspecified error.\n - REJECT_CODE_PRICE_ORACLE_UNAVAILABLE: REJECT_CODE_PRICE_ORACLE_UNAVAILABLE indicates that pricing could not be\nprovided due to an unavailable oracle." + "description": "RejectCode represents the possible error codes that can be returned in a\nResolveRequestResponse reject result.\n\n - REJECT_CODE_UNSPECIFIED: REJECT_CODE_UNSPECIFIED indicates an unspecified error.\n - REJECT_CODE_PRICE_ORACLE_UNAVAILABLE: REJECT_CODE_PRICE_ORACLE_UNAVAILABLE indicates that pricing could not be\nprovided due to an unavailable oracle.\n - REJECT_CODE_MIN_FILL_NOT_MET: REJECT_CODE_MIN_FILL_NOT_MET indicates that the minimum fill\nconstraint was not satisfiable at the accepted rate.\n - REJECT_CODE_PRICE_BOUND_MISS: REJECT_CODE_PRICE_BOUND_MISS indicates that the accepted rate\nviolated the requester's rate limit constraint.\n - REJECT_CODE_FOK_NOT_VIABLE: REJECT_CODE_FOK_NOT_VIABLE indicates that the FOK execution\npolicy could not be satisfied at the accepted rate." }, "portfoliopilotrpcRejectErr": { "type": "object", @@ -363,6 +383,11 @@ "reject": { "$ref": "#/definitions/portfoliopilotrpcRejectErr", "description": "reject is the rejection reason for the request." + }, + "accepted_max_amount": { + "type": "string", + "format": "uint64", + "description": "accepted_max_amount is an optional fill quantity that caps the\namount the responder is willing to accept. Only meaningful when\naccept is set; 0 means no fill cap (full request max). The unit\ndepends on the request type: asset units for a buy request, msat\nfor a sell request." } }, "description": "ResolveRequestResponse is the response to a ResolveRequest call." @@ -391,6 +416,15 @@ "type": "string", "format": "byte", "description": "peer_id is the 33-byte public key of the counterparty peer." + }, + "payment_min_amount": { + "type": "string", + "format": "uint64", + "description": "payment_min_amount is an optional minimum msat amount. A value of 0\nmeans unset." + }, + "asset_rate_limit": { + "$ref": "#/definitions/portfoliopilotrpcFixedPoint", + "description": "asset_rate_limit is an optional maximum acceptable rate (asset units\nper BTC)." } }, "description": "SellRequest represents a request to sell the subject asset." diff --git a/taprpc/rfqrpc/rfq.pb.go b/taprpc/rfqrpc/rfq.pb.go index f6590578ef..f71e7d6dd2 100644 --- a/taprpc/rfqrpc/rfq.pb.go +++ b/taprpc/rfqrpc/rfq.pb.go @@ -20,6 +20,57 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ExecutionPolicy specifies how a quote request should be filled. +type ExecutionPolicy int32 + +const ( + // EXECUTION_POLICY_IOC is Immediate-Or-Cancel: accept any partial + // fill at or above the minimum threshold. This is the default. + ExecutionPolicy_EXECUTION_POLICY_IOC ExecutionPolicy = 0 + // EXECUTION_POLICY_FOK is Fill-Or-Kill: the accepted rate must + // support the full maximum amount or the quote is rejected. + ExecutionPolicy_EXECUTION_POLICY_FOK ExecutionPolicy = 1 +) + +// Enum value maps for ExecutionPolicy. +var ( + ExecutionPolicy_name = map[int32]string{ + 0: "EXECUTION_POLICY_IOC", + 1: "EXECUTION_POLICY_FOK", + } + ExecutionPolicy_value = map[string]int32{ + "EXECUTION_POLICY_IOC": 0, + "EXECUTION_POLICY_FOK": 1, + } +) + +func (x ExecutionPolicy) Enum() *ExecutionPolicy { + p := new(ExecutionPolicy) + *p = x + return p +} + +func (x ExecutionPolicy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExecutionPolicy) Descriptor() protoreflect.EnumDescriptor { + return file_rfqrpc_rfq_proto_enumTypes[0].Descriptor() +} + +func (ExecutionPolicy) Type() protoreflect.EnumType { + return &file_rfqrpc_rfq_proto_enumTypes[0] +} + +func (x ExecutionPolicy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExecutionPolicy.Descriptor instead. +func (ExecutionPolicy) EnumDescriptor() ([]byte, []int) { + return file_rfqrpc_rfq_proto_rawDescGZIP(), []int{0} +} + // QuoteRespStatus is an enum that represents the status of a quote response. type QuoteRespStatus int32 @@ -39,6 +90,15 @@ const ( // VALID_ACCEPT_QUOTE indicates that the accepted quote passed all // validation checks successfully. QuoteRespStatus_VALID_ACCEPT_QUOTE QuoteRespStatus = 4 + // MIN_FILL_NOT_MET indicates that the minimum fill constraint was + // not satisfiable at the accepted rate. + QuoteRespStatus_MIN_FILL_NOT_MET QuoteRespStatus = 5 + // RATE_BOUND_MISS indicates that the accepted rate violated the + // requester's rate limit constraint. + QuoteRespStatus_RATE_BOUND_MISS QuoteRespStatus = 6 + // FOK_NOT_VIABLE indicates that the FOK execution policy could + // not be satisfied at the accepted rate. + QuoteRespStatus_FOK_NOT_VIABLE QuoteRespStatus = 7 ) // Enum value maps for QuoteRespStatus. @@ -49,6 +109,9 @@ var ( 2: "PRICE_ORACLE_QUERY_ERR", 3: "PORTFOLIO_PILOT_ERR", 4: "VALID_ACCEPT_QUOTE", + 5: "MIN_FILL_NOT_MET", + 6: "RATE_BOUND_MISS", + 7: "FOK_NOT_VIABLE", } QuoteRespStatus_value = map[string]int32{ "INVALID_ASSET_RATES": 0, @@ -56,6 +119,9 @@ var ( "PRICE_ORACLE_QUERY_ERR": 2, "PORTFOLIO_PILOT_ERR": 3, "VALID_ACCEPT_QUOTE": 4, + "MIN_FILL_NOT_MET": 5, + "RATE_BOUND_MISS": 6, + "FOK_NOT_VIABLE": 7, } ) @@ -70,11 +136,11 @@ func (x QuoteRespStatus) String() string { } func (QuoteRespStatus) Descriptor() protoreflect.EnumDescriptor { - return file_rfqrpc_rfq_proto_enumTypes[0].Descriptor() + return file_rfqrpc_rfq_proto_enumTypes[1].Descriptor() } func (QuoteRespStatus) Type() protoreflect.EnumType { - return &file_rfqrpc_rfq_proto_enumTypes[0] + return &file_rfqrpc_rfq_proto_enumTypes[1] } func (x QuoteRespStatus) Number() protoreflect.EnumNumber { @@ -83,7 +149,7 @@ func (x QuoteRespStatus) Number() protoreflect.EnumNumber { // Deprecated: Use QuoteRespStatus.Descriptor instead. func (QuoteRespStatus) EnumDescriptor() ([]byte, []int) { - return file_rfqrpc_rfq_proto_rawDescGZIP(), []int{0} + return file_rfqrpc_rfq_proto_rawDescGZIP(), []int{1} } // RfqPolicyType indicates the type of policy of an RFQ session. @@ -119,11 +185,11 @@ func (x RfqPolicyType) String() string { } func (RfqPolicyType) Descriptor() protoreflect.EnumDescriptor { - return file_rfqrpc_rfq_proto_enumTypes[1].Descriptor() + return file_rfqrpc_rfq_proto_enumTypes[2].Descriptor() } func (RfqPolicyType) Type() protoreflect.EnumType { - return &file_rfqrpc_rfq_proto_enumTypes[1] + return &file_rfqrpc_rfq_proto_enumTypes[2] } func (x RfqPolicyType) Number() protoreflect.EnumNumber { @@ -132,7 +198,7 @@ func (x RfqPolicyType) Number() protoreflect.EnumNumber { // Deprecated: Use RfqPolicyType.Descriptor instead. func (RfqPolicyType) EnumDescriptor() ([]byte, []int) { - return file_rfqrpc_rfq_proto_rawDescGZIP(), []int{1} + return file_rfqrpc_rfq_proto_rawDescGZIP(), []int{2} } type AssetSpecifier struct { @@ -362,6 +428,18 @@ type AddAssetBuyOrderRequest struct { // This field is optional and can be left empty if no metadata is available. // The maximum length of this field is 32'768 bytes. PriceOracleMetadata string `protobuf:"bytes,7,opt,name=price_oracle_metadata,json=priceOracleMetadata,proto3" json:"price_oracle_metadata,omitempty"` + // The optional minimum amount of the asset that the provider must be + // willing to offer. If set, must be less than or equal to asset_max_amt. + // A value of 0 means unset. + AssetMinAmt uint64 `protobuf:"varint,8,opt,name=asset_min_amt,json=assetMinAmt,proto3" json:"asset_min_amt,omitempty"` + // An optional rate limit constraint expressed as a fixed-point number. + // For buy orders this is the minimum acceptable rate (asset units per + // BTC). If unset, no rate floor is enforced. + AssetRateLimit *FixedPoint `protobuf:"bytes,9,opt,name=asset_rate_limit,json=assetRateLimit,proto3" json:"asset_rate_limit,omitempty"` + // The execution policy for this order. IOC (default) accepts any + // partial fill >= min threshold. FOK requires the rate to support + // the full max amount. + ExecutionPolicy ExecutionPolicy `protobuf:"varint,10,opt,name=execution_policy,json=executionPolicy,proto3,enum=rfqrpc.ExecutionPolicy" json:"execution_policy,omitempty"` } func (x *AddAssetBuyOrderRequest) Reset() { @@ -445,6 +523,27 @@ func (x *AddAssetBuyOrderRequest) GetPriceOracleMetadata() string { return "" } +func (x *AddAssetBuyOrderRequest) GetAssetMinAmt() uint64 { + if x != nil { + return x.AssetMinAmt + } + return 0 +} + +func (x *AddAssetBuyOrderRequest) GetAssetRateLimit() *FixedPoint { + if x != nil { + return x.AssetRateLimit + } + return nil +} + +func (x *AddAssetBuyOrderRequest) GetExecutionPolicy() ExecutionPolicy { + if x != nil { + return x.ExecutionPolicy + } + return ExecutionPolicy_EXECUTION_POLICY_IOC +} + type AddAssetBuyOrderResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -577,6 +676,18 @@ type AddAssetSellOrderRequest struct { // This field is optional and can be left empty if no metadata is available. // The maximum length of this field is 32'768 bytes. PriceOracleMetadata string `protobuf:"bytes,7,opt,name=price_oracle_metadata,json=priceOracleMetadata,proto3" json:"price_oracle_metadata,omitempty"` + // The optional minimum msat amount that the responding peer must agree + // to pay. If set, must be less than or equal to payment_max_amt. + // A value of 0 means unset (units: millisats). + PaymentMinAmt uint64 `protobuf:"varint,8,opt,name=payment_min_amt,json=paymentMinAmt,proto3" json:"payment_min_amt,omitempty"` + // An optional rate limit constraint expressed as a fixed-point number. + // For sell orders this is the maximum acceptable rate (asset units per + // BTC). If unset, no rate ceiling is enforced. + AssetRateLimit *FixedPoint `protobuf:"bytes,9,opt,name=asset_rate_limit,json=assetRateLimit,proto3" json:"asset_rate_limit,omitempty"` + // The execution policy for this order. IOC (default) accepts any + // partial fill >= min threshold. FOK requires the rate to support + // the full max amount. + ExecutionPolicy ExecutionPolicy `protobuf:"varint,10,opt,name=execution_policy,json=executionPolicy,proto3,enum=rfqrpc.ExecutionPolicy" json:"execution_policy,omitempty"` } func (x *AddAssetSellOrderRequest) Reset() { @@ -660,6 +771,27 @@ func (x *AddAssetSellOrderRequest) GetPriceOracleMetadata() string { return "" } +func (x *AddAssetSellOrderRequest) GetPaymentMinAmt() uint64 { + if x != nil { + return x.PaymentMinAmt + } + return 0 +} + +func (x *AddAssetSellOrderRequest) GetAssetRateLimit() *FixedPoint { + if x != nil { + return x.AssetRateLimit + } + return nil +} + +func (x *AddAssetSellOrderRequest) GetExecutionPolicy() ExecutionPolicy { + if x != nil { + return x.ExecutionPolicy + } + return ExecutionPolicy_EXECUTION_POLICY_IOC +} + type AddAssetSellOrderResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1083,6 +1215,10 @@ type PeerAcceptedBuyQuote struct { PriceOracleMetadata string `protobuf:"bytes,8,opt,name=price_oracle_metadata,json=priceOracleMetadata,proto3" json:"price_oracle_metadata,omitempty"` // The subject asset specifier. AssetSpec *AssetSpec `protobuf:"bytes,9,opt,name=asset_spec,json=assetSpec,proto3" json:"asset_spec,omitempty"` + // accepted_max_amount is an optional negotiated fill quantity. When + // non-zero the responder accepted up to this many asset units instead + // of the full request max. + AcceptedMaxAmount uint64 `protobuf:"varint,10,opt,name=accepted_max_amount,json=acceptedMaxAmount,proto3" json:"accepted_max_amount,omitempty"` } func (x *PeerAcceptedBuyQuote) Reset() { @@ -1180,6 +1316,13 @@ func (x *PeerAcceptedBuyQuote) GetAssetSpec() *AssetSpec { return nil } +func (x *PeerAcceptedBuyQuote) GetAcceptedMaxAmount() uint64 { + if x != nil { + return x.AcceptedMaxAmount + } + return 0 +} + type PeerAcceptedSellQuote struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1213,6 +1356,10 @@ type PeerAcceptedSellQuote struct { PriceOracleMetadata string `protobuf:"bytes,8,opt,name=price_oracle_metadata,json=priceOracleMetadata,proto3" json:"price_oracle_metadata,omitempty"` // The subject asset specifier. AssetSpec *AssetSpec `protobuf:"bytes,9,opt,name=asset_spec,json=assetSpec,proto3" json:"asset_spec,omitempty"` + // accepted_max_amount is an optional negotiated fill quantity. When + // non-zero the responder accepted up to this many msat instead of the + // full request max. + AcceptedMaxAmount uint64 `protobuf:"varint,10,opt,name=accepted_max_amount,json=acceptedMaxAmount,proto3" json:"accepted_max_amount,omitempty"` } func (x *PeerAcceptedSellQuote) Reset() { @@ -1310,6 +1457,13 @@ func (x *PeerAcceptedSellQuote) GetAssetSpec() *AssetSpec { return nil } +func (x *PeerAcceptedSellQuote) GetAcceptedMaxAmount() uint64 { + if x != nil { + return x.AcceptedMaxAmount + } + return 0 +} + // InvalidQuoteResponse is a message that is returned when a quote response is // invalid or insufficient. type InvalidQuoteResponse struct { @@ -2174,7 +2328,7 @@ var file_rfqrpc_rfq_proto_rawDesc = []byte{ 0x69, 0x63, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x65, 0x66, 0x66, 0x69, 0x63, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x22, - 0xce, 0x02, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, + 0xf4, 0x03, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, @@ -2195,44 +2349,65 @@ var file_rfqrpc_rfq_proto_rawDesc = []byte{ 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x6f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x22, 0xfa, 0x01, 0x0a, 0x18, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, - 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, - 0x0e, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x50, - 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x42, 0x75, 0x79, 0x51, 0x75, - 0x6f, 0x74, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, - 0x75, 0x6f, 0x74, 0x65, 0x12, 0x43, 0x0a, 0x0d, 0x69, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, - 0x71, 0x75, 0x6f, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x66, - 0x71, 0x72, 0x70, 0x63, 0x2e, 0x49, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x51, 0x75, 0x6f, 0x74, - 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0c, 0x69, 0x6e, 0x76, - 0x61, 0x6c, 0x69, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x46, 0x0a, 0x0e, 0x72, 0x65, 0x6a, - 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, - 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, - 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xd3, 0x02, - 0x0a, 0x18, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, - 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3f, 0x0a, 0x0f, 0x61, 0x73, - 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, - 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x52, 0x0e, 0x61, 0x73, 0x73, - 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0f, 0x70, - 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x6d, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x4d, 0x61, 0x78, - 0x41, 0x6d, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x04, 0x52, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x20, 0x0a, 0x0c, 0x70, - 0x65, 0x65, 0x72, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x27, 0x0a, - 0x0f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, - 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, - 0x73, 0x73, 0x65, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x63, 0x68, 0x65, - 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x73, - 0x73, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, - 0x32, 0x0a, 0x15, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x6f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x5f, - 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, - 0x70, 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, - 0x61, 0x74, 0x61, 0x22, 0xfc, 0x01, 0x0a, 0x19, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, + 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x5f, 0x61, 0x6d, + 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x61, 0x73, 0x73, 0x65, 0x74, 0x4d, 0x69, + 0x6e, 0x41, 0x6d, 0x74, 0x12, 0x3c, 0x0a, 0x10, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, + 0x74, 0x65, 0x5f, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, + 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x78, 0x65, 0x64, 0x50, 0x6f, 0x69, + 0x6e, 0x74, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x6d, + 0x69, 0x74, 0x12, 0x42, 0x0a, 0x10, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x72, + 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x52, 0x0f, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x22, 0xfa, 0x01, 0x0a, 0x18, 0x41, 0x64, 0x64, 0x41, 0x73, + 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x0e, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, + 0x71, 0x75, 0x6f, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x66, + 0x71, 0x72, 0x70, 0x63, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, + 0x64, 0x42, 0x75, 0x79, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x61, 0x63, 0x63, + 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x43, 0x0a, 0x0d, 0x69, 0x6e, + 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x49, 0x6e, 0x76, 0x61, 0x6c, + 0x69, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, + 0x00, 0x52, 0x0c, 0x69, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, + 0x46, 0x0a, 0x0e, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x71, 0x75, 0x6f, 0x74, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, + 0x2e, 0x52, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0xfd, 0x03, 0x0a, 0x18, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, + 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x3f, 0x0a, 0x0f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, + 0x69, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x66, 0x71, 0x72, + 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x52, 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, + 0x72, 0x12, 0x26, 0x0a, 0x0f, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, + 0x5f, 0x61, 0x6d, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x70, 0x61, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x4d, 0x61, 0x78, 0x41, 0x6d, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x78, 0x70, + 0x69, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x65, 0x78, 0x70, 0x69, 0x72, + 0x79, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, + 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x50, 0x75, 0x62, + 0x4b, 0x65, 0x79, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x5f, 0x73, + 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x74, 0x69, + 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x37, 0x0a, 0x18, + 0x73, 0x6b, 0x69, 0x70, 0x5f, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, + 0x65, 0x6c, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, + 0x73, 0x6b, 0x69, 0x70, 0x41, 0x73, 0x73, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x6f, + 0x72, 0x61, 0x63, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x69, 0x63, 0x65, 0x4f, 0x72, 0x61, 0x63, 0x6c, + 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x26, 0x0a, 0x0f, 0x70, 0x61, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x5f, 0x61, 0x6d, 0x74, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x04, 0x52, 0x0d, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x4d, 0x69, 0x6e, 0x41, 0x6d, + 0x74, 0x12, 0x3c, 0x0a, 0x10, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x5f, + 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x72, 0x66, + 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x78, 0x65, 0x64, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, + 0x0e, 0x61, 0x73, 0x73, 0x65, 0x74, 0x52, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x12, + 0x42, 0x0a, 0x10, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x70, 0x6f, 0x6c, + 0x69, 0x63, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x72, 0x66, 0x71, 0x72, + 0x70, 0x63, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x52, 0x0f, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x79, 0x22, 0xfc, 0x01, 0x0a, 0x19, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0e, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x72, 0x66, 0x71, 0x72, @@ -2273,7 +2448,7 @@ var file_rfqrpc_rfq_proto_rawDesc = []byte{ 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0b, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x50, 0x75, 0x62, 0x4b, 0x65, - 0x79, 0x22, 0xe8, 0x02, 0x0a, 0x14, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, + 0x79, 0x22, 0x98, 0x03, 0x0a, 0x14, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x42, 0x75, 0x79, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x65, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x65, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, @@ -2295,7 +2470,10 @@ var file_rfqrpc_rfq_proto_rawDesc = []byte{ 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x30, 0x0a, 0x0a, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, - 0x63, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x22, 0xe0, 0x02, 0x0a, + 0x63, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x12, 0x2e, 0x0a, 0x13, + 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x11, 0x61, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x4d, 0x61, 0x78, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x90, 0x03, 0x0a, 0x15, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, 0x6c, 0x6c, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x65, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x65, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, @@ -2317,7 +2495,10 @@ var file_rfqrpc_rfq_proto_rawDesc = []byte{ 0x72, 0x61, 0x63, 0x6c, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x30, 0x0a, 0x0a, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x73, 0x70, 0x65, 0x63, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, - 0x53, 0x70, 0x65, 0x63, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x22, + 0x53, 0x70, 0x65, 0x63, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x12, + 0x2e, 0x0a, 0x13, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x78, 0x5f, + 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52, 0x11, 0x61, 0x63, + 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x78, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, @@ -2436,65 +2617,73 @@ var file_rfqrpc_rfq_proto_rawDesc = []byte{ 0x63, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x53, 0x70, 0x65, 0x63, 0x12, 0x26, 0x0a, 0x04, 0x72, 0x61, 0x74, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x69, 0x78, 0x65, 0x64, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x52, 0x04, - 0x72, 0x61, 0x74, 0x65, 0x2a, 0x8b, 0x01, 0x0a, 0x0f, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x17, 0x0a, 0x13, 0x49, 0x4e, 0x56, 0x41, - 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x53, 0x53, 0x45, 0x54, 0x5f, 0x52, 0x41, 0x54, 0x45, 0x53, 0x10, - 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x45, 0x58, 0x50, - 0x49, 0x52, 0x59, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x50, 0x52, 0x49, 0x43, 0x45, 0x5f, 0x4f, - 0x52, 0x41, 0x43, 0x4c, 0x45, 0x5f, 0x51, 0x55, 0x45, 0x52, 0x59, 0x5f, 0x45, 0x52, 0x52, 0x10, - 0x02, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x4f, 0x52, 0x54, 0x46, 0x4f, 0x4c, 0x49, 0x4f, 0x5f, 0x50, - 0x49, 0x4c, 0x4f, 0x54, 0x5f, 0x45, 0x52, 0x52, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x56, 0x41, - 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x43, 0x43, 0x45, 0x50, 0x54, 0x5f, 0x51, 0x55, 0x4f, 0x54, 0x45, - 0x10, 0x04, 0x2a, 0x47, 0x0a, 0x0d, 0x52, 0x66, 0x71, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x54, - 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x52, 0x46, 0x51, 0x5f, 0x50, 0x4f, 0x4c, 0x49, 0x43, - 0x59, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, 0x41, 0x4c, 0x45, 0x10, 0x00, 0x12, 0x1c, 0x0a, - 0x18, 0x52, 0x46, 0x51, 0x5f, 0x50, 0x4f, 0x4c, 0x49, 0x43, 0x59, 0x5f, 0x54, 0x59, 0x50, 0x45, - 0x5f, 0x50, 0x55, 0x52, 0x43, 0x48, 0x41, 0x53, 0x45, 0x10, 0x01, 0x32, 0x82, 0x05, 0x0a, 0x03, - 0x52, 0x66, 0x71, 0x12, 0x55, 0x0a, 0x10, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, - 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x1f, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, - 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, - 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, - 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x72, 0x64, + 0x72, 0x61, 0x74, 0x65, 0x2a, 0x45, 0x0a, 0x0f, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x18, 0x0a, 0x14, 0x45, 0x58, 0x45, 0x43, 0x55, + 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x50, 0x4f, 0x4c, 0x49, 0x43, 0x59, 0x5f, 0x49, 0x4f, 0x43, 0x10, + 0x00, 0x12, 0x18, 0x0a, 0x14, 0x45, 0x58, 0x45, 0x43, 0x55, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x50, + 0x4f, 0x4c, 0x49, 0x43, 0x59, 0x5f, 0x46, 0x4f, 0x4b, 0x10, 0x01, 0x2a, 0xca, 0x01, 0x0a, 0x0f, + 0x51, 0x75, 0x6f, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, + 0x17, 0x0a, 0x13, 0x49, 0x4e, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x53, 0x53, 0x45, 0x54, + 0x5f, 0x52, 0x41, 0x54, 0x45, 0x53, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, 0x49, 0x4e, 0x56, 0x41, + 0x4c, 0x49, 0x44, 0x5f, 0x45, 0x58, 0x50, 0x49, 0x52, 0x59, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, + 0x50, 0x52, 0x49, 0x43, 0x45, 0x5f, 0x4f, 0x52, 0x41, 0x43, 0x4c, 0x45, 0x5f, 0x51, 0x55, 0x45, + 0x52, 0x59, 0x5f, 0x45, 0x52, 0x52, 0x10, 0x02, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x4f, 0x52, 0x54, + 0x46, 0x4f, 0x4c, 0x49, 0x4f, 0x5f, 0x50, 0x49, 0x4c, 0x4f, 0x54, 0x5f, 0x45, 0x52, 0x52, 0x10, + 0x03, 0x12, 0x16, 0x0a, 0x12, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x5f, 0x41, 0x43, 0x43, 0x45, 0x50, + 0x54, 0x5f, 0x51, 0x55, 0x4f, 0x54, 0x45, 0x10, 0x04, 0x12, 0x14, 0x0a, 0x10, 0x4d, 0x49, 0x4e, + 0x5f, 0x46, 0x49, 0x4c, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x4d, 0x45, 0x54, 0x10, 0x05, 0x12, + 0x13, 0x0a, 0x0f, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x42, 0x4f, 0x55, 0x4e, 0x44, 0x5f, 0x4d, 0x49, + 0x53, 0x53, 0x10, 0x06, 0x12, 0x12, 0x0a, 0x0e, 0x46, 0x4f, 0x4b, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, + 0x56, 0x49, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x07, 0x2a, 0x47, 0x0a, 0x0d, 0x52, 0x66, 0x71, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x52, 0x46, 0x51, + 0x5f, 0x50, 0x4f, 0x4c, 0x49, 0x43, 0x59, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x53, 0x41, 0x4c, + 0x45, 0x10, 0x00, 0x12, 0x1c, 0x0a, 0x18, 0x52, 0x46, 0x51, 0x5f, 0x50, 0x4f, 0x4c, 0x49, 0x43, + 0x59, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x50, 0x55, 0x52, 0x43, 0x48, 0x41, 0x53, 0x45, 0x10, + 0x01, 0x32, 0x82, 0x05, 0x0a, 0x03, 0x52, 0x66, 0x71, 0x12, 0x55, 0x0a, 0x10, 0x41, 0x64, 0x64, + 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x1f, 0x2e, + 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, + 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, + 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, + 0x42, 0x75, 0x79, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x58, 0x0a, 0x11, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, + 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, + 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, + 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x11, 0x41, 0x64, - 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, + 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x12, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, - 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, - 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x11, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, - 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x12, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, - 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, - 0x66, 0x66, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x72, 0x66, - 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, - 0x6c, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, - 0x0a, 0x10, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x66, 0x66, - 0x65, 0x72, 0x12, 0x1f, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, - 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, - 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x17, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x65, - 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x73, - 0x12, 0x26, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, - 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, - 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, - 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x53, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x66, - 0x71, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4e, 0x74, 0x66, 0x6e, 0x73, 0x12, 0x25, 0x2e, 0x72, 0x66, - 0x71, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x52, 0x66, - 0x71, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4e, 0x74, 0x66, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x66, 0x71, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x58, 0x0a, 0x11, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, - 0x64, 0x69, 0x6e, 0x67, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x20, 0x2e, 0x72, 0x66, - 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x48, - 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, - 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, - 0x67, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, - 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x74, 0x61, 0x70, - 0x72, 0x6f, 0x6f, 0x74, 0x2d, 0x61, 0x73, 0x73, 0x65, 0x74, 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, - 0x70, 0x63, 0x2f, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x73, 0x65, 0x74, 0x53, 0x65, 0x6c, 0x6c, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x10, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, + 0x42, 0x75, 0x79, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x12, 0x1f, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, + 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x66, 0x66, + 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, + 0x70, 0x63, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x75, 0x79, 0x4f, 0x66, + 0x66, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x17, 0x51, + 0x75, 0x65, 0x72, 0x79, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x51, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x12, 0x26, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, + 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x65, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, + 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, + 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x50, 0x65, 0x65, + 0x72, 0x41, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x51, 0x75, 0x6f, 0x74, 0x65, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, + 0x72, 0x69, 0x62, 0x65, 0x52, 0x66, 0x71, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4e, 0x74, 0x66, 0x6e, + 0x73, 0x12, 0x25, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, + 0x72, 0x69, 0x62, 0x65, 0x52, 0x66, 0x71, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x4e, 0x74, 0x66, 0x6e, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, + 0x63, 0x2e, 0x52, 0x66, 0x71, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x12, 0x58, 0x0a, 0x11, + 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, + 0x79, 0x12, 0x20, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, + 0x72, 0x64, 0x69, 0x6e, 0x67, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, + 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x37, 0x5a, 0x35, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6c, 0x61, + 0x62, 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, 0x6f, 0x6f, 0x74, 0x2d, 0x61, 0x73, 0x73, 0x65, 0x74, + 0x73, 0x2f, 0x74, 0x61, 0x70, 0x72, 0x70, 0x63, 0x2f, 0x72, 0x66, 0x71, 0x72, 0x70, 0x63, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2509,84 +2698,89 @@ func file_rfqrpc_rfq_proto_rawDescGZIP() []byte { return file_rfqrpc_rfq_proto_rawDescData } -var file_rfqrpc_rfq_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_rfqrpc_rfq_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_rfqrpc_rfq_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_rfqrpc_rfq_proto_goTypes = []any{ - (QuoteRespStatus)(0), // 0: rfqrpc.QuoteRespStatus - (RfqPolicyType)(0), // 1: rfqrpc.RfqPolicyType - (*AssetSpecifier)(nil), // 2: rfqrpc.AssetSpecifier - (*FixedPoint)(nil), // 3: rfqrpc.FixedPoint - (*AddAssetBuyOrderRequest)(nil), // 4: rfqrpc.AddAssetBuyOrderRequest - (*AddAssetBuyOrderResponse)(nil), // 5: rfqrpc.AddAssetBuyOrderResponse - (*AddAssetSellOrderRequest)(nil), // 6: rfqrpc.AddAssetSellOrderRequest - (*AddAssetSellOrderResponse)(nil), // 7: rfqrpc.AddAssetSellOrderResponse - (*AddAssetSellOfferRequest)(nil), // 8: rfqrpc.AddAssetSellOfferRequest - (*AddAssetSellOfferResponse)(nil), // 9: rfqrpc.AddAssetSellOfferResponse - (*AddAssetBuyOfferRequest)(nil), // 10: rfqrpc.AddAssetBuyOfferRequest - (*AddAssetBuyOfferResponse)(nil), // 11: rfqrpc.AddAssetBuyOfferResponse - (*QueryPeerAcceptedQuotesRequest)(nil), // 12: rfqrpc.QueryPeerAcceptedQuotesRequest - (*AssetSpec)(nil), // 13: rfqrpc.AssetSpec - (*PeerAcceptedBuyQuote)(nil), // 14: rfqrpc.PeerAcceptedBuyQuote - (*PeerAcceptedSellQuote)(nil), // 15: rfqrpc.PeerAcceptedSellQuote - (*InvalidQuoteResponse)(nil), // 16: rfqrpc.InvalidQuoteResponse - (*RejectedQuoteResponse)(nil), // 17: rfqrpc.RejectedQuoteResponse - (*QueryPeerAcceptedQuotesResponse)(nil), // 18: rfqrpc.QueryPeerAcceptedQuotesResponse - (*SubscribeRfqEventNtfnsRequest)(nil), // 19: rfqrpc.SubscribeRfqEventNtfnsRequest - (*PeerAcceptedBuyQuoteEvent)(nil), // 20: rfqrpc.PeerAcceptedBuyQuoteEvent - (*PeerAcceptedSellQuoteEvent)(nil), // 21: rfqrpc.PeerAcceptedSellQuoteEvent - (*AcceptHtlcEvent)(nil), // 22: rfqrpc.AcceptHtlcEvent - (*RfqEvent)(nil), // 23: rfqrpc.RfqEvent - (*ForwardingHistoryRequest)(nil), // 24: rfqrpc.ForwardingHistoryRequest - (*ForwardingHistoryResponse)(nil), // 25: rfqrpc.ForwardingHistoryResponse - (*ForwardingEvent)(nil), // 26: rfqrpc.ForwardingEvent + (ExecutionPolicy)(0), // 0: rfqrpc.ExecutionPolicy + (QuoteRespStatus)(0), // 1: rfqrpc.QuoteRespStatus + (RfqPolicyType)(0), // 2: rfqrpc.RfqPolicyType + (*AssetSpecifier)(nil), // 3: rfqrpc.AssetSpecifier + (*FixedPoint)(nil), // 4: rfqrpc.FixedPoint + (*AddAssetBuyOrderRequest)(nil), // 5: rfqrpc.AddAssetBuyOrderRequest + (*AddAssetBuyOrderResponse)(nil), // 6: rfqrpc.AddAssetBuyOrderResponse + (*AddAssetSellOrderRequest)(nil), // 7: rfqrpc.AddAssetSellOrderRequest + (*AddAssetSellOrderResponse)(nil), // 8: rfqrpc.AddAssetSellOrderResponse + (*AddAssetSellOfferRequest)(nil), // 9: rfqrpc.AddAssetSellOfferRequest + (*AddAssetSellOfferResponse)(nil), // 10: rfqrpc.AddAssetSellOfferResponse + (*AddAssetBuyOfferRequest)(nil), // 11: rfqrpc.AddAssetBuyOfferRequest + (*AddAssetBuyOfferResponse)(nil), // 12: rfqrpc.AddAssetBuyOfferResponse + (*QueryPeerAcceptedQuotesRequest)(nil), // 13: rfqrpc.QueryPeerAcceptedQuotesRequest + (*AssetSpec)(nil), // 14: rfqrpc.AssetSpec + (*PeerAcceptedBuyQuote)(nil), // 15: rfqrpc.PeerAcceptedBuyQuote + (*PeerAcceptedSellQuote)(nil), // 16: rfqrpc.PeerAcceptedSellQuote + (*InvalidQuoteResponse)(nil), // 17: rfqrpc.InvalidQuoteResponse + (*RejectedQuoteResponse)(nil), // 18: rfqrpc.RejectedQuoteResponse + (*QueryPeerAcceptedQuotesResponse)(nil), // 19: rfqrpc.QueryPeerAcceptedQuotesResponse + (*SubscribeRfqEventNtfnsRequest)(nil), // 20: rfqrpc.SubscribeRfqEventNtfnsRequest + (*PeerAcceptedBuyQuoteEvent)(nil), // 21: rfqrpc.PeerAcceptedBuyQuoteEvent + (*PeerAcceptedSellQuoteEvent)(nil), // 22: rfqrpc.PeerAcceptedSellQuoteEvent + (*AcceptHtlcEvent)(nil), // 23: rfqrpc.AcceptHtlcEvent + (*RfqEvent)(nil), // 24: rfqrpc.RfqEvent + (*ForwardingHistoryRequest)(nil), // 25: rfqrpc.ForwardingHistoryRequest + (*ForwardingHistoryResponse)(nil), // 26: rfqrpc.ForwardingHistoryResponse + (*ForwardingEvent)(nil), // 27: rfqrpc.ForwardingEvent } var file_rfqrpc_rfq_proto_depIdxs = []int32{ - 2, // 0: rfqrpc.AddAssetBuyOrderRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier - 14, // 1: rfqrpc.AddAssetBuyOrderResponse.accepted_quote:type_name -> rfqrpc.PeerAcceptedBuyQuote - 16, // 2: rfqrpc.AddAssetBuyOrderResponse.invalid_quote:type_name -> rfqrpc.InvalidQuoteResponse - 17, // 3: rfqrpc.AddAssetBuyOrderResponse.rejected_quote:type_name -> rfqrpc.RejectedQuoteResponse - 2, // 4: rfqrpc.AddAssetSellOrderRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier - 15, // 5: rfqrpc.AddAssetSellOrderResponse.accepted_quote:type_name -> rfqrpc.PeerAcceptedSellQuote - 16, // 6: rfqrpc.AddAssetSellOrderResponse.invalid_quote:type_name -> rfqrpc.InvalidQuoteResponse - 17, // 7: rfqrpc.AddAssetSellOrderResponse.rejected_quote:type_name -> rfqrpc.RejectedQuoteResponse - 2, // 8: rfqrpc.AddAssetSellOfferRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier - 2, // 9: rfqrpc.AddAssetBuyOfferRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier - 3, // 10: rfqrpc.PeerAcceptedBuyQuote.ask_asset_rate:type_name -> rfqrpc.FixedPoint - 13, // 11: rfqrpc.PeerAcceptedBuyQuote.asset_spec:type_name -> rfqrpc.AssetSpec - 3, // 12: rfqrpc.PeerAcceptedSellQuote.bid_asset_rate:type_name -> rfqrpc.FixedPoint - 13, // 13: rfqrpc.PeerAcceptedSellQuote.asset_spec:type_name -> rfqrpc.AssetSpec - 0, // 14: rfqrpc.InvalidQuoteResponse.status:type_name -> rfqrpc.QuoteRespStatus - 14, // 15: rfqrpc.QueryPeerAcceptedQuotesResponse.buy_quotes:type_name -> rfqrpc.PeerAcceptedBuyQuote - 15, // 16: rfqrpc.QueryPeerAcceptedQuotesResponse.sell_quotes:type_name -> rfqrpc.PeerAcceptedSellQuote - 14, // 17: rfqrpc.PeerAcceptedBuyQuoteEvent.peer_accepted_buy_quote:type_name -> rfqrpc.PeerAcceptedBuyQuote - 15, // 18: rfqrpc.PeerAcceptedSellQuoteEvent.peer_accepted_sell_quote:type_name -> rfqrpc.PeerAcceptedSellQuote - 20, // 19: rfqrpc.RfqEvent.peer_accepted_buy_quote:type_name -> rfqrpc.PeerAcceptedBuyQuoteEvent - 21, // 20: rfqrpc.RfqEvent.peer_accepted_sell_quote:type_name -> rfqrpc.PeerAcceptedSellQuoteEvent - 22, // 21: rfqrpc.RfqEvent.accept_htlc:type_name -> rfqrpc.AcceptHtlcEvent - 2, // 22: rfqrpc.ForwardingHistoryRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier - 26, // 23: rfqrpc.ForwardingHistoryResponse.forwards:type_name -> rfqrpc.ForwardingEvent - 1, // 24: rfqrpc.ForwardingEvent.policy_type:type_name -> rfqrpc.RfqPolicyType - 13, // 25: rfqrpc.ForwardingEvent.asset_spec:type_name -> rfqrpc.AssetSpec - 3, // 26: rfqrpc.ForwardingEvent.rate:type_name -> rfqrpc.FixedPoint - 4, // 27: rfqrpc.Rfq.AddAssetBuyOrder:input_type -> rfqrpc.AddAssetBuyOrderRequest - 6, // 28: rfqrpc.Rfq.AddAssetSellOrder:input_type -> rfqrpc.AddAssetSellOrderRequest - 8, // 29: rfqrpc.Rfq.AddAssetSellOffer:input_type -> rfqrpc.AddAssetSellOfferRequest - 10, // 30: rfqrpc.Rfq.AddAssetBuyOffer:input_type -> rfqrpc.AddAssetBuyOfferRequest - 12, // 31: rfqrpc.Rfq.QueryPeerAcceptedQuotes:input_type -> rfqrpc.QueryPeerAcceptedQuotesRequest - 19, // 32: rfqrpc.Rfq.SubscribeRfqEventNtfns:input_type -> rfqrpc.SubscribeRfqEventNtfnsRequest - 24, // 33: rfqrpc.Rfq.ForwardingHistory:input_type -> rfqrpc.ForwardingHistoryRequest - 5, // 34: rfqrpc.Rfq.AddAssetBuyOrder:output_type -> rfqrpc.AddAssetBuyOrderResponse - 7, // 35: rfqrpc.Rfq.AddAssetSellOrder:output_type -> rfqrpc.AddAssetSellOrderResponse - 9, // 36: rfqrpc.Rfq.AddAssetSellOffer:output_type -> rfqrpc.AddAssetSellOfferResponse - 11, // 37: rfqrpc.Rfq.AddAssetBuyOffer:output_type -> rfqrpc.AddAssetBuyOfferResponse - 18, // 38: rfqrpc.Rfq.QueryPeerAcceptedQuotes:output_type -> rfqrpc.QueryPeerAcceptedQuotesResponse - 23, // 39: rfqrpc.Rfq.SubscribeRfqEventNtfns:output_type -> rfqrpc.RfqEvent - 25, // 40: rfqrpc.Rfq.ForwardingHistory:output_type -> rfqrpc.ForwardingHistoryResponse - 34, // [34:41] is the sub-list for method output_type - 27, // [27:34] is the sub-list for method input_type - 27, // [27:27] is the sub-list for extension type_name - 27, // [27:27] is the sub-list for extension extendee - 0, // [0:27] is the sub-list for field type_name + 3, // 0: rfqrpc.AddAssetBuyOrderRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier + 4, // 1: rfqrpc.AddAssetBuyOrderRequest.asset_rate_limit:type_name -> rfqrpc.FixedPoint + 0, // 2: rfqrpc.AddAssetBuyOrderRequest.execution_policy:type_name -> rfqrpc.ExecutionPolicy + 15, // 3: rfqrpc.AddAssetBuyOrderResponse.accepted_quote:type_name -> rfqrpc.PeerAcceptedBuyQuote + 17, // 4: rfqrpc.AddAssetBuyOrderResponse.invalid_quote:type_name -> rfqrpc.InvalidQuoteResponse + 18, // 5: rfqrpc.AddAssetBuyOrderResponse.rejected_quote:type_name -> rfqrpc.RejectedQuoteResponse + 3, // 6: rfqrpc.AddAssetSellOrderRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier + 4, // 7: rfqrpc.AddAssetSellOrderRequest.asset_rate_limit:type_name -> rfqrpc.FixedPoint + 0, // 8: rfqrpc.AddAssetSellOrderRequest.execution_policy:type_name -> rfqrpc.ExecutionPolicy + 16, // 9: rfqrpc.AddAssetSellOrderResponse.accepted_quote:type_name -> rfqrpc.PeerAcceptedSellQuote + 17, // 10: rfqrpc.AddAssetSellOrderResponse.invalid_quote:type_name -> rfqrpc.InvalidQuoteResponse + 18, // 11: rfqrpc.AddAssetSellOrderResponse.rejected_quote:type_name -> rfqrpc.RejectedQuoteResponse + 3, // 12: rfqrpc.AddAssetSellOfferRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier + 3, // 13: rfqrpc.AddAssetBuyOfferRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier + 4, // 14: rfqrpc.PeerAcceptedBuyQuote.ask_asset_rate:type_name -> rfqrpc.FixedPoint + 14, // 15: rfqrpc.PeerAcceptedBuyQuote.asset_spec:type_name -> rfqrpc.AssetSpec + 4, // 16: rfqrpc.PeerAcceptedSellQuote.bid_asset_rate:type_name -> rfqrpc.FixedPoint + 14, // 17: rfqrpc.PeerAcceptedSellQuote.asset_spec:type_name -> rfqrpc.AssetSpec + 1, // 18: rfqrpc.InvalidQuoteResponse.status:type_name -> rfqrpc.QuoteRespStatus + 15, // 19: rfqrpc.QueryPeerAcceptedQuotesResponse.buy_quotes:type_name -> rfqrpc.PeerAcceptedBuyQuote + 16, // 20: rfqrpc.QueryPeerAcceptedQuotesResponse.sell_quotes:type_name -> rfqrpc.PeerAcceptedSellQuote + 15, // 21: rfqrpc.PeerAcceptedBuyQuoteEvent.peer_accepted_buy_quote:type_name -> rfqrpc.PeerAcceptedBuyQuote + 16, // 22: rfqrpc.PeerAcceptedSellQuoteEvent.peer_accepted_sell_quote:type_name -> rfqrpc.PeerAcceptedSellQuote + 21, // 23: rfqrpc.RfqEvent.peer_accepted_buy_quote:type_name -> rfqrpc.PeerAcceptedBuyQuoteEvent + 22, // 24: rfqrpc.RfqEvent.peer_accepted_sell_quote:type_name -> rfqrpc.PeerAcceptedSellQuoteEvent + 23, // 25: rfqrpc.RfqEvent.accept_htlc:type_name -> rfqrpc.AcceptHtlcEvent + 3, // 26: rfqrpc.ForwardingHistoryRequest.asset_specifier:type_name -> rfqrpc.AssetSpecifier + 27, // 27: rfqrpc.ForwardingHistoryResponse.forwards:type_name -> rfqrpc.ForwardingEvent + 2, // 28: rfqrpc.ForwardingEvent.policy_type:type_name -> rfqrpc.RfqPolicyType + 14, // 29: rfqrpc.ForwardingEvent.asset_spec:type_name -> rfqrpc.AssetSpec + 4, // 30: rfqrpc.ForwardingEvent.rate:type_name -> rfqrpc.FixedPoint + 5, // 31: rfqrpc.Rfq.AddAssetBuyOrder:input_type -> rfqrpc.AddAssetBuyOrderRequest + 7, // 32: rfqrpc.Rfq.AddAssetSellOrder:input_type -> rfqrpc.AddAssetSellOrderRequest + 9, // 33: rfqrpc.Rfq.AddAssetSellOffer:input_type -> rfqrpc.AddAssetSellOfferRequest + 11, // 34: rfqrpc.Rfq.AddAssetBuyOffer:input_type -> rfqrpc.AddAssetBuyOfferRequest + 13, // 35: rfqrpc.Rfq.QueryPeerAcceptedQuotes:input_type -> rfqrpc.QueryPeerAcceptedQuotesRequest + 20, // 36: rfqrpc.Rfq.SubscribeRfqEventNtfns:input_type -> rfqrpc.SubscribeRfqEventNtfnsRequest + 25, // 37: rfqrpc.Rfq.ForwardingHistory:input_type -> rfqrpc.ForwardingHistoryRequest + 6, // 38: rfqrpc.Rfq.AddAssetBuyOrder:output_type -> rfqrpc.AddAssetBuyOrderResponse + 8, // 39: rfqrpc.Rfq.AddAssetSellOrder:output_type -> rfqrpc.AddAssetSellOrderResponse + 10, // 40: rfqrpc.Rfq.AddAssetSellOffer:output_type -> rfqrpc.AddAssetSellOfferResponse + 12, // 41: rfqrpc.Rfq.AddAssetBuyOffer:output_type -> rfqrpc.AddAssetBuyOfferResponse + 19, // 42: rfqrpc.Rfq.QueryPeerAcceptedQuotes:output_type -> rfqrpc.QueryPeerAcceptedQuotesResponse + 24, // 43: rfqrpc.Rfq.SubscribeRfqEventNtfns:output_type -> rfqrpc.RfqEvent + 26, // 44: rfqrpc.Rfq.ForwardingHistory:output_type -> rfqrpc.ForwardingHistoryResponse + 38, // [38:45] is the sub-list for method output_type + 31, // [31:38] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_rfqrpc_rfq_proto_init() } @@ -2922,7 +3116,7 @@ func file_rfqrpc_rfq_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_rfqrpc_rfq_proto_rawDesc, - NumEnums: 2, + NumEnums: 3, NumMessages: 25, NumExtensions: 0, NumServices: 1, diff --git a/taprpc/rfqrpc/rfq.proto b/taprpc/rfqrpc/rfq.proto index 4d612dd10b..c0436bba97 100644 --- a/taprpc/rfqrpc/rfq.proto +++ b/taprpc/rfqrpc/rfq.proto @@ -127,6 +127,17 @@ message FixedPoint { uint32 scale = 2; } +// ExecutionPolicy specifies how a quote request should be filled. +enum ExecutionPolicy { + // EXECUTION_POLICY_IOC is Immediate-Or-Cancel: accept any partial + // fill at or above the minimum threshold. This is the default. + EXECUTION_POLICY_IOC = 0; + + // EXECUTION_POLICY_FOK is Fill-Or-Kill: the accepted rate must + // support the full maximum amount or the quote is rejected. + EXECUTION_POLICY_FOK = 1; +} + message AddAssetBuyOrderRequest { // asset_specifier is the subject asset. AssetSpecifier asset_specifier = 1; @@ -160,6 +171,21 @@ message AddAssetBuyOrderRequest { // This field is optional and can be left empty if no metadata is available. // The maximum length of this field is 32'768 bytes. string price_oracle_metadata = 7; + + // The optional minimum amount of the asset that the provider must be + // willing to offer. If set, must be less than or equal to asset_max_amt. + // A value of 0 means unset. + uint64 asset_min_amt = 8; + + // An optional rate limit constraint expressed as a fixed-point number. + // For buy orders this is the minimum acceptable rate (asset units per + // BTC). If unset, no rate floor is enforced. + FixedPoint asset_rate_limit = 9; + + // The execution policy for this order. IOC (default) accepts any + // partial fill >= min threshold. FOK requires the rate to support + // the full max amount. + ExecutionPolicy execution_policy = 10; } message AddAssetBuyOrderResponse { @@ -211,6 +237,21 @@ message AddAssetSellOrderRequest { // This field is optional and can be left empty if no metadata is available. // The maximum length of this field is 32'768 bytes. string price_oracle_metadata = 7; + + // The optional minimum msat amount that the responding peer must agree + // to pay. If set, must be less than or equal to payment_max_amt. + // A value of 0 means unset (units: millisats). + uint64 payment_min_amt = 8; + + // An optional rate limit constraint expressed as a fixed-point number. + // For sell orders this is the maximum acceptable rate (asset units per + // BTC). If unset, no rate ceiling is enforced. + FixedPoint asset_rate_limit = 9; + + // The execution policy for this order. IOC (default) accepts any + // partial fill >= min threshold. FOK requires the rate to support + // the full max amount. + ExecutionPolicy execution_policy = 10; } message AddAssetSellOrderResponse { @@ -303,6 +344,11 @@ message PeerAcceptedBuyQuote { // The subject asset specifier. AssetSpec asset_spec = 9; + + // accepted_max_amount is an optional negotiated fill quantity. When + // non-zero the responder accepted up to this many asset units instead + // of the full request max. + uint64 accepted_max_amount = 10; } message PeerAcceptedSellQuote { @@ -342,6 +388,11 @@ message PeerAcceptedSellQuote { // The subject asset specifier. AssetSpec asset_spec = 9; + + // accepted_max_amount is an optional negotiated fill quantity. When + // non-zero the responder accepted up to this many msat instead of the + // full request max. + uint64 accepted_max_amount = 10; } // QuoteRespStatus is an enum that represents the status of a quote response. @@ -365,6 +416,18 @@ enum QuoteRespStatus { // VALID_ACCEPT_QUOTE indicates that the accepted quote passed all // validation checks successfully. VALID_ACCEPT_QUOTE = 4; + + // MIN_FILL_NOT_MET indicates that the minimum fill constraint was + // not satisfiable at the accepted rate. + MIN_FILL_NOT_MET = 5; + + // RATE_BOUND_MISS indicates that the accepted rate violated the + // requester's rate limit constraint. + RATE_BOUND_MISS = 6; + + // FOK_NOT_VIABLE indicates that the FOK execution policy could + // not be satisfied at the accepted rate. + FOK_NOT_VIABLE = 7; } // InvalidQuoteResponse is a message that is returned when a quote response is diff --git a/taprpc/rfqrpc/rfq.swagger.json b/taprpc/rfqrpc/rfq.swagger.json index c830910d0b..b4ffd55a49 100644 --- a/taprpc/rfqrpc/rfq.swagger.json +++ b/taprpc/rfqrpc/rfq.swagger.json @@ -584,6 +584,19 @@ "price_oracle_metadata": { "type": "string", "description": "An optional text field that can be used to provide additional metadata\nabout the buy order to the price oracle. This can include information\nabout the wallet end user that initiated the transaction, or any\nauthentication information that the price oracle can use to give out a\nmore accurate (or discount) asset rate. Though not verified or enforced\nby tapd, the suggested format for this field is a JSON string.\nThis field is optional and can be left empty if no metadata is available.\nThe maximum length of this field is 32'768 bytes." + }, + "asset_min_amt": { + "type": "string", + "format": "uint64", + "description": "The optional minimum amount of the asset that the provider must be\nwilling to offer. If set, must be less than or equal to asset_max_amt.\nA value of 0 means unset." + }, + "asset_rate_limit": { + "$ref": "#/definitions/rfqrpcFixedPoint", + "description": "An optional rate limit constraint expressed as a fixed-point number.\nFor buy orders this is the minimum acceptable rate (asset units per\nBTC). If unset, no rate floor is enforced." + }, + "execution_policy": { + "$ref": "#/definitions/rfqrpcExecutionPolicy", + "description": "The execution policy for this order. IOC (default) accepts any\npartial fill \u003e= min threshold. FOK requires the rate to support\nthe full max amount." } } }, @@ -669,6 +682,19 @@ "price_oracle_metadata": { "type": "string", "description": "An optional text field that can be used to provide additional metadata\nabout the sell order to the price oracle. This can include information\nabout the wallet end user that initiated the transaction, or any\nauthentication information that the price oracle can use to give out a\nmore accurate (or discount) asset rate. Though not verified or enforced\nby tapd, the suggested format for this field is a JSON string.\nThis field is optional and can be left empty if no metadata is available.\nThe maximum length of this field is 32'768 bytes." + }, + "payment_min_amt": { + "type": "string", + "format": "uint64", + "description": "The optional minimum msat amount that the responding peer must agree\nto pay. If set, must be less than or equal to payment_max_amt.\nA value of 0 means unset (units: millisats)." + }, + "asset_rate_limit": { + "$ref": "#/definitions/rfqrpcFixedPoint", + "description": "An optional rate limit constraint expressed as a fixed-point number.\nFor sell orders this is the maximum acceptable rate (asset units per\nBTC). If unset, no rate ceiling is enforced." + }, + "execution_policy": { + "$ref": "#/definitions/rfqrpcExecutionPolicy", + "description": "The execution policy for this order. IOC (default) accepts any\npartial fill \u003e= min threshold. FOK requires the rate to support\nthe full max amount." } } }, @@ -774,6 +800,15 @@ } } }, + "rfqrpcExecutionPolicy": { + "type": "string", + "enum": [ + "EXECUTION_POLICY_IOC", + "EXECUTION_POLICY_FOK" + ], + "default": "EXECUTION_POLICY_IOC", + "description": "ExecutionPolicy specifies how a quote request should be filled.\n\n - EXECUTION_POLICY_IOC: EXECUTION_POLICY_IOC is Immediate-Or-Cancel: accept any partial\nfill at or above the minimum threshold. This is the default.\n - EXECUTION_POLICY_FOK: EXECUTION_POLICY_FOK is Fill-Or-Kill: the accepted rate must\nsupport the full maximum amount or the quote is rejected." + }, "rfqrpcFixedPoint": { "type": "object", "properties": { @@ -940,6 +975,11 @@ "asset_spec": { "$ref": "#/definitions/rfqrpcAssetSpec", "description": "The subject asset specifier." + }, + "accepted_max_amount": { + "type": "string", + "format": "uint64", + "description": "accepted_max_amount is an optional negotiated fill quantity. When\nnon-zero the responder accepted up to this many asset units instead\nof the full request max." } } }, @@ -1000,6 +1040,11 @@ "asset_spec": { "$ref": "#/definitions/rfqrpcAssetSpec", "description": "The subject asset specifier." + }, + "accepted_max_amount": { + "type": "string", + "format": "uint64", + "description": "accepted_max_amount is an optional negotiated fill quantity. When\nnon-zero the responder accepted up to this many msat instead of the\nfull request max." } } }, @@ -1045,10 +1090,13 @@ "INVALID_EXPIRY", "PRICE_ORACLE_QUERY_ERR", "PORTFOLIO_PILOT_ERR", - "VALID_ACCEPT_QUOTE" + "VALID_ACCEPT_QUOTE", + "MIN_FILL_NOT_MET", + "RATE_BOUND_MISS", + "FOK_NOT_VIABLE" ], "default": "INVALID_ASSET_RATES", - "description": "QuoteRespStatus is an enum that represents the status of a quote response.\n\n - INVALID_ASSET_RATES: INVALID_ASSET_RATES indicates that at least one asset rate in the\nquote response is invalid.\n - INVALID_EXPIRY: INVALID_EXPIRY indicates that the expiry in the quote response is\ninvalid.\n - PRICE_ORACLE_QUERY_ERR: PRICE_ORACLE_QUERY_ERR indicates that an error occurred when querying the\nprice oracle whilst evaluating the quote response.\n - PORTFOLIO_PILOT_ERR: PORTFOLIO_PILOT_ERR indicates that an unexpected error occurred in the\nportfolio pilot while evaluating the quote response.\n - VALID_ACCEPT_QUOTE: VALID_ACCEPT_QUOTE indicates that the accepted quote passed all\nvalidation checks successfully." + "description": "QuoteRespStatus is an enum that represents the status of a quote response.\n\n - INVALID_ASSET_RATES: INVALID_ASSET_RATES indicates that at least one asset rate in the\nquote response is invalid.\n - INVALID_EXPIRY: INVALID_EXPIRY indicates that the expiry in the quote response is\ninvalid.\n - PRICE_ORACLE_QUERY_ERR: PRICE_ORACLE_QUERY_ERR indicates that an error occurred when querying the\nprice oracle whilst evaluating the quote response.\n - PORTFOLIO_PILOT_ERR: PORTFOLIO_PILOT_ERR indicates that an unexpected error occurred in the\nportfolio pilot while evaluating the quote response.\n - VALID_ACCEPT_QUOTE: VALID_ACCEPT_QUOTE indicates that the accepted quote passed all\nvalidation checks successfully.\n - MIN_FILL_NOT_MET: MIN_FILL_NOT_MET indicates that the minimum fill constraint was\nnot satisfiable at the accepted rate.\n - RATE_BOUND_MISS: RATE_BOUND_MISS indicates that the accepted rate violated the\nrequester's rate limit constraint.\n - FOK_NOT_VIABLE: FOK_NOT_VIABLE indicates that the FOK execution policy could\nnot be satisfied at the accepted rate." }, "rfqrpcRejectedQuoteResponse": { "type": "object", diff --git a/taprpc/tapchannelrpc/tapchannel.swagger.json b/taprpc/tapchannelrpc/tapchannel.swagger.json index 2f22c78fb6..b206f760ce 100644 --- a/taprpc/tapchannelrpc/tapchannel.swagger.json +++ b/taprpc/tapchannelrpc/tapchannel.swagger.json @@ -1319,6 +1319,11 @@ "asset_spec": { "$ref": "#/definitions/rfqrpcAssetSpec", "description": "The subject asset specifier." + }, + "accepted_max_amount": { + "type": "string", + "format": "uint64", + "description": "accepted_max_amount is an optional negotiated fill quantity. When\nnon-zero the responder accepted up to this many asset units instead\nof the full request max." } } }, @@ -1365,6 +1370,11 @@ "asset_spec": { "$ref": "#/definitions/rfqrpcAssetSpec", "description": "The subject asset specifier." + }, + "accepted_max_amount": { + "type": "string", + "format": "uint64", + "description": "accepted_max_amount is an optional negotiated fill quantity. When\nnon-zero the responder accepted up to this many msat instead of the\nfull request max." } } },