diff --git a/rpc/swapclientrpc/swap_client.pb.go b/rpc/swapclientrpc/swap_client.pb.go index 992001630..547aaded8 100644 --- a/rpc/swapclientrpc/swap_client.pb.go +++ b/rpc/swapclientrpc/swap_client.pb.go @@ -472,8 +472,15 @@ type QuotePayResponse struct { // larger than that cap. ExceedsMaxFee bool `protobuf:"varint,7,opt,name=exceeds_max_fee,json=exceedsMaxFee,proto3" json:"exceeds_max_fee,omitempty"` CreditQuote *CreditQuote `protobuf:"bytes,8,opt,name=credit_quote,json=creditQuote,proto3" json:"credit_quote,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // server_fee_sat is the service fee retained by the swap server. + ServerFeeSat uint64 `protobuf:"varint,9,opt,name=server_fee_sat,json=serverFeeSat,proto3" json:"server_fee_sat,omitempty"` + // estimated_routing_fee_sat is the current whole-satoshi route estimate. + EstimatedRoutingFeeSat uint64 `protobuf:"varint,10,opt,name=estimated_routing_fee_sat,json=estimatedRoutingFeeSat,proto3" json:"estimated_routing_fee_sat,omitempty"` + // routing_fee_budget_sat is the client-funded Lightning routing allowance + // that would be charged into the vHTLC as part of fee_sat. + RoutingFeeBudgetSat uint64 `protobuf:"varint,11,opt,name=routing_fee_budget_sat,json=routingFeeBudgetSat,proto3" json:"routing_fee_budget_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *QuotePayResponse) Reset() { @@ -562,6 +569,27 @@ func (x *QuotePayResponse) GetCreditQuote() *CreditQuote { return nil } +func (x *QuotePayResponse) GetServerFeeSat() uint64 { + if x != nil { + return x.ServerFeeSat + } + return 0 +} + +func (x *QuotePayResponse) GetEstimatedRoutingFeeSat() uint64 { + if x != nil { + return x.EstimatedRoutingFeeSat + } + return 0 +} + +func (x *QuotePayResponse) GetRoutingFeeBudgetSat() uint64 { + if x != nil { + return x.RoutingFeeBudgetSat + } + return 0 +} + type StartPayRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // invoice is the BOLT-11 Lightning invoice to pay. @@ -2031,8 +2059,13 @@ type SwapSummary struct { // available_credit_sat is the balance considered when the receive route was // planned. AvailableCreditSat uint64 `protobuf:"varint,26,opt,name=available_credit_sat,json=availableCreditSat,proto3" json:"available_credit_sat,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // server_fee_sat is the service fee retained by the swap server. + ServerFeeSat uint64 `protobuf:"varint,27,opt,name=server_fee_sat,json=serverFeeSat,proto3" json:"server_fee_sat,omitempty"` + // routing_fee_budget_sat is the client-funded Lightning routing + // allowance charged into the vHTLC as part of fee_sat. + RoutingFeeBudgetSat uint64 `protobuf:"varint,28,opt,name=routing_fee_budget_sat,json=routingFeeBudgetSat,proto3" json:"routing_fee_budget_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SwapSummary) Reset() { @@ -2247,6 +2280,20 @@ func (x *SwapSummary) GetAvailableCreditSat() uint64 { return 0 } +func (x *SwapSummary) GetServerFeeSat() uint64 { + if x != nil { + return x.ServerFeeSat + } + return 0 +} + +func (x *SwapSummary) GetRoutingFeeBudgetSat() uint64 { + if x != nil { + return x.RoutingFeeBudgetSat + } + return 0 +} + var File_swap_client_proto protoreflect.FileDescriptor const file_swap_client_proto_rawDesc = "" + @@ -2255,7 +2302,7 @@ const file_swap_client_proto_rawDesc = "" + "\x0fQuotePayRequest\x12\x18\n" + "\ainvoice\x18\x01 \x01(\tR\ainvoice\x12\x1e\n" + "\vmax_fee_sat\x18\x02 \x01(\x04R\tmaxFeeSat\x12$\n" + - "\x0emax_credit_sat\x18\x03 \x01(\x04R\fmaxCreditSat\"\xf6\x02\n" + + "\x0emax_credit_sat\x18\x03 \x01(\x04R\fmaxCreditSat\"\x8c\x04\n" + "\x10QuotePayResponse\x12!\n" + "\fpayment_hash\x18\x01 \x01(\tR\vpaymentHash\x12,\n" + "\x12invoice_amount_sat\x18\x02 \x01(\x04R\x10invoiceAmountSat\x12\x1d\n" + @@ -2265,7 +2312,11 @@ const file_swap_client_proto_rawDesc = "" + "\x0fsettlement_type\x18\x05 \x01(\x0e2!.swapclientrpc.SwapSettlementTypeR\x0esettlementType\x12&\n" + "\x0fexpires_at_unix\x18\x06 \x01(\x03R\rexpiresAtUnix\x12&\n" + "\x0fexceeds_max_fee\x18\a \x01(\bR\rexceedsMaxFee\x12=\n" + - "\fcredit_quote\x18\b \x01(\v2\x1a.swapclientrpc.CreditQuoteR\vcreditQuote\"\x9a\x01\n" + + "\fcredit_quote\x18\b \x01(\v2\x1a.swapclientrpc.CreditQuoteR\vcreditQuote\x12$\n" + + "\x0eserver_fee_sat\x18\t \x01(\x04R\fserverFeeSat\x129\n" + + "\x19estimated_routing_fee_sat\x18\n" + + " \x01(\x04R\x16estimatedRoutingFeeSat\x123\n" + + "\x16routing_fee_budget_sat\x18\v \x01(\x04R\x13routingFeeBudgetSat\"\x9a\x01\n" + "\x0fStartPayRequest\x12\x18\n" + "\ainvoice\x18\x01 \x01(\tR\ainvoice\x12\x1e\n" + "\vmax_fee_sat\x18\x02 \x01(\x04R\tmaxFeeSat\x12'\n" + @@ -2375,7 +2426,7 @@ const file_swap_client_proto_rawDesc = "" + "\x10include_existing\x18\x01 \x01(\bR\x0fincludeExisting\x12!\n" + "\fpending_only\x18\x02 \x01(\bR\vpendingOnly\"H\n" + "\x16SubscribeSwapsResponse\x12.\n" + - "\x04swap\x18\x01 \x01(\v2\x1a.swapclientrpc.SwapSummaryR\x04swap\"\xca\b\n" + + "\x04swap\x18\x01 \x01(\v2\x1a.swapclientrpc.SwapSummaryR\x04swap\"\xa5\t\n" + "\vSwapSummary\x12:\n" + "\tdirection\x18\x01 \x01(\x0e2\x1c.swapclientrpc.SwapDirectionR\tdirection\x12!\n" + "\fpayment_hash\x18\x02 \x01(\tR\vpaymentHash\x12.\n" + @@ -2404,7 +2455,9 @@ const file_swap_client_proto_rawDesc = "" + "\x14requested_amount_sat\x18\x17 \x01(\x04R\x12requestedAmountSat\x12.\n" + "\x13attached_credit_sat\x18\x18 \x01(\x04R\x11attachedCreditSat\x12$\n" + "\x0edust_limit_sat\x18\x19 \x01(\x04R\fdustLimitSat\x120\n" + - "\x14available_credit_sat\x18\x1a \x01(\x04R\x12availableCreditSat*c\n" + + "\x14available_credit_sat\x18\x1a \x01(\x04R\x12availableCreditSat\x12$\n" + + "\x0eserver_fee_sat\x18\x1b \x01(\x04R\fserverFeeSat\x123\n" + + "\x16routing_fee_budget_sat\x18\x1c \x01(\x04R\x13routingFeeBudgetSat*c\n" + "\rSwapDirection\x12\x1e\n" + "\x1aSWAP_DIRECTION_UNSPECIFIED\x10\x00\x12\x16\n" + "\x12SWAP_DIRECTION_PAY\x10\x01\x12\x1a\n" + diff --git a/rpc/swapclientrpc/swap_client.proto b/rpc/swapclientrpc/swap_client.proto index cfa29d977..2e1f6bc4d 100644 --- a/rpc/swapclientrpc/swap_client.proto +++ b/rpc/swapclientrpc/swap_client.proto @@ -144,6 +144,16 @@ message QuotePayResponse { bool exceeds_max_fee = 7; CreditQuote credit_quote = 8; + + // server_fee_sat is the service fee retained by the swap server. + uint64 server_fee_sat = 9; + + // estimated_routing_fee_sat is the current whole-satoshi route estimate. + uint64 estimated_routing_fee_sat = 10; + + // routing_fee_budget_sat is the client-funded Lightning routing allowance + // that would be charged into the vHTLC as part of fee_sat. + uint64 routing_fee_budget_sat = 11; } message StartPayRequest { @@ -429,4 +439,11 @@ message SwapSummary { // available_credit_sat is the balance considered when the receive route was // planned. uint64 available_credit_sat = 26; + + // server_fee_sat is the service fee retained by the swap server. + uint64 server_fee_sat = 27; + + // routing_fee_budget_sat is the client-funded Lightning routing + // allowance charged into the vHTLC as part of fee_sat. + uint64 routing_fee_budget_sat = 28; } diff --git a/rpc/wavewalletrpc/wallet.pb.go b/rpc/wavewalletrpc/wallet.pb.go index ae4997fa4..2638434b1 100644 --- a/rpc/wavewalletrpc/wallet.pb.go +++ b/rpc/wavewalletrpc/wallet.pb.go @@ -1221,8 +1221,15 @@ type PrepareSendResponse struct { // credit_preview is populated when the invoice send will or can use // sat-native server credits. CreditPreview *CreditPreview `protobuf:"bytes,15,opt,name=credit_preview,json=creditPreview,proto3" json:"credit_preview,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // server_fee_sat is the service fee retained by the swap server. + ServerFeeSat uint64 `protobuf:"varint,16,opt,name=server_fee_sat,json=serverFeeSat,proto3" json:"server_fee_sat,omitempty"` + // estimated_routing_fee_sat is the current whole-satoshi route estimate. + EstimatedRoutingFeeSat uint64 `protobuf:"varint,17,opt,name=estimated_routing_fee_sat,json=estimatedRoutingFeeSat,proto3" json:"estimated_routing_fee_sat,omitempty"` + // routing_fee_budget_sat is the client-funded Lightning routing allowance + // that Send would charge into the vHTLC as part of fee_sat. + RoutingFeeBudgetSat uint64 `protobuf:"varint,18,opt,name=routing_fee_budget_sat,json=routingFeeBudgetSat,proto3" json:"routing_fee_budget_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PrepareSendResponse) Reset() { @@ -1360,6 +1367,27 @@ func (x *PrepareSendResponse) GetCreditPreview() *CreditPreview { return nil } +func (x *PrepareSendResponse) GetServerFeeSat() uint64 { + if x != nil { + return x.ServerFeeSat + } + return 0 +} + +func (x *PrepareSendResponse) GetEstimatedRoutingFeeSat() uint64 { + if x != nil { + return x.EstimatedRoutingFeeSat + } + return 0 +} + +func (x *PrepareSendResponse) GetRoutingFeeBudgetSat() uint64 { + if x != nil { + return x.RoutingFeeBudgetSat + } + return 0 +} + // SendRequest dispatches a previously prepared send by consuming the // single-use intent token returned by PrepareSend. type SendRequest struct { @@ -5715,7 +5743,7 @@ const file_wallet_proto_rawDesc = "" + "\x04note\x18\x04 \x01(\tR\x04note\x12\x1e\n" + "\vmax_fee_sat\x18\x05 \x01(\x04R\tmaxFeeSat\x12\x1b\n" + "\tsweep_all\x18\x06 \x01(\bR\bsweepAllB\r\n" + - "\vdestination\"\xb9\x05\n" + + "\vdestination\"\xcf\x06\n" + "\x13PrepareSendResponse\x12$\n" + "\x0esend_intent_id\x18\x01 \x01(\tR\fsendIntentId\x12\x1d\n" + "\n" + @@ -5733,7 +5761,10 @@ const file_wallet_proto_rawDesc = "" + "\x0fexpires_at_unix\x18\f \x01(\x03R\rexpiresAtUnix\x12-\n" + "\x12selected_outpoints\x18\r \x03(\tR\x11selectedOutpoints\x12\x18\n" + "\awarning\x18\x0e \x01(\tR\awarning\x12C\n" + - "\x0ecredit_preview\x18\x0f \x01(\v2\x1c.wavewalletrpc.CreditPreviewR\rcreditPreview\"3\n" + + "\x0ecredit_preview\x18\x0f \x01(\v2\x1c.wavewalletrpc.CreditPreviewR\rcreditPreview\x12$\n" + + "\x0eserver_fee_sat\x18\x10 \x01(\x04R\fserverFeeSat\x129\n" + + "\x19estimated_routing_fee_sat\x18\x11 \x01(\x04R\x16estimatedRoutingFeeSat\x123\n" + + "\x16routing_fee_budget_sat\x18\x12 \x01(\x04R\x13routingFeeBudgetSat\"3\n" + "\vSendRequest\x12$\n" + "\x0esend_intent_id\x18\x01 \x01(\tR\fsendIntentId\"l\n" + "\fSendResponse\x120\n" + diff --git a/rpc/wavewalletrpc/wallet.proto b/rpc/wavewalletrpc/wallet.proto index 309c9d659..bc8aaf03c 100644 --- a/rpc/wavewalletrpc/wallet.proto +++ b/rpc/wavewalletrpc/wallet.proto @@ -426,6 +426,16 @@ message PrepareSendResponse { // credit_preview is populated when the invoice send will or can use // sat-native server credits. CreditPreview credit_preview = 15; + + // server_fee_sat is the service fee retained by the swap server. + uint64 server_fee_sat = 16; + + // estimated_routing_fee_sat is the current whole-satoshi route estimate. + uint64 estimated_routing_fee_sat = 17; + + // routing_fee_budget_sat is the client-funded Lightning routing allowance + // that Send would charge into the vHTLC as part of fee_sat. + uint64 routing_fee_budget_sat = 18; } // SendRequest dispatches a previously prepared send by consuming the diff --git a/sdk/swaps/client.go b/sdk/swaps/client.go index 63db26795..22d655de8 100644 --- a/sdk/swaps/client.go +++ b/sdk/swaps/client.go @@ -61,6 +61,10 @@ type CreditQuote struct { ArkFundingSat uint64 } +// AllAvailableCredit authorizes the server to consider the caller's complete +// available credit balance. +const AllAvailableCredit = ^uint64(0) + // CreditFundingSource identifies how value enters a credit account. type CreditFundingSource string @@ -191,6 +195,13 @@ type SwapSummary struct { // FeeSat is the negotiated swap-server fee in satoshis when known. FeeSat uint64 + // ServerFeeSat is the service fee retained by the swap server. + ServerFeeSat uint64 + + // RoutingFeeBudgetSat is the client-funded Lightning routing + // allowance included in FeeSat. + RoutingFeeBudgetSat uint64 + // PayerFeeMsat is the payer-paid Lightning route fee quoted for // receive swaps. It is not deducted from AmountSat. PayerFeeMsat uint64 @@ -622,6 +633,13 @@ type InSwapConfig struct { // for this swap. FeeSat uint64 + // ServerFeeSat is the service fee retained by the swap server. + ServerFeeSat uint64 + + // RoutingFeeBudgetSat is the client-funded Lightning routing + // allowance included in FeeSat. + RoutingFeeBudgetSat uint64 + // ServerPubkey is the swap server's public key for this swap // instance. ServerPubkey *btcec.PublicKey @@ -664,6 +682,17 @@ type InSwapQuote struct { // FeeSat is the fee in satoshis charged by the swap server. FeeSat uint64 + // ServerFeeSat is the service fee retained by the swap server. + ServerFeeSat uint64 + + // EstimatedRoutingFeeSat is the server's current whole-satoshi route + // estimate. + EstimatedRoutingFeeSat uint64 + + // RoutingFeeBudgetSat is the Lightning routing allowance that would be + // included in FeeSat. + RoutingFeeBudgetSat uint64 + // Expiry is the wall-clock deadline by which the quoted swap must // complete before it is considered stale. Expiry time.Time @@ -680,6 +709,17 @@ type InSwapQuote struct { CreditQuote *CreditQuote } +// InSwapOptions contains caller-controlled fee and credit limits for an +// Ark-to-Lightning payment. +type InSwapOptions struct { + // MaxFeeSat is the maximum total fee the caller accepts. + MaxFeeSat uint64 + + // MaxCreditSat is the maximum credit amount the caller authorizes. + // MaxUint64 requests a quote using all available credit. + MaxCreditSat uint64 +} + // SwapServerConn abstracts the connection to the swap server's // gRPC service. This allows the client to talk to the swap server // without importing the server module. diff --git a/sdk/swaps/grpc_conn.go b/sdk/swaps/grpc_conn.go index 909122202..6289a20e9 100644 --- a/sdk/swaps/grpc_conn.go +++ b/sdk/swaps/grpc_conn.go @@ -148,6 +148,21 @@ func (g *GRPCSwapServerConn) CreateInSwapWithCredits(ctx context.Context, invoice string, maxFeeSat uint64, clientVhtlcPubkey *btcec.PublicKey, accountPubKey []byte, maxCreditSat uint64) (*InSwapConfig, error) { + return g.CreateInSwapWithOptions( + ctx, invoice, InSwapOptions{ + MaxFeeSat: maxFeeSat, + MaxCreditSat: maxCreditSat, + }, clientVhtlcPubkey, accountPubKey, + ) +} + +// CreateInSwapWithOptions initiates one Ark-to-Lightning swap with explicit +// fee and credit limits. +func (g *GRPCSwapServerConn) CreateInSwapWithOptions(ctx context.Context, + invoice string, options InSwapOptions, + clientVhtlcPubkey *btcec.PublicKey, accountPubKey []byte) ( + *InSwapConfig, error) { + if clientVhtlcPubkey == nil { return nil, fmt.Errorf("client vHTLC pubkey must be provided") } @@ -155,11 +170,11 @@ func (g *GRPCSwapServerConn) CreateInSwapWithCredits(ctx context.Context, resp, err := g.client.CreateInSwap( ctx, &swaprpc.CreateInSwapRequest{ Invoice: invoice, - MaxFeeSat: maxFeeSat, + MaxFeeSat: options.MaxFeeSat, ClientVhtlcPubkey: clientVhtlcPubkey. SerializeCompressed(), AccountPubkey: append([]byte(nil), accountPubKey...), - MaxCreditSat: maxCreditSat, + MaxCreditSat: options.MaxCreditSat, }, ) if err != nil { @@ -183,12 +198,28 @@ func (g *GRPCSwapServerConn) QuoteInSwapWithCredits(ctx context.Context, invoice string, maxFeeSat uint64, accountPubKey []byte, maxCreditSat uint64) (*InSwapQuote, error) { + return g.QuoteInSwapWithOptions( + ctx, invoice, InSwapOptions{ + MaxFeeSat: maxFeeSat, + MaxCreditSat: maxCreditSat, + }, accountPubKey, + ) +} + +// QuoteInSwapWithOptions previews one Ark-to-Lightning swap with explicit fee +// and credit limits. +func (g *GRPCSwapServerConn) QuoteInSwapWithOptions(ctx context.Context, + invoice string, options InSwapOptions, accountPubKey []byte) ( + *InSwapQuote, error) { + resp, err := g.client.QuoteInSwap( ctx, &swaprpc.QuoteInSwapRequest{ - Invoice: invoice, - MaxFeeSat: maxFeeSat, - AccountPubkey: append([]byte(nil), accountPubKey...), - MaxCreditSat: maxCreditSat, + Invoice: invoice, + MaxFeeSat: options.MaxFeeSat, + AccountPubkey: append( + []byte(nil), accountPubKey..., + ), + MaxCreditSat: options.MaxCreditSat, }, ) if err != nil { @@ -823,13 +854,15 @@ func inSwapConfigFromProto(resp *swaprpc.CreateInSwapResponse) (*InSwapConfig, } return &InSwapConfig{ - PaymentHash: paymentHash, - AmountSat: 0, - FeeSat: resp.GetFeeSat(), - Expiry: expiryTime, - SettlementType: settlementType, - CreditQuote: creditQuote, - Preimage: &preimage, + PaymentHash: paymentHash, + AmountSat: 0, + FeeSat: resp.GetFeeSat(), + ServerFeeSat: resp.GetServerFeeSat(), + RoutingFeeBudgetSat: resp.GetRoutingFeeBudgetSat(), + Expiry: expiryTime, + SettlementType: settlementType, + CreditQuote: creditQuote, + Preimage: &preimage, }, nil } @@ -856,14 +889,16 @@ func inSwapConfigFromProto(resp *swaprpc.CreateInSwapResponse) (*InSwapConfig, } return &InSwapConfig{ - PaymentHash: paymentHash, - AmountSat: int64(resp.GetAmountSat()), - FeeSat: resp.GetFeeSat(), - ServerPubkey: serverPubkey, - VHTLCConfig: *cfg, - Expiry: expiryTime, - SettlementType: settlementType, - CreditQuote: creditQuote, + PaymentHash: paymentHash, + AmountSat: int64(resp.GetAmountSat()), + FeeSat: resp.GetFeeSat(), + ServerFeeSat: resp.GetServerFeeSat(), + RoutingFeeBudgetSat: resp.GetRoutingFeeBudgetSat(), + ServerPubkey: serverPubkey, + VHTLCConfig: *cfg, + Expiry: expiryTime, + SettlementType: settlementType, + CreditQuote: creditQuote, }, nil } @@ -950,14 +985,17 @@ func inSwapQuoteFromProto(resp *swaprpc.QuoteInSwapResponse) (*InSwapQuote, copy(paymentHash[:], resp.GetPaymentHash()) return &InSwapQuote{ - PaymentHash: paymentHash, - InvoiceAmountSat: resp.GetInvoiceAmountSat(), - AmountSat: resp.GetAmountSat(), - FeeSat: resp.GetFeeSat(), - Expiry: expiryTime, - SettlementType: settlementType, - ExceedsMaxFee: resp.GetExceedsMaxFee(), - CreditQuote: creditQuote, + PaymentHash: paymentHash, + InvoiceAmountSat: resp.GetInvoiceAmountSat(), + AmountSat: resp.GetAmountSat(), + FeeSat: resp.GetFeeSat(), + ServerFeeSat: resp.GetServerFeeSat(), + EstimatedRoutingFeeSat: resp.GetEstimatedRoutingFeeSat(), + RoutingFeeBudgetSat: resp.GetRoutingFeeBudgetSat(), + Expiry: expiryTime, + SettlementType: settlementType, + ExceedsMaxFee: resp.GetExceedsMaxFee(), + CreditQuote: creditQuote, }, nil } diff --git a/sdk/swaps/grpc_conn_test.go b/sdk/swaps/grpc_conn_test.go index 7440b10a3..38bad1768 100644 --- a/sdk/swaps/grpc_conn_test.go +++ b/sdk/swaps/grpc_conn_test.go @@ -2,6 +2,7 @@ package swaps import ( "context" + "errors" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -22,10 +23,32 @@ type testSwapServiceClient struct { signForfeitErr error submitForfeitErr error lastAckReq *swaprpc.AcknowledgeOutSwapHtlcRequest + lastCreateReq *swaprpc.CreateInSwapRequest + lastQuoteReq *swaprpc.QuoteInSwapRequest lastSignReq *swaprpc.SignInSwapForfeitRequest lastSubmitSigReq *swaprpc.SubmitOutSwapForfeitSignatureRequest } +// CreateInSwap records the request and returns the configured transport error. +func (c *testSwapServiceClient) CreateInSwap(_ context.Context, + req *swaprpc.CreateInSwapRequest, _ ...grpc.CallOption) ( + *swaprpc.CreateInSwapResponse, error) { + + c.lastCreateReq = req + + return nil, errors.New("stop after request") +} + +// QuoteInSwap records the request and returns the configured transport error. +func (c *testSwapServiceClient) QuoteInSwap(_ context.Context, + req *swaprpc.QuoteInSwapRequest, _ ...grpc.CallOption) ( + *swaprpc.QuoteInSwapResponse, error) { + + c.lastQuoteReq = req + + return nil, errors.New("stop after request") +} + func (c *testSwapServiceClient) AuthorizeInSwapRefund(context.Context, *swaprpc.AuthorizeInSwapRefundRequest, ...grpc.CallOption) ( *swaprpc.AuthorizeInSwapRefundResponse, error) { @@ -88,6 +111,45 @@ func testForfeitSignaturePayload() *ForfeitSignaturePayload { } } +// TestInSwapOptionsMapToRPC verifies total fee and credit caps are mapped to +// both in-swap RPCs. +func TestInSwapOptionsMapToRPC(t *testing.T) { + t.Parallel() + + client := &testSwapServiceClient{} + conn := &GRPCSwapServerConn{client: client} + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + + options := InSwapOptions{ + MaxFeeSat: 101, + MaxCreditSat: 47, + } + accountKey := []byte("account") + + _, err = conn.CreateInSwapWithOptions( + t.Context(), + "invoice", options, key.PubKey(), accountKey, + ) + require.ErrorContains(t, err, "CreateInSwap RPC") + require.Equal(t, options.MaxFeeSat, client.lastCreateReq.GetMaxFeeSat()) + require.Equal( + t, options.MaxCreditSat, client.lastCreateReq.GetMaxCreditSat(), + ) + require.Equal(t, accountKey, client.lastCreateReq.GetAccountPubkey()) + + _, err = conn.QuoteInSwapWithOptions( + t.Context(), + "invoice", options, accountKey, + ) + require.ErrorContains(t, err, "QuoteInSwap RPC") + require.Equal(t, options.MaxFeeSat, client.lastQuoteReq.GetMaxFeeSat()) + require.Equal( + t, options.MaxCreditSat, client.lastQuoteReq.GetMaxCreditSat(), + ) + require.Equal(t, accountKey, client.lastQuoteReq.GetAccountPubkey()) +} + // TestRouteHintPathsFromProto verifies alternative route-hint paths convert // to the SDK shape with path and hop order preserved, and that empty or // invalid paths are rejected with the offending path index. diff --git a/sdk/swaps/in_swap.go b/sdk/swaps/in_swap.go index 08c625742..ee44bff90 100644 --- a/sdk/swaps/in_swap.go +++ b/sdk/swaps/in_swap.go @@ -472,7 +472,9 @@ func (c *SwapClient) PayViaLightning(ctx context.Context, invoice string, func (c *SwapClient) StartPayViaLightning(ctx context.Context, invoice string, maxFeeSat uint64) (*PaySession, error) { - return c.StartPayViaLightningWithCredits(ctx, invoice, maxFeeSat, 0) + return c.StartPayViaLightningWithOptions(ctx, invoice, InSwapOptions{ + MaxFeeSat: maxFeeSat, + }) } // StartPayViaLightningWithCredits creates an Ark-to-Lightning pay session and @@ -481,11 +483,26 @@ func (c *SwapClient) StartPayViaLightningWithCredits(ctx context.Context, invoice string, maxFeeSat uint64, maxCreditSat uint64) (*PaySession, error) { + return c.StartPayViaLightningWithOptions(ctx, invoice, InSwapOptions{ + MaxFeeSat: maxFeeSat, + MaxCreditSat: maxCreditSat, + }) +} + +// StartPayViaLightningWithOptions creates an Ark-to-Lightning pay session +// with explicit fee and credit limits. +func (c *SwapClient) StartPayViaLightningWithOptions(ctx context.Context, + invoice string, options InSwapOptions) (*PaySession, error) { + + if err := validateInSwapOptions(options); err != nil { + return nil, err + } + session := &paySession{ client: c, invoice: invoice, - maxFeeSat: maxFeeSat, - maxCreditSat: maxCreditSat, + maxFeeSat: options.MaxFeeSat, + maxCreditSat: options.MaxCreditSat, state: PayStateCreated, } @@ -558,17 +575,33 @@ func (s *paySession) createSwap(ctx context.Context) error { return fmt.Errorf("get client pubkey: %w", err) } - var cfg *InSwapConfig + var ( + cfg *InSwapConfig + legacyFeeTerms bool + ) if server, ok := s.client.server.(interface { + CreateInSwapWithOptions(context.Context, string, InSwapOptions, + *btcec.PublicKey, []byte) (*InSwapConfig, error) + }); ok { + + cfg, err = server.CreateInSwapWithOptions( + ctx, s.invoice, InSwapOptions{ + MaxFeeSat: s.maxFeeSat, + MaxCreditSat: s.maxCreditSat, + }, clientKey, clientKey.SerializeCompressed(), + ) + } else if server, ok := s.client.server.(interface { CreateInSwapWithCredits(context.Context, string, uint64, *btcec.PublicKey, []byte, uint64) (*InSwapConfig, error) }); ok { + legacyFeeTerms = true cfg, err = server.CreateInSwapWithCredits( ctx, s.invoice, s.maxFeeSat, clientKey, clientKey.SerializeCompressed(), s.maxCreditSat, ) } else { + legacyFeeTerms = true cfg, err = s.client.server.CreateInSwap( ctx, s.invoice, s.maxFeeSat, clientKey, ) @@ -583,6 +616,9 @@ func (s *paySession) createSwap(ctx context.Context) error { cfg.SettlementType = SettlementTypeLightning } + if legacyFeeTerms { + normalizeInSwapConfigFeeTerms(cfg) + } err = validateInSwapQuote( s.invoice, s.maxFeeSat, cfg, s.client.chainParams, ) diff --git a/sdk/swaps/in_swap_quote.go b/sdk/swaps/in_swap_quote.go index 17fa6fcfb..1287aa068 100644 --- a/sdk/swaps/in_swap_quote.go +++ b/sdk/swaps/in_swap_quote.go @@ -57,7 +57,11 @@ func ceilMsatToSat(msat uint64) uint64 { func (c *SwapClient) QuotePayViaLightning(ctx context.Context, invoice string, maxFeeSat uint64) (*InSwapQuote, error) { - return c.QuotePayViaLightningWithCredits(ctx, invoice, maxFeeSat, 0) + return c.QuotePayViaLightningWithOptions( + ctx, invoice, InSwapOptions{ + MaxFeeSat: maxFeeSat, + }, + ) } // QuotePayViaLightningWithCredits previews a pay-side in-swap with optional @@ -66,37 +70,78 @@ func (c *SwapClient) QuotePayViaLightningWithCredits(ctx context.Context, invoice string, maxFeeSat uint64, maxCreditSat uint64) (*InSwapQuote, error) { + return c.QuotePayViaLightningWithOptions(ctx, invoice, InSwapOptions{ + MaxFeeSat: maxFeeSat, + MaxCreditSat: maxCreditSat, + }) +} + +// QuotePayViaLightningWithOptions previews a pay-side in-swap with explicit +// fee and credit limits. +func (c *SwapClient) QuotePayViaLightningWithOptions(ctx context.Context, + invoice string, options InSwapOptions) (*InSwapQuote, error) { + if c == nil || c.server == nil { return nil, fmt.Errorf("swap server is required") } + if err := validateInSwapOptions(options); err != nil { + return nil, err + } - var quote *InSwapQuote + var ( + quote *InSwapQuote + legacyFeeTerms bool + ) if server, ok := c.server.(interface { + QuoteInSwapWithOptions(context.Context, string, InSwapOptions, + []byte) (*InSwapQuote, error) + }); ok { + + accountKey, err := c.inSwapQuoteAccountKey(ctx) + if err != nil { + return nil, err + } + + quoted, err := server.QuoteInSwapWithOptions( + ctx, invoice, options, accountKey, + ) + if err != nil { + return nil, err + } + quote = quoted + } else if server, ok := c.server.(interface { QuoteInSwapWithCredits(context.Context, string, uint64, []byte, uint64) (*InSwapQuote, error) }); ok { - accountKey, err := c.daemon.IdentityPubKey(ctx) + legacyFeeTerms = true + accountKey, err := c.inSwapQuoteAccountKey(ctx) if err != nil { - return nil, fmt.Errorf("get credit account pubkey: %w", - err) + return nil, err } - quote, err = server.QuoteInSwapWithCredits( - ctx, invoice, maxFeeSat, - accountKey.SerializeCompressed(), maxCreditSat, + quoted, err := server.QuoteInSwapWithCredits( + ctx, invoice, options.MaxFeeSat, accountKey, + options.MaxCreditSat, ) if err != nil { return nil, err } + quote = quoted } else { + legacyFeeTerms = true var err error - quote, err = c.server.QuoteInSwap(ctx, invoice, maxFeeSat) + quote, err = c.server.QuoteInSwap( + ctx, invoice, options.MaxFeeSat, + ) if err != nil { return nil, err } } + if legacyFeeTerms { + normalizeInSwapQuoteFeeTerms(quote) + } if err := validateInSwapPreview( invoice, quote, c.chainParams, ); err != nil { @@ -106,6 +151,111 @@ func (c *SwapClient) QuotePayViaLightningWithCredits(ctx context.Context, return quote, nil } +// validateInSwapOptions rejects values that cannot be represented by durable +// SQLite and PostgreSQL BIGINT columns. +func validateInSwapOptions(options InSwapOptions) error { + switch { + case options.MaxFeeSat > maxInt64Uint: + return fmt.Errorf("max fee %d sat exceeds int64", + options.MaxFeeSat) + + case options.MaxCreditSat > maxInt64Uint && + options.MaxCreditSat != AllAvailableCredit: + return fmt.Errorf("max credit %d sat exceeds int64", + options.MaxCreditSat) + + default: + return nil + } +} + +// inSwapQuoteAccountKey returns the wallet account used to resolve any credit +// requirements attached to a quote. +func (c *SwapClient) inSwapQuoteAccountKey(ctx context.Context) ([]byte, + error) { + + if c == nil || c.daemon == nil { + return nil, fmt.Errorf("daemon is not configured") + } + + accountKey, err := c.daemon.IdentityPubKey(ctx) + if err != nil { + return nil, fmt.Errorf("get credit account pubkey: %w", err) + } + + return accountKey.SerializeCompressed(), nil +} + +// normalizeInSwapQuoteFeeTerms supplies the conservative legacy split used +// when an older server returns no decomposition for a non-zero total fee. +func normalizeInSwapQuoteFeeTerms(quote *InSwapQuote) { + if quote == nil { + return + } + + quote.ServerFeeSat, quote.RoutingFeeBudgetSat = + normalizeInSwapFeeTerms( + quote.FeeSat, quote.ServerFeeSat, + quote.RoutingFeeBudgetSat, + ) +} + +// normalizeInSwapConfigFeeTerms supplies the conservative legacy split used +// when an older server returns no decomposition for a non-zero total fee. +func normalizeInSwapConfigFeeTerms(cfg *InSwapConfig) { + if cfg == nil { + return + } + + cfg.ServerFeeSat, cfg.RoutingFeeBudgetSat = normalizeInSwapFeeTerms( + cfg.FeeSat, cfg.ServerFeeSat, cfg.RoutingFeeBudgetSat, + ) +} + +// normalizeInSwapFeeTerms treats an absent legacy decomposition as entirely +// routing-funded so the reported components still reconcile to the total fee. +func normalizeInSwapFeeTerms(feeSat, serverFeeSat, + routingFeeBudgetSat uint64) (uint64, uint64) { + + if feeSat > 0 && serverFeeSat == 0 && routingFeeBudgetSat == 0 { + return 0, feeSat + } + + return serverFeeSat, routingFeeBudgetSat +} + +// validateInSwapFeeTerms verifies the server's fee decomposition is durable +// and reconciles exactly to the accepted total fee. +func validateInSwapFeeTerms(feeSat, serverFeeSat, estimatedRoutingFeeSat, + routingFeeBudgetSat uint64) error { + + switch { + case feeSat > maxInt64Uint: + return fmt.Errorf("in-swap fee overflows int64 range") + + case serverFeeSat > maxInt64Uint: + return fmt.Errorf("in-swap server fee overflows int64 range") + + case estimatedRoutingFeeSat > maxInt64Uint: + return fmt.Errorf("in-swap estimated routing fee overflows " + + "int64 range") + + case routingFeeBudgetSat > maxInt64Uint: + return fmt.Errorf("in-swap routing fee budget overflows " + + "int64 range") + + case serverFeeSat > feeSat || + routingFeeBudgetSat != feeSat-serverFeeSat: + return fmt.Errorf("in-swap fee split does not reconcile: "+ + "service fee %d + routing fee budget %d != "+ + "total fee %d", serverFeeSat, routingFeeBudgetSat, + feeSat) + + default: + return nil + } +} + // validateInSwapPreview verifies that a server quote is bound to the caller's // invoice before the wallet renders it as a user-visible preview. func validateInSwapPreview(invoice string, quote *InSwapQuote, @@ -153,8 +303,12 @@ func validateInSwapPreview(invoice string, quote *InSwapQuote, expectedInvoiceSat) } - if quote.FeeSat > maxInt64Uint { - return fmt.Errorf("in-swap quote fee overflows int64 range") + err = validateInSwapFeeTerms( + quote.FeeSat, quote.ServerFeeSat, quote.EstimatedRoutingFeeSat, + quote.RoutingFeeBudgetSat, + ) + if err != nil { + return fmt.Errorf("invalid in-swap quote: %w", err) } if expectedInvoiceSat > maxInt64Uint-quote.FeeSat { @@ -221,8 +375,11 @@ func validateInSwapQuote(invoice string, maxFeeSat uint64, cfg *InSwapConfig, cfg.FeeSat, maxFeeSat) } - if cfg.FeeSat > maxInt64Uint { - return fmt.Errorf("in-swap fee overflows int64 range") + err = validateInSwapFeeTerms( + cfg.FeeSat, cfg.ServerFeeSat, 0, cfg.RoutingFeeBudgetSat, + ) + if err != nil { + return fmt.Errorf("invalid in-swap quote: %w", err) } if amountSat > maxInt64Uint-cfg.FeeSat { diff --git a/sdk/swaps/in_swap_test.go b/sdk/swaps/in_swap_test.go index 5d08d3740..3be06f1e3 100644 --- a/sdk/swaps/in_swap_test.go +++ b/sdk/swaps/in_swap_test.go @@ -46,6 +46,7 @@ type testInSwapServerConn struct { quoteCalls int quoteInvoice string quoteMaxFeeSat uint64 + quoteAccountKey []byte createCalls int createAccountKey []byte createMaxCreditSat uint64 @@ -136,6 +137,31 @@ func (c *testInSwapServerConn) CreateInSwapWithCredits(_ context.Context, return c.cfg, nil } +// CreateInSwapWithOptions returns the preconfigured config and records all +// caller-controlled limits. +func (c *testInSwapServerConn) CreateInSwapWithOptions(_ context.Context, + _ string, options InSwapOptions, _ *btcec.PublicKey, + accountKey []byte) (*InSwapConfig, error) { + + c.createCalls++ + c.createAccountKey = append([]byte(nil), accountKey...) + c.createMaxCreditSat = options.MaxCreditSat + + cfg := c.cfg + if cfg != nil && cfg.FeeSat > 0 && cfg.ServerFeeSat == 0 && + cfg.RoutingFeeBudgetSat == 0 { + + // Most FSM fixtures predate the additive fee split. Model a + // conforming options-capable server without duplicating the + // same routing-only split in every unrelated lifecycle test. + clone := *cfg + clone.RoutingFeeBudgetSat = clone.FeeSat + cfg = &clone + } + + return cfg, nil +} + // QuoteInSwap returns the preconfigured in-swap quote. func (c *testInSwapServerConn) QuoteInSwap(_ context.Context, invoice string, maxFeeSat uint64) (*InSwapQuote, error) { @@ -150,6 +176,23 @@ func (c *testInSwapServerConn) QuoteInSwap(_ context.Context, invoice string, return c.quote, nil } +// QuoteInSwapWithOptions returns the preconfigured quote and records all +// caller-controlled limits. +func (c *testInSwapServerConn) QuoteInSwapWithOptions(_ context.Context, + invoice string, options InSwapOptions, accountKey []byte) (*InSwapQuote, + error) { + + c.quoteCalls++ + c.quoteInvoice = invoice + c.quoteMaxFeeSat = options.MaxFeeSat + c.quoteAccountKey = append([]byte(nil), accountKey...) + if c.quoteErr != nil { + return nil, c.quoteErr + } + + return c.quote, nil +} + // AuthorizeInSwapRefund records and returns the preconfigured refund // authorization. func (c *testInSwapServerConn) AuthorizeInSwapRefund(_ context.Context, @@ -270,10 +313,12 @@ func testInSwapConfig(serverPubkey *btcec.PublicKey, preimage lntypes.Preimage, expiry time.Time) *InSwapConfig { return &InSwapConfig{ - PaymentHash: preimage.Hash(), - AmountSat: testInSwapAmountSat, - FeeSat: testInSwapFeeSat, - ServerPubkey: serverPubkey, + PaymentHash: preimage.Hash(), + AmountSat: testInSwapAmountSat, + FeeSat: testInSwapFeeSat, + ServerFeeSat: 23, + RoutingFeeBudgetSat: testInSwapFeeSat - 23, + ServerPubkey: serverPubkey, VHTLCConfig: VHTLCConfig{ RefundLocktime: 144, UnilateralClaimDelay: 12, @@ -302,23 +347,30 @@ func TestQuotePayViaLightningReturnsBoundPreview(t *testing.T) { invoice := testValidPayInvoice(t, preimage) expiry := time.Now().Add(time.Minute) + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) serverConn := &testInSwapServerConn{ quote: &InSwapQuote{ - PaymentHash: preimage.Hash(), - InvoiceAmountSat: testInSwapInvoiceSat, - AmountSat: testInSwapAmountSat, - FeeSat: testInSwapFeeSat, - Expiry: expiry, - SettlementType: SettlementTypeLightning, - ExceedsMaxFee: true, - }, - } + PaymentHash: preimage.Hash(), + InvoiceAmountSat: testInSwapInvoiceSat, + AmountSat: testInSwapAmountSat, + FeeSat: testInSwapFeeSat, + ServerFeeSat: 23, + RoutingFeeBudgetSat: testInSwapFeeSat - 23, + Expiry: expiry, + SettlementType: SettlementTypeLightning, + ExceedsMaxFee: true, + }, + } + daemonConn := &testDaemonConn{identityKey: clientPriv.PubKey()} client := configureTestPayClient( - NewSwapClient(serverConn, nil, nil, nil), + NewSwapClient(serverConn, daemonConn, nil, nil), ) - quote, err := client.QuotePayViaLightning( - t.Context(), invoice, testInSwapFeeSat-1, + quote, err := client.QuotePayViaLightningWithOptions( + t.Context(), invoice, InSwapOptions{ + MaxFeeSat: testInSwapFeeSat - 1, + }, ) require.NoError(t, err) require.Equal(t, preimage.Hash(), quote.PaymentHash) @@ -332,9 +384,142 @@ func TestQuotePayViaLightningReturnsBoundPreview(t *testing.T) { require.Equal(t, invoice, serverConn.quoteInvoice) require.Equal(t, uint64(testInSwapFeeSat-1), serverConn.quoteMaxFeeSat) + require.Equal( + t, clientPriv.PubKey().SerializeCompressed(), + serverConn.quoteAccountKey, + ) require.Zero(t, serverConn.createCalls) } +// TestQuotePayViaLightningRequiresDaemon verifies options-capable quote +// connections report a missing wallet daemon instead of dereferencing it. +func TestQuotePayViaLightningRequiresDaemon(t *testing.T) { + t.Parallel() + + preimage, err := NewPreimage() + require.NoError(t, err) + invoice := testValidPayInvoice(t, preimage) + serverConn := &testInSwapServerConn{} + client := configureTestPayClient( + NewSwapClient(serverConn, nil, nil, nil), + ) + + _, err = client.QuotePayViaLightningWithOptions( + t.Context(), invoice, InSwapOptions{}, + ) + require.ErrorContains(t, err, "daemon is not configured") + require.Zero(t, serverConn.quoteCalls) +} + +// TestQuotePayViaLightningNormalizesLegacyFeeTerms verifies older server +// interfaces remain compatible when they cannot return a fee decomposition. +func TestQuotePayViaLightningNormalizesLegacyFeeTerms(t *testing.T) { + t.Parallel() + + preimage, err := NewPreimage() + require.NoError(t, err) + invoice := testValidPayInvoice(t, preimage) + serverConn := &testInSwapServerConn{ + quote: &InSwapQuote{ + PaymentHash: preimage.Hash(), + InvoiceAmountSat: testInSwapInvoiceSat, + AmountSat: testInSwapAmountSat, + FeeSat: testInSwapFeeSat, + Expiry: time.Now().Add(time.Minute), + SettlementType: SettlementTypeLightning, + }, + } + legacyServer := struct{ SwapServerConn }{serverConn} + client := configureTestPayClient( + NewSwapClient(&legacyServer, nil, nil, nil), + ) + + quote, err := client.QuotePayViaLightningWithOptions( + t.Context(), invoice, InSwapOptions{ + MaxFeeSat: testInSwapFeeSat, + }, + ) + require.NoError(t, err) + require.Zero(t, quote.ServerFeeSat) + require.Equal(t, uint64(testInSwapFeeSat), + quote.RoutingFeeBudgetSat) +} + +// TestQuotePayViaLightningRejectsMissingCurrentFeeTerms verifies the options +// contract cannot hide a current server that omits its fee decomposition. +func TestQuotePayViaLightningRejectsMissingCurrentFeeTerms(t *testing.T) { + t.Parallel() + + preimage, err := NewPreimage() + require.NoError(t, err) + invoice := testValidPayInvoice(t, preimage) + serverConn := &testInSwapServerConn{ + quote: &InSwapQuote{ + PaymentHash: preimage.Hash(), + InvoiceAmountSat: testInSwapInvoiceSat, + AmountSat: testInSwapAmountSat, + FeeSat: testInSwapFeeSat, + Expiry: time.Now().Add(time.Minute), + SettlementType: SettlementTypeLightning, + }, + } + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + client := configureTestPayClient( + NewSwapClient( + serverConn, &testDaemonConn{ + identityKey: clientPriv.PubKey(), + }, + nil, + nil, + ), + ) + + _, err = client.QuotePayViaLightningWithOptions( + t.Context(), invoice, InSwapOptions{ + MaxFeeSat: testInSwapFeeSat, + }, + ) + require.ErrorContains(t, err, "fee split does not reconcile") +} + +// TestValidateInSwapOptionsRejectsOverflow verifies fee and credit limits fit +// their durable BIGINT representation before a session is created. +func TestValidateInSwapOptionsRejectsOverflow(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options InSwapOptions + errText string + }{ + { + name: "max fee", + options: InSwapOptions{ + MaxFeeSat: maxInt64Uint + 1, + }, + errText: "max fee", + }, + { + name: "max credit", + options: InSwapOptions{ + MaxCreditSat: maxInt64Uint + 1, + }, + errText: "max credit", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateInSwapOptions(test.options) + require.ErrorContains(t, err, test.errText) + }) + } +} + // TestValidateInSwapPreviewAllowsCreditOnlyZeroAmount verifies credit-only // quotes can report zero Ark funding while still binding to the invoice. func TestValidateInSwapPreviewAllowsCreditOnlyZeroAmount(t *testing.T) { @@ -363,6 +548,62 @@ func TestValidateInSwapPreviewAllowsCreditOnlyZeroAmount(t *testing.T) { require.NoError(t, err) } +// TestValidateInSwapPreviewRejectsInvalidFeeTerms verifies wallet previews do +// not display overflowing or internally inconsistent server fee components. +func TestValidateInSwapPreviewRejectsInvalidFeeTerms(t *testing.T) { + t.Parallel() + + preimage, err := NewPreimage() + require.NoError(t, err) + invoice := testValidPayInvoice(t, preimage) + + baseQuote := InSwapQuote{ + PaymentHash: preimage.Hash(), + InvoiceAmountSat: testInSwapInvoiceSat, + AmountSat: testInSwapAmountSat, + FeeSat: testInSwapFeeSat, + ServerFeeSat: 23, + RoutingFeeBudgetSat: testInSwapFeeSat - 23, + Expiry: time.Now().Add(time.Minute), + SettlementType: SettlementTypeLightning, + } + + tests := []struct { + name string + mutate func(*InSwapQuote) + errText string + }{ + { + name: "estimated routing fee overflow", + mutate: func(quote *InSwapQuote) { + quote.EstimatedRoutingFeeSat = maxInt64Uint + 1 + }, + errText: "estimated routing fee overflows", + }, + { + name: "fee split mismatch", + mutate: func(quote *InSwapQuote) { + quote.RoutingFeeBudgetSat-- + }, + errText: "fee split does not reconcile", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + quote := baseQuote + test.mutate("e) + err := validateInSwapPreview( + invoice, "e, &chaincfg.RegressionNetParams, + ) + require.ErrorContains(t, err, test.errText) + }) + } +} + // TestValidateInSwapQuoteRejectsServerMismatches verifies the client treats // the swap server response as a quote that must match the caller's invoice. func TestValidateInSwapQuoteRejectsServerMismatches(t *testing.T) { @@ -429,6 +670,33 @@ func TestValidateInSwapQuoteRejectsServerMismatches(t *testing.T) { cfg.FeeSat = maxInt64Uint + 1 }, wantErr: "fee overflows int64 range", + }, { + name: "server fee overflows int64", + invoice: invoice, + maxFeeSat: testInSwapFeeSat, + chainParams: &chaincfg.RegressionNetParams, + mutate: func(cfg *InSwapConfig) { + cfg.ServerFeeSat = maxInt64Uint + 1 + }, + wantErr: "server fee overflows int64 range", + }, { + name: "routing fee budget overflows int64", + invoice: invoice, + maxFeeSat: testInSwapFeeSat, + chainParams: &chaincfg.RegressionNetParams, + mutate: func(cfg *InSwapConfig) { + cfg.RoutingFeeBudgetSat = maxInt64Uint + 1 + }, + wantErr: "routing fee budget overflows int64 range", + }, { + name: "fee split mismatch", + invoice: invoice, + maxFeeSat: testInSwapFeeSat, + chainParams: &chaincfg.RegressionNetParams, + mutate: func(cfg *InSwapConfig) { + cfg.RoutingFeeBudgetSat-- + }, + wantErr: "fee split does not reconcile", }, { name: "amount below invoice plus fee", invoice: invoice, @@ -573,8 +841,11 @@ func TestPaySessionCreditOnlyStartCompletesWithoutVHTLC(t *testing.T) { NewSwapClient(serverConn, daemonConn, nil, nil), ) - session, err := client.StartPayViaLightningWithCredits( - t.Context(), invoice, testInSwapFeeSat, creditSat, + session, err := client.StartPayViaLightningWithOptions( + t.Context(), invoice, InSwapOptions{ + MaxFeeSat: testInSwapFeeSat, + MaxCreditSat: creditSat, + }, ) require.NoError(t, err) require.Equal(t, PayStateCompleted, session.State()) diff --git a/sdk/swaps/migrations/000002_in_swap_routing_budget.down.sql b/sdk/swaps/migrations/000002_in_swap_routing_budget.down.sql new file mode 100644 index 000000000..7233c08a7 --- /dev/null +++ b/sdk/swaps/migrations/000002_in_swap_routing_budget.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE pay_swaps DROP COLUMN routing_fee_budget_sat; +ALTER TABLE pay_swaps DROP COLUMN server_fee_sat; diff --git a/sdk/swaps/migrations/000002_in_swap_routing_budget.up.sql b/sdk/swaps/migrations/000002_in_swap_routing_budget.up.sql new file mode 100644 index 000000000..ed2068d86 --- /dev/null +++ b/sdk/swaps/migrations/000002_in_swap_routing_budget.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE pay_swaps + -- server_fee_sat is the service-fee portion of the accepted total fee. + ADD COLUMN server_fee_sat BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE pay_swaps + -- routing_fee_budget_sat is the client-funded Lightning fee allowance. + ADD COLUMN routing_fee_budget_sat BIGINT NOT NULL DEFAULT 0; diff --git a/sdk/swaps/pay_summary_preimage_test.go b/sdk/swaps/pay_summary_preimage_test.go index 1fcff3350..34fe9705e 100644 --- a/sdk/swaps/pay_summary_preimage_test.go +++ b/sdk/swaps/pay_summary_preimage_test.go @@ -53,3 +53,26 @@ func TestPaySummaryFromRowNilPreimageWhilePending(t *testing.T) { require.NoError(t, err) require.Nil(t, summary.Preimage) } + +// TestPaySummaryFromLegacyRowReconcilesFeeTerms verifies pre-migration rows +// expose a conservative split instead of an irreconcilable zero breakdown. +func TestPaySummaryFromLegacyRowReconcilesFeeTerms(t *testing.T) { + t.Parallel() + + var paymentHash lntypes.Hash + paymentHash[0] = 0xcd + + summary, err := paySummaryFromRow(swapsqlc.PaySwap{ + PaymentHash: paymentHash[:], + State: PayStateCompleted.String(), + AmountSat: 10_100, + FeeSat: 100, + }) + require.NoError(t, err) + require.Zero(t, summary.ServerFeeSat) + require.Equal(t, uint64(100), summary.RoutingFeeBudgetSat) + require.Equal( + t, summary.FeeSat, + summary.ServerFeeSat+summary.RoutingFeeBudgetSat, + ) +} diff --git a/sdk/swaps/queries/swaps.sql b/sdk/swaps/queries/swaps.sql index 37f61ed9e..1e0a9bc5b 100644 --- a/sdk/swaps/queries/swaps.sql +++ b/sdk/swaps/queries/swaps.sql @@ -92,6 +92,8 @@ INSERT INTO pay_swaps ( state, amount_sat, fee_sat, + server_fee_sat, + routing_fee_budget_sat, expiry_unix, client_pubkey, operator_pubkey, @@ -116,7 +118,7 @@ INSERT INTO pay_swaps ( updated_at_unix ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, - $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28 + $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30 ) ON CONFLICT (payment_hash) DO UPDATE SET invoice = EXCLUDED.invoice, @@ -124,6 +126,8 @@ ON CONFLICT (payment_hash) DO UPDATE SET state = EXCLUDED.state, amount_sat = EXCLUDED.amount_sat, fee_sat = EXCLUDED.fee_sat, + server_fee_sat = EXCLUDED.server_fee_sat, + routing_fee_budget_sat = EXCLUDED.routing_fee_budget_sat, expiry_unix = EXCLUDED.expiry_unix, client_pubkey = EXCLUDED.client_pubkey, operator_pubkey = EXCLUDED.operator_pubkey, diff --git a/sdk/swaps/sqlc/models.go b/sdk/swaps/sqlc/models.go index f41d835ca..7c0e63282 100644 --- a/sdk/swaps/sqlc/models.go +++ b/sdk/swaps/sqlc/models.go @@ -33,6 +33,8 @@ type PaySwap struct { CreatedAtUnix int64 UpdatedAtUnix int64 SettlementType string + ServerFeeSat int64 + RoutingFeeBudgetSat int64 } type ReceiveSwap struct { diff --git a/sdk/swaps/sqlc/swaps.sql.go b/sdk/swaps/sqlc/swaps.sql.go index c6bedee8c..a0cbac00f 100644 --- a/sdk/swaps/sqlc/swaps.sql.go +++ b/sdk/swaps/sqlc/swaps.sql.go @@ -10,7 +10,7 @@ import ( ) const GetPaySwap = `-- name: GetPaySwap :one -SELECT payment_hash, invoice, max_fee_sat, state, amount_sat, fee_sat, expiry_unix, client_pubkey, operator_pubkey, server_pubkey, refund_locktime, unilateral_claim_delay, unilateral_refund_delay, unilateral_refund_without_receiver_delay, vhtlc_pkscript, vhtlc_policy_template, vhtlc_outpoint, vhtlc_amount, funding_session_id, refund_receive_pubkey, refund_receive_pkscript, refund_session_id, refund_recovery_id, preimage, intervention_reason, created_at_unix, updated_at_unix, settlement_type FROM pay_swaps +SELECT payment_hash, invoice, max_fee_sat, state, amount_sat, fee_sat, expiry_unix, client_pubkey, operator_pubkey, server_pubkey, refund_locktime, unilateral_claim_delay, unilateral_refund_delay, unilateral_refund_without_receiver_delay, vhtlc_pkscript, vhtlc_policy_template, vhtlc_outpoint, vhtlc_amount, funding_session_id, refund_receive_pubkey, refund_receive_pkscript, refund_session_id, refund_recovery_id, preimage, intervention_reason, created_at_unix, updated_at_unix, settlement_type, server_fee_sat, routing_fee_budget_sat FROM pay_swaps WHERE payment_hash = $1 LIMIT 1 ` @@ -47,6 +47,8 @@ func (q *Queries) GetPaySwap(ctx context.Context, paymentHash []byte) (PaySwap, &i.CreatedAtUnix, &i.UpdatedAtUnix, &i.SettlementType, + &i.ServerFeeSat, + &i.RoutingFeeBudgetSat, ) return i, err } @@ -98,7 +100,7 @@ func (q *Queries) GetReceiveSwap(ctx context.Context, paymentHash []byte) (Recei } const ListPaySwaps = `-- name: ListPaySwaps :many -SELECT payment_hash, invoice, max_fee_sat, state, amount_sat, fee_sat, expiry_unix, client_pubkey, operator_pubkey, server_pubkey, refund_locktime, unilateral_claim_delay, unilateral_refund_delay, unilateral_refund_without_receiver_delay, vhtlc_pkscript, vhtlc_policy_template, vhtlc_outpoint, vhtlc_amount, funding_session_id, refund_receive_pubkey, refund_receive_pkscript, refund_session_id, refund_recovery_id, preimage, intervention_reason, created_at_unix, updated_at_unix, settlement_type FROM pay_swaps +SELECT payment_hash, invoice, max_fee_sat, state, amount_sat, fee_sat, expiry_unix, client_pubkey, operator_pubkey, server_pubkey, refund_locktime, unilateral_claim_delay, unilateral_refund_delay, unilateral_refund_without_receiver_delay, vhtlc_pkscript, vhtlc_policy_template, vhtlc_outpoint, vhtlc_amount, funding_session_id, refund_receive_pubkey, refund_receive_pkscript, refund_session_id, refund_recovery_id, preimage, intervention_reason, created_at_unix, updated_at_unix, settlement_type, server_fee_sat, routing_fee_budget_sat FROM pay_swaps ORDER BY created_at_unix ASC ` @@ -140,6 +142,8 @@ func (q *Queries) ListPaySwaps(ctx context.Context) ([]PaySwap, error) { &i.CreatedAtUnix, &i.UpdatedAtUnix, &i.SettlementType, + &i.ServerFeeSat, + &i.RoutingFeeBudgetSat, ); err != nil { return nil, err } @@ -155,7 +159,7 @@ func (q *Queries) ListPaySwaps(ctx context.Context) ([]PaySwap, error) { } const ListPendingPaySwaps = `-- name: ListPendingPaySwaps :many -SELECT payment_hash, invoice, max_fee_sat, state, amount_sat, fee_sat, expiry_unix, client_pubkey, operator_pubkey, server_pubkey, refund_locktime, unilateral_claim_delay, unilateral_refund_delay, unilateral_refund_without_receiver_delay, vhtlc_pkscript, vhtlc_policy_template, vhtlc_outpoint, vhtlc_amount, funding_session_id, refund_receive_pubkey, refund_receive_pkscript, refund_session_id, refund_recovery_id, preimage, intervention_reason, created_at_unix, updated_at_unix, settlement_type FROM pay_swaps +SELECT payment_hash, invoice, max_fee_sat, state, amount_sat, fee_sat, expiry_unix, client_pubkey, operator_pubkey, server_pubkey, refund_locktime, unilateral_claim_delay, unilateral_refund_delay, unilateral_refund_without_receiver_delay, vhtlc_pkscript, vhtlc_policy_template, vhtlc_outpoint, vhtlc_amount, funding_session_id, refund_receive_pubkey, refund_receive_pkscript, refund_session_id, refund_recovery_id, preimage, intervention_reason, created_at_unix, updated_at_unix, settlement_type, server_fee_sat, routing_fee_budget_sat FROM pay_swaps WHERE state NOT IN ( 'Completed', 'Expired', 'Refunded', 'NeedsIntervention', 'Failed' ) @@ -200,6 +204,8 @@ func (q *Queries) ListPendingPaySwaps(ctx context.Context) ([]PaySwap, error) { &i.CreatedAtUnix, &i.UpdatedAtUnix, &i.SettlementType, + &i.ServerFeeSat, + &i.RoutingFeeBudgetSat, ); err != nil { return nil, err } @@ -345,6 +351,8 @@ INSERT INTO pay_swaps ( state, amount_sat, fee_sat, + server_fee_sat, + routing_fee_budget_sat, expiry_unix, client_pubkey, operator_pubkey, @@ -369,7 +377,7 @@ INSERT INTO pay_swaps ( updated_at_unix ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, - $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28 + $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30 ) ON CONFLICT (payment_hash) DO UPDATE SET invoice = EXCLUDED.invoice, @@ -377,6 +385,8 @@ ON CONFLICT (payment_hash) DO UPDATE SET state = EXCLUDED.state, amount_sat = EXCLUDED.amount_sat, fee_sat = EXCLUDED.fee_sat, + server_fee_sat = EXCLUDED.server_fee_sat, + routing_fee_budget_sat = EXCLUDED.routing_fee_budget_sat, expiry_unix = EXCLUDED.expiry_unix, client_pubkey = EXCLUDED.client_pubkey, operator_pubkey = EXCLUDED.operator_pubkey, @@ -408,6 +418,8 @@ type UpsertPaySwapParams struct { State string AmountSat int64 FeeSat int64 + ServerFeeSat int64 + RoutingFeeBudgetSat int64 ExpiryUnix int64 ClientPubkey []byte OperatorPubkey []byte @@ -440,6 +452,8 @@ func (q *Queries) UpsertPaySwap(ctx context.Context, arg UpsertPaySwapParams) er arg.State, arg.AmountSat, arg.FeeSat, + arg.ServerFeeSat, + arg.RoutingFeeBudgetSat, arg.ExpiryUnix, arg.ClientPubkey, arg.OperatorPubkey, diff --git a/sdk/swaps/store.go b/sdk/swaps/store.go index bf6c62778..cc9afde1f 100644 --- a/sdk/swaps/store.go +++ b/sdk/swaps/store.go @@ -38,7 +38,7 @@ const ( defaultConnMaxLifetime = 10 * time.Minute // LatestMigrationVersion is the latest swap-client schema migration. - LatestMigrationVersion uint = 1 + LatestMigrationVersion uint = 2 ) //go:embed migrations/*.sql diff --git a/sdk/swaps/store_sessions.go b/sdk/swaps/store_sessions.go index 54ab79f83..9f874f60b 100644 --- a/sdk/swaps/store_sessions.go +++ b/sdk/swaps/store_sessions.go @@ -321,26 +321,33 @@ func paySummaryFromRow(row swapsqlc.PaySwap) (SwapSummary, error) { preimage = &decoded } + serverFeeSat, routingFeeBudgetSat := normalizeInSwapFeeTerms( + uint64(row.FeeSat), uint64(row.ServerFeeSat), + uint64(row.RoutingFeeBudgetSat), + ) + return SwapSummary{ - Direction: SwapDirectionPay, - PaymentHash: paymentHash, - Preimage: preimage, - Invoice: row.Invoice, - State: state.String(), - Pending: !state.IsTerminal(), - AmountSat: row.AmountSat, - FeeSat: uint64(row.FeeSat), - MaxFeeSat: uint64(row.MaxFeeSat), - VHTLCOutpoint: row.VhtlcOutpoint, - VHTLCAmountSat: row.VhtlcAmount, - FundingSessionID: row.FundingSessionID, - RefundSessionID: row.RefundSessionID, - SettlementType: SettlementType(row.SettlementType), - TerminalReason: row.InterventionReason, - CreatedAt: time.Unix(row.CreatedAtUnix, 0), - UpdatedAt: time.Unix(row.UpdatedAtUnix, 0), - Deadline: time.Unix(row.ExpiryUnix, 0), - RefundLocktime: uint32(row.RefundLocktime), + Direction: SwapDirectionPay, + PaymentHash: paymentHash, + Preimage: preimage, + Invoice: row.Invoice, + State: state.String(), + Pending: !state.IsTerminal(), + AmountSat: row.AmountSat, + FeeSat: uint64(row.FeeSat), + ServerFeeSat: serverFeeSat, + RoutingFeeBudgetSat: routingFeeBudgetSat, + MaxFeeSat: uint64(row.MaxFeeSat), + VHTLCOutpoint: row.VhtlcOutpoint, + VHTLCAmountSat: row.VhtlcAmount, + FundingSessionID: row.FundingSessionID, + RefundSessionID: row.RefundSessionID, + SettlementType: SettlementType(row.SettlementType), + TerminalReason: row.InterventionReason, + CreatedAt: time.Unix(row.CreatedAtUnix, 0), + UpdatedAt: time.Unix(row.UpdatedAtUnix, 0), + Deadline: time.Unix(row.ExpiryUnix, 0), + RefundLocktime: uint32(row.RefundLocktime), }, nil } @@ -650,7 +657,13 @@ func (s *paySession) persist(ctx context.Context) error { State: s.state.String(), AmountSat: s.cfg.AmountSat, FeeSat: int64(s.cfg.FeeSat), - ExpiryUnix: s.cfg.Expiry.Unix(), + ServerFeeSat: int64( + s.cfg.ServerFeeSat, + ), + RoutingFeeBudgetSat: int64( + s.cfg.RoutingFeeBudgetSat, + ), + ExpiryUnix: s.cfg.Expiry.Unix(), ClientPubkey: append( []byte(nil), s.clientPubKey.SerializeCompressed()..., ), @@ -893,9 +906,13 @@ func paySessionFromRow(c *SwapClient, } cfg := &InSwapConfig{ - PaymentHash: paymentHash, - AmountSat: row.AmountSat, - FeeSat: uint64(row.FeeSat), + PaymentHash: paymentHash, + AmountSat: row.AmountSat, + FeeSat: uint64(row.FeeSat), + ServerFeeSat: uint64(row.ServerFeeSat), + RoutingFeeBudgetSat: uint64( + row.RoutingFeeBudgetSat, + ), ServerPubkey: serverKey, SettlementType: settlementType, VHTLCConfig: restoreVHTLCConfig( @@ -906,15 +923,24 @@ func paySessionFromRow(c *SwapClient, ), Expiry: time.Unix(row.ExpiryUnix, 0), } + normalizeInSwapConfigFeeTerms(cfg) + err = validateInSwapFeeTerms( + cfg.FeeSat, cfg.ServerFeeSat, 0, cfg.RoutingFeeBudgetSat, + ) + if err != nil { + return nil, fmt.Errorf("restore in-swap fee terms: %w", err) + } session := &paySession{ - client: c, - invoice: row.Invoice, - maxFeeSat: uint64(row.MaxFeeSat), - state: state, - cfg: cfg, - vhtlcPolicy: policy, - vhtlcPkScript: append([]byte(nil), row.VhtlcPkscript...), + client: c, + invoice: row.Invoice, + maxFeeSat: uint64(row.MaxFeeSat), + state: state, + cfg: cfg, + vhtlcPolicy: policy, + vhtlcPkScript: append( + []byte(nil), row.VhtlcPkscript..., + ), vhtlcPolicyTemplate: append( []byte(nil), row.VhtlcPolicyTemplate..., ), diff --git a/sdk/swaps/store_test.go b/sdk/swaps/store_test.go index 7b577b423..75604d614 100644 --- a/sdk/swaps/store_test.go +++ b/sdk/swaps/store_test.go @@ -139,10 +139,13 @@ func TestPaySessionPersistAllowsCreditOnlyWithoutVHTLC(t *testing.T) { maxFeeSat: 100, state: PayStateCompleted, cfg: &InSwapConfig{ - PaymentHash: preimage.Hash(), - SettlementType: SettlementTypeCredit, - Preimage: &preimage, - Expiry: now.Add(time.Hour), + PaymentHash: preimage.Hash(), + FeeSat: 10, + ServerFeeSat: 3, + RoutingFeeBudgetSat: 7, + SettlementType: SettlementTypeCredit, + Preimage: &preimage, + Expiry: now.Add(time.Hour), }, preimage: &preimage, clientPubKey: key.PubKey(), @@ -157,6 +160,8 @@ func TestPaySessionPersistAllowsCreditOnlyWithoutVHTLC(t *testing.T) { row, err := store.queries.GetPaySwap(ctx, paymentHash[:]) require.NoError(t, err) require.Equal(t, string(SettlementTypeCredit), row.SettlementType) + require.EqualValues(t, 3, row.ServerFeeSat) + require.EqualValues(t, 7, row.RoutingFeeBudgetSat) require.Empty(t, row.VhtlcPkscript) require.Empty(t, row.VhtlcPolicyTemplate) require.Empty(t, row.VhtlcOutpoint) @@ -165,10 +170,62 @@ func TestPaySessionPersistAllowsCreditOnlyWithoutVHTLC(t *testing.T) { resumed, err := client.ResumePayViaLightning(ctx, paymentHash) require.NoError(t, err) require.Equal(t, SettlementTypeCredit, resumed.cfg.SettlementType) + require.EqualValues(t, 3, resumed.cfg.ServerFeeSat) + require.EqualValues(t, 7, resumed.cfg.RoutingFeeBudgetSat) require.Empty(t, resumed.vhtlcPkScript) require.Empty(t, resumed.vhtlcPolicyTemplate) } +// TestPaySessionRestoreRejectsInvalidFeeTerms verifies corrupted durable fee +// components cannot resume into a payment session. +func TestPaySessionRestoreRejectsInvalidFeeTerms(t *testing.T) { + t.Parallel() + + ctx := context.Background() + store := newTestSwapStore(t) + client := NewSwapClientWithStore(nil, nil, nil, nil, store) + + key, err := btcec.NewPrivateKey() + require.NoError(t, err) + + preimage, err := NewPreimage() + require.NoError(t, err) + + now := time.Now() + session := &paySession{ + client: client, + invoice: "ln-invalid-fee-terms", + maxFeeSat: 100, + state: PayStateCompleted, + cfg: &InSwapConfig{ + PaymentHash: preimage.Hash(), + FeeSat: 10, + ServerFeeSat: 3, + RoutingFeeBudgetSat: 7, + SettlementType: SettlementTypeCredit, + Preimage: &preimage, + Expiry: now.Add(time.Hour), + }, + preimage: &preimage, + clientPubKey: key.PubKey(), + operatorPubKey: key.PubKey(), + serverPubKey: key.PubKey(), + createdAt: now, + } + require.NoError(t, session.persist(ctx)) + + paymentHash := preimage.Hash() + _, err = store.DB().ExecContext( + ctx, "UPDATE pay_swaps SET server_fee_sat = 11 "+ + "WHERE payment_hash = ?", paymentHash[:], + ) + require.NoError(t, err) + + _, err = client.ResumePayViaLightning(ctx, paymentHash) + require.ErrorContains(t, err, "restore in-swap fee terms") + require.ErrorContains(t, err, "fee split does not reconcile") +} + // TestReceiveAuthKeyDerivesAcrossRestart verifies receive-auth keys come from // the wallet-backed daemon derivation and are not stored in the swap DB. func TestReceiveAuthKeyDerivesAcrossRestart(t *testing.T) { @@ -227,16 +284,18 @@ func TestListSwapSummariesIncludesFeesAndPendingFilter(t *testing.T) { expiryUnix := time.Unix(1_700, 0).Unix() err := store.queries.UpsertPaySwap(ctx, swapsqlc.UpsertPaySwapParams{ - PaymentHash: payHash[:], - Invoice: "ln-pay", - MaxFeeSat: 999, - State: fundingState, - AmountSat: 42_000, - FeeSat: 123, - ExpiryUnix: expiryUnix, - ClientPubkey: testPubKeyBytes(2), - OperatorPubkey: testPubKeyBytes(3), - ServerPubkey: testPubKeyBytes(4), + PaymentHash: payHash[:], + Invoice: "ln-pay", + MaxFeeSat: 999, + State: fundingState, + AmountSat: 42_000, + FeeSat: 123, + ServerFeeSat: 100, + RoutingFeeBudgetSat: 23, + ExpiryUnix: expiryUnix, + ClientPubkey: testPubKeyBytes(2), + OperatorPubkey: testPubKeyBytes(3), + ServerPubkey: testPubKeyBytes(4), SettlementType: string( SettlementTypeLightning, ), @@ -297,6 +356,8 @@ func TestListSwapSummariesIncludesFeesAndPendingFilter(t *testing.T) { require.Equal(t, SwapDirectionPay, all[0].Direction) require.Equal(t, "ln-pay", all[0].Invoice) require.EqualValues(t, 123, all[0].FeeSat) + require.EqualValues(t, 100, all[0].ServerFeeSat) + require.EqualValues(t, 23, all[0].RoutingFeeBudgetSat) require.EqualValues(t, 999, all[0].MaxFeeSat) require.Equal(t, SettlementTypeLightning, all[0].SettlementType) require.True(t, all[0].Pending) diff --git a/swapclientserver/service.go b/swapclientserver/service.go index 6e7bf6c90..9ee614a66 100644 --- a/swapclientserver/service.go +++ b/swapclientserver/service.go @@ -674,6 +674,14 @@ func (a *swapClientAdapter) StartPayViaLightningWithCredits(ctx context.Context, ) } +// StartPayViaLightningWithOptions starts a real sdk/swaps pay session with +// explicit fee and credit limits. +func (a *swapClientAdapter) StartPayViaLightningWithOptions(ctx context.Context, + invoice string, options swaps.InSwapOptions) (paySwapSession, error) { + + return a.client.StartPayViaLightningWithOptions(ctx, invoice, options) +} + // QuotePayViaLightning previews a pay swap without creating durable state. func (a *swapClientAdapter) QuotePayViaLightning(ctx context.Context, invoice string, maxFeeSat uint64) (*swaps.InSwapQuote, error) { @@ -691,6 +699,15 @@ func (a *swapClientAdapter) QuotePayViaLightningWithCredits(ctx context.Context, ) } +// QuotePayViaLightningWithOptions previews a pay swap with explicit fee and +// credit limits. +func (a *swapClientAdapter) QuotePayViaLightningWithOptions(ctx context.Context, + invoice string, options swaps.InSwapOptions) (*swaps.InSwapQuote, + error) { + + return a.client.QuotePayViaLightningWithOptions(ctx, invoice, options) +} + // CreateCredit forwards credit funding requests to sdk/swaps. func (a *swapClientAdapter) CreateCredit(ctx context.Context, req swaps.CreateCreditRequest) (*swaps.CreditOperation, error) { @@ -1345,33 +1362,55 @@ func (s *swapClientService) validatePayInvoiceAmount(ctx context.Context, // when the SDK client supports credits so a sub-dust or credit-assisted pay is // quoted against the caller's credit balance. func (s *swapClientService) quotePay(ctx context.Context, invoice string, - maxFeeSat uint64, maxCreditSat uint64) (*swaps.InSwapQuote, error) { + options swaps.InSwapOptions) (*swaps.InSwapQuote, error) { + + if client, ok := s.client.(interface { + QuotePayViaLightningWithOptions(context.Context, string, + swaps.InSwapOptions) (*swaps.InSwapQuote, error) + }); ok { + return client.QuotePayViaLightningWithOptions( + ctx, invoice, options, + ) + } if client, ok := s.client.(interface { QuotePayViaLightningWithCredits(context.Context, string, uint64, uint64) (*swaps.InSwapQuote, error) }); ok { return client.QuotePayViaLightningWithCredits( - ctx, invoice, maxFeeSat, maxCreditSat, + ctx, invoice, options.MaxFeeSat, options.MaxCreditSat, ) } - return s.client.QuotePayViaLightning(ctx, invoice, maxFeeSat) + return s.client.QuotePayViaLightning( + ctx, invoice, options.MaxFeeSat, + ) } func (s *swapClientService) startPay(ctx context.Context, invoice string, - maxFeeSat uint64, maxCreditSat uint64) (paySwapSession, error) { + options swaps.InSwapOptions) (paySwapSession, error) { + + if client, ok := s.client.(interface { + StartPayViaLightningWithOptions(context.Context, string, + swaps.InSwapOptions) (paySwapSession, error) + }); ok { + return client.StartPayViaLightningWithOptions( + ctx, invoice, options, + ) + } if client, ok := s.client.(interface { StartPayViaLightningWithCredits(context.Context, string, uint64, uint64) (paySwapSession, error) }); ok { return client.StartPayViaLightningWithCredits( - ctx, invoice, maxFeeSat, maxCreditSat, + ctx, invoice, options.MaxFeeSat, options.MaxCreditSat, ) } - return s.client.StartPayViaLightning(ctx, invoice, maxFeeSat) + return s.client.StartPayViaLightning( + ctx, invoice, options.MaxFeeSat, + ) } // QuotePay previews a pay swap through sdk/swaps without starting a daemon @@ -1405,7 +1444,10 @@ func (s *swapClientService) QuotePay(ctx context.Context, ) quote, err := s.quotePay( - ctx, req.GetInvoice(), maxFeeSat, req.GetMaxCreditSat(), + ctx, req.GetInvoice(), swaps.InSwapOptions{ + MaxFeeSat: maxFeeSat, + MaxCreditSat: req.GetMaxCreditSat(), + }, ) if err != nil { return nil, fmt.Errorf("quote pay swap: %w", wrapInSwapFeeError( @@ -1447,7 +1489,10 @@ func (s *swapClientService) StartPay(ctx context.Context, ) session, err := s.startPay( - ctx, req.GetInvoice(), maxFeeSat, req.GetMaxCreditSat(), + ctx, req.GetInvoice(), swaps.InSwapOptions{ + MaxFeeSat: maxFeeSat, + MaxCreditSat: req.GetMaxCreditSat(), + }, ) if err != nil { return nil, fmt.Errorf("start pay swap: %w", wrapInSwapFeeError( @@ -2033,24 +2078,26 @@ func swapSummaryToProto(summary swaps.SwapSummary) *swapclientrpc.SwapSummary { } return &swapclientrpc.SwapSummary{ - Direction: swapDirectionToProto(summary.Direction), - PaymentHash: hex.EncodeToString(summary.PaymentHash[:]), - Invoice: summary.Invoice, - State: swapStateToProto(summary.State), - Pending: summary.Pending, - AmountSat: summary.AmountSat, - FeeSat: summary.FeeSat, - MaxFeeSat: summary.MaxFeeSat, - VhtlcOutpoint: summary.VHTLCOutpoint, - VhtlcAmountSat: summary.VHTLCAmountSat, - FundingSessionId: summary.FundingSessionID, - ClaimSessionId: summary.ClaimSessionID, - RefundSessionId: summary.RefundSessionID, - TerminalReason: summary.TerminalReason, - CreatedAtUnix: summary.CreatedAt.Unix(), - UpdatedAtUnix: summary.UpdatedAt.Unix(), - DeadlineUnix: summary.Deadline.Unix(), - RefundLocktime: summary.RefundLocktime, + Direction: swapDirectionToProto(summary.Direction), + PaymentHash: hex.EncodeToString(summary.PaymentHash[:]), + Invoice: summary.Invoice, + State: swapStateToProto(summary.State), + Pending: summary.Pending, + AmountSat: summary.AmountSat, + FeeSat: summary.FeeSat, + ServerFeeSat: summary.ServerFeeSat, + RoutingFeeBudgetSat: summary.RoutingFeeBudgetSat, + MaxFeeSat: summary.MaxFeeSat, + VhtlcOutpoint: summary.VHTLCOutpoint, + VhtlcAmountSat: summary.VHTLCAmountSat, + FundingSessionId: summary.FundingSessionID, + ClaimSessionId: summary.ClaimSessionID, + RefundSessionId: summary.RefundSessionID, + TerminalReason: summary.TerminalReason, + CreatedAtUnix: summary.CreatedAt.Unix(), + UpdatedAtUnix: summary.UpdatedAt.Unix(), + DeadlineUnix: summary.Deadline.Unix(), + RefundLocktime: summary.RefundLocktime, SettlementType: swapSettlementTypeToProto( summary.SettlementType, ), @@ -2081,9 +2128,12 @@ func quotePayToProto(quote *swaps.InSwapQuote) (*swapclientrpc.QuotePayResponse, SettlementType: swapSettlementTypeToProto( quote.SettlementType, ), - ExpiresAtUnix: quote.Expiry.Unix(), - ExceedsMaxFee: quote.ExceedsMaxFee, - CreditQuote: creditQuoteToProto(quote.CreditQuote), + ExpiresAtUnix: quote.Expiry.Unix(), + ExceedsMaxFee: quote.ExceedsMaxFee, + CreditQuote: creditQuoteToProto(quote.CreditQuote), + ServerFeeSat: quote.ServerFeeSat, + EstimatedRoutingFeeSat: quote.EstimatedRoutingFeeSat, + RoutingFeeBudgetSat: quote.RoutingFeeBudgetSat, }, nil } diff --git a/swapclientserver/service_test.go b/swapclientserver/service_test.go index 42c9a3fa2..8d0710bed 100644 --- a/swapclientserver/service_test.go +++ b/swapclientserver/service_test.go @@ -225,12 +225,15 @@ func TestStartPayReturnsSummaryAndStartsWorker(t *testing.T) { payHash := testHash(3) fakeClient := newFakeSwapRuntime( swaps.SwapSummary{ - Direction: swaps.SwapDirectionPay, - PaymentHash: payHash, - State: "created", - Pending: true, - AmountSat: 10_000, - MaxFeeSat: 25, + Direction: swaps.SwapDirectionPay, + PaymentHash: payHash, + State: "created", + Pending: true, + AmountSat: 10_000, + FeeSat: 25, + ServerFeeSat: 15, + RoutingFeeBudgetSat: 10, + MaxFeeSat: 25, }, ) fakeClient.startPaySession = &fakePaySession{hash: payHash} @@ -239,13 +242,19 @@ func TestStartPayReturnsSummaryAndStartsWorker(t *testing.T) { resp, err := service.StartPay( t.Context(), &swapclientrpc.StartPayRequest{ - Invoice: "lnbc1test", - MaxFeeSat: 25, + Invoice: "lnbc1test", + MaxFeeSat: 25, + MaxCreditSat: 11, }, ) require.NoError(t, err) require.Equal(t, hex.EncodeToString(payHash[:]), resp.GetPaymentHash()) require.Equal(t, int64(10_000), resp.GetSwap().GetAmountSat()) + require.Equal(t, uint64(15), resp.GetSwap().GetServerFeeSat()) + require.Equal( + t, uint64(10), resp.GetSwap().GetRoutingFeeBudgetSat(), + ) + require.Equal(t, uint64(11), fakeClient.startMaxCreditSat) fakeClient.awaitPayResume(t, payHash) require.Equal(t, 1, fakeClient.startPayCount()) @@ -261,21 +270,25 @@ func TestQuotePayReturnsRemotePreview(t *testing.T) { expiresAt := time.Unix(1_700_200_000, 0) fakeClient := newFakeSwapRuntime() fakeClient.quotePayResp = &swaps.InSwapQuote{ - PaymentHash: payHash, - InvoiceAmountSat: 10_000, - AmountSat: 10_210, - FeeSat: 210, - Expiry: expiresAt, - SettlementType: swaps.SettlementTypeLightning, - ExceedsMaxFee: true, + PaymentHash: payHash, + InvoiceAmountSat: 10_000, + AmountSat: 10_210, + FeeSat: 210, + ServerFeeSat: 190, + EstimatedRoutingFeeSat: 3, + RoutingFeeBudgetSat: 20, + Expiry: expiresAt, + SettlementType: swaps.SettlementTypeLightning, + ExceedsMaxFee: true, } service := newTestSwapClientService(fakeClient) defer service.cancel() resp, err := service.QuotePay( t.Context(), &swapclientrpc.QuotePayRequest{ - Invoice: "lnbc1test", - MaxFeeSat: 1, + Invoice: "lnbc1test", + MaxFeeSat: 1, + MaxCreditSat: 30, }, ) require.NoError(t, err) @@ -283,6 +296,9 @@ func TestQuotePayReturnsRemotePreview(t *testing.T) { require.Equal(t, uint64(10_000), resp.GetInvoiceAmountSat()) require.Equal(t, uint64(10_210), resp.GetAmountSat()) require.Equal(t, uint64(210), resp.GetFeeSat()) + require.Equal(t, uint64(190), resp.GetServerFeeSat()) + require.Equal(t, uint64(3), resp.GetEstimatedRoutingFeeSat()) + require.Equal(t, uint64(20), resp.GetRoutingFeeBudgetSat()) require.Equal( t, swapclientrpc. SwapSettlementType_SWAP_SETTLEMENT_TYPE_LIGHTNING, @@ -293,6 +309,7 @@ func TestQuotePayReturnsRemotePreview(t *testing.T) { require.Equal(t, 1, fakeClient.quotePayCalls) require.Equal(t, "lnbc1test", fakeClient.quotePayInvoice) require.Equal(t, uint64(1), fakeClient.quotePayMaxFeeSat) + require.Equal(t, uint64(30), fakeClient.quoteMaxCreditSat) require.Equal(t, 0, fakeClient.startPayCount()) } @@ -751,30 +768,32 @@ func TestSwapSummaryToProtoCopiesDurableFields(t *testing.T) { ) got := swapSummaryToProto(swaps.SwapSummary{ - Direction: swaps.SwapDirectionReceive, - PaymentHash: hash, - Invoice: "lnbc1summary", - State: "Completed", - Pending: false, - AmountSat: 1_000, - FeeSat: 20, - MaxFeeSat: 30, - VHTLCOutpoint: "txid:0", - VHTLCAmountSat: 990, - FundingSessionID: "funding", - ClaimSessionID: "claim", - RefundSessionID: "refund", - TerminalReason: "done", - CreatedAt: createdAt, - UpdatedAt: updatedAt, - Deadline: deadline, - RefundLocktime: 42, - SettlementType: swaps.SettlementTypeInArk, - SenderPubkey: senderPubKey, + Direction: swaps.SwapDirectionPay, + PaymentHash: hash, + Invoice: "lnbc1summary", + State: "Completed", + Pending: false, + AmountSat: 1_000, + FeeSat: 20, + ServerFeeSat: 12, + RoutingFeeBudgetSat: 8, + MaxFeeSat: 30, + VHTLCOutpoint: "txid:0", + VHTLCAmountSat: 990, + FundingSessionID: "funding", + ClaimSessionID: "claim", + RefundSessionID: "refund", + TerminalReason: "done", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Deadline: deadline, + RefundLocktime: 42, + SettlementType: swaps.SettlementTypeInArk, + SenderPubkey: senderPubKey, }) require.Equal( - t, swapclientrpc.SwapDirection_SWAP_DIRECTION_RECEIVE, + t, swapclientrpc.SwapDirection_SWAP_DIRECTION_PAY, got.GetDirection(), ) require.Equal(t, hex.EncodeToString(hash[:]), got.GetPaymentHash()) @@ -785,6 +804,8 @@ func TestSwapSummaryToProtoCopiesDurableFields(t *testing.T) { require.False(t, got.GetPending()) require.Equal(t, int64(1_000), got.GetAmountSat()) require.Equal(t, uint64(20), got.GetFeeSat()) + require.Equal(t, uint64(12), got.GetServerFeeSat()) + require.Equal(t, uint64(8), got.GetRoutingFeeBudgetSat()) require.Equal(t, uint64(30), got.GetMaxFeeSat()) require.Equal(t, "txid:0", got.GetVhtlcOutpoint()) require.Equal(t, int64(990), got.GetVhtlcAmountSat()) @@ -1421,7 +1442,9 @@ type fakeSwapRuntime struct { quotePayCalls int quotePayInvoice string quotePayMaxFeeSat uint64 + quoteMaxCreditSat uint64 startPayMaxFeeSat uint64 + startMaxCreditSat uint64 startPayCalls int startReceiveCalls int startReceiveMemo string @@ -1469,6 +1492,28 @@ func (f *fakeSwapRuntime) QuotePayViaLightning(_ context.Context, return f.quotePayResp, nil } +// QuotePayViaLightningWithOptions records explicit fee and credit limits. +func (f *fakeSwapRuntime) QuotePayViaLightningWithOptions(_ context.Context, + invoice string, options swaps.InSwapOptions) (*swaps.InSwapQuote, + error) { + + f.mu.Lock() + defer f.mu.Unlock() + + f.quotePayCalls++ + f.quotePayInvoice = invoice + f.quotePayMaxFeeSat = options.MaxFeeSat + f.quoteMaxCreditSat = options.MaxCreditSat + if f.quotePayErr != nil { + return nil, f.quotePayErr + } + if f.quotePayResp == nil { + return nil, errors.New("quote pay response not configured") + } + + return f.quotePayResp, nil +} + func (f *fakeSwapRuntime) StartPayViaLightning(_ context.Context, _ string, maxFeeSat uint64) (paySwapSession, error) { @@ -1487,6 +1532,26 @@ func (f *fakeSwapRuntime) StartPayViaLightning(_ context.Context, _ string, return f.startPaySession, nil } +// StartPayViaLightningWithOptions records explicit fee and credit limits. +func (f *fakeSwapRuntime) StartPayViaLightningWithOptions(_ context.Context, + _ string, options swaps.InSwapOptions) (paySwapSession, error) { + + f.mu.Lock() + defer f.mu.Unlock() + + f.startPayCalls++ + f.startPayMaxFeeSat = options.MaxFeeSat + f.startMaxCreditSat = options.MaxCreditSat + if f.startPayErr != nil { + return nil, f.startPayErr + } + if f.startPaySession == nil { + return nil, errors.New("start pay session not configured") + } + + return f.startPaySession, nil +} + func (f *fakeSwapRuntime) StartReceiveViaLightning(_ context.Context, _ btcutil.Amount, memo string) (receiveSwapSession, error) { diff --git a/swaprpc/swap.pb.go b/swaprpc/swap.pb.go index 40a7c1ed3..8a845d3c7 100644 --- a/swaprpc/swap.pb.go +++ b/swaprpc/swap.pb.go @@ -1296,9 +1296,14 @@ type CreateInSwapResponse struct { CreditQuote *CreditQuote `protobuf:"bytes,8,opt,name=credit_quote,json=creditQuote,proto3" json:"credit_quote,omitempty"` // preimage is set for credit-only pays after the swap server has already // paid the Lightning invoice from reserved credits. - Preimage []byte `protobuf:"bytes,9,opt,name=preimage,proto3" json:"preimage,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Preimage []byte `protobuf:"bytes,9,opt,name=preimage,proto3" json:"preimage,omitempty"` + // server_fee_sat is the service fee retained by the swap server. + ServerFeeSat uint64 `protobuf:"varint,10,opt,name=server_fee_sat,json=serverFeeSat,proto3" json:"server_fee_sat,omitempty"` + // routing_fee_budget_sat is the client-funded Lightning routing + // allowance charged into the vHTLC as part of fee_sat. + RoutingFeeBudgetSat uint64 `protobuf:"varint,11,opt,name=routing_fee_budget_sat,json=routingFeeBudgetSat,proto3" json:"routing_fee_budget_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateInSwapResponse) Reset() { @@ -1394,6 +1399,20 @@ func (x *CreateInSwapResponse) GetPreimage() []byte { return nil } +func (x *CreateInSwapResponse) GetServerFeeSat() uint64 { + if x != nil { + return x.ServerFeeSat + } + return 0 +} + +func (x *CreateInSwapResponse) GetRoutingFeeBudgetSat() uint64 { + if x != nil { + return x.RoutingFeeBudgetSat + } + return 0 +} + // QuoteInSwapRequest previews one Ark-to-Lightning invoice send. type QuoteInSwapRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1492,9 +1511,17 @@ type QuoteInSwapResponse struct { // larger than that cap. ExceedsMaxFee bool `protobuf:"varint,7,opt,name=exceeds_max_fee,json=exceedsMaxFee,proto3" json:"exceeds_max_fee,omitempty"` // credit_quote describes how credits would be used for this invoice. - CreditQuote *CreditQuote `protobuf:"bytes,8,opt,name=credit_quote,json=creditQuote,proto3" json:"credit_quote,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CreditQuote *CreditQuote `protobuf:"bytes,8,opt,name=credit_quote,json=creditQuote,proto3" json:"credit_quote,omitempty"` + // server_fee_sat is the service fee retained by the swap server. + ServerFeeSat uint64 `protobuf:"varint,9,opt,name=server_fee_sat,json=serverFeeSat,proto3" json:"server_fee_sat,omitempty"` + // estimated_routing_fee_sat is the server's current whole-satoshi + // Lightning route estimate. + EstimatedRoutingFeeSat uint64 `protobuf:"varint,10,opt,name=estimated_routing_fee_sat,json=estimatedRoutingFeeSat,proto3" json:"estimated_routing_fee_sat,omitempty"` + // routing_fee_budget_sat is the client-funded Lightning routing allowance + // that would be charged into the vHTLC as part of fee_sat. + RoutingFeeBudgetSat uint64 `protobuf:"varint,11,opt,name=routing_fee_budget_sat,json=routingFeeBudgetSat,proto3" json:"routing_fee_budget_sat,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *QuoteInSwapResponse) Reset() { @@ -1583,6 +1610,27 @@ func (x *QuoteInSwapResponse) GetCreditQuote() *CreditQuote { return nil } +func (x *QuoteInSwapResponse) GetServerFeeSat() uint64 { + if x != nil { + return x.ServerFeeSat + } + return 0 +} + +func (x *QuoteInSwapResponse) GetEstimatedRoutingFeeSat() uint64 { + if x != nil { + return x.EstimatedRoutingFeeSat + } + return 0 +} + +func (x *QuoteInSwapResponse) GetRoutingFeeBudgetSat() uint64 { + if x != nil { + return x.RoutingFeeBudgetSat + } + return 0 +} + // CreditQuote describes the credit component of a pay quote. type CreditQuote struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3051,7 +3099,7 @@ const file_swap_proto_rawDesc = "" + "\vmax_fee_sat\x18\x02 \x01(\x04R\tmaxFeeSat\x12.\n" + "\x13client_vhtlc_pubkey\x18\x03 \x01(\fR\x11clientVhtlcPubkey\x12%\n" + "\x0eaccount_pubkey\x18\x04 \x01(\fR\raccountPubkey\x12$\n" + - "\x0emax_credit_sat\x18\x05 \x01(\x04R\fmaxCreditSat\"\x9a\x03\n" + + "\x0emax_credit_sat\x18\x05 \x01(\x04R\fmaxCreditSat\"\xf5\x03\n" + "\x14CreateInSwapResponse\x12!\n" + "\fpayment_hash\x18\x01 \x01(\fR\vpaymentHash\x12\x1d\n" + "\n" + @@ -3062,12 +3110,15 @@ const file_swap_proto_rawDesc = "" + "\x06expiry\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\x12@\n" + "\x0fsettlement_type\x18\a \x01(\x0e2\x17.swaprpc.SettlementTypeR\x0esettlementType\x127\n" + "\fcredit_quote\x18\b \x01(\v2\x14.swaprpc.CreditQuoteR\vcreditQuote\x12\x1a\n" + - "\bpreimage\x18\t \x01(\fR\bpreimage\"\x9b\x01\n" + + "\bpreimage\x18\t \x01(\fR\bpreimage\x12$\n" + + "\x0eserver_fee_sat\x18\n" + + " \x01(\x04R\fserverFeeSat\x123\n" + + "\x16routing_fee_budget_sat\x18\v \x01(\x04R\x13routingFeeBudgetSat\"\x9b\x01\n" + "\x12QuoteInSwapRequest\x12\x18\n" + "\ainvoice\x18\x01 \x01(\tR\ainvoice\x12\x1e\n" + "\vmax_fee_sat\x18\x02 \x01(\x04R\tmaxFeeSat\x12%\n" + "\x0eaccount_pubkey\x18\x03 \x01(\fR\raccountPubkey\x12$\n" + - "\x0emax_credit_sat\x18\x04 \x01(\x04R\fmaxCreditSat\"\xf5\x02\n" + + "\x0emax_credit_sat\x18\x04 \x01(\x04R\fmaxCreditSat\"\x8b\x04\n" + "\x13QuoteInSwapResponse\x12!\n" + "\fpayment_hash\x18\x01 \x01(\fR\vpaymentHash\x12,\n" + "\x12invoice_amount_sat\x18\x02 \x01(\x04R\x10invoiceAmountSat\x12\x1d\n" + @@ -3077,7 +3128,11 @@ const file_swap_proto_rawDesc = "" + "\x0fsettlement_type\x18\x05 \x01(\x0e2\x17.swaprpc.SettlementTypeR\x0esettlementType\x122\n" + "\x06expiry\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\x12&\n" + "\x0fexceeds_max_fee\x18\a \x01(\bR\rexceedsMaxFee\x127\n" + - "\fcredit_quote\x18\b \x01(\v2\x14.swaprpc.CreditQuoteR\vcreditQuote\"\xe7\x01\n" + + "\fcredit_quote\x18\b \x01(\v2\x14.swaprpc.CreditQuoteR\vcreditQuote\x12$\n" + + "\x0eserver_fee_sat\x18\t \x01(\x04R\fserverFeeSat\x129\n" + + "\x19estimated_routing_fee_sat\x18\n" + + " \x01(\x04R\x16estimatedRoutingFeeSat\x123\n" + + "\x16routing_fee_budget_sat\x18\v \x01(\x04R\x13routingFeeBudgetSat\"\xe7\x01\n" + "\vCreditQuote\x12&\n" + "\x0fmust_use_credit\x18\x01 \x01(\bR\rmustUseCredit\x12,\n" + "\x12credit_applied_sat\x18\x02 \x01(\x04R\x10creditAppliedSat\x120\n" + diff --git a/swaprpc/swap.proto b/swaprpc/swap.proto index 7b2b8c647..8103982a4 100644 --- a/swaprpc/swap.proto +++ b/swaprpc/swap.proto @@ -403,6 +403,13 @@ message CreateInSwapResponse { // preimage is set for credit-only pays after the swap server has already // paid the Lightning invoice from reserved credits. bytes preimage = 9; + + // server_fee_sat is the service fee retained by the swap server. + uint64 server_fee_sat = 10; + + // routing_fee_budget_sat is the client-funded Lightning routing + // allowance charged into the vHTLC as part of fee_sat. + uint64 routing_fee_budget_sat = 11; } // QuoteInSwapRequest previews one Ark-to-Lightning invoice send. @@ -452,6 +459,17 @@ message QuoteInSwapResponse { // credit_quote describes how credits would be used for this invoice. CreditQuote credit_quote = 8; + + // server_fee_sat is the service fee retained by the swap server. + uint64 server_fee_sat = 9; + + // estimated_routing_fee_sat is the server's current whole-satoshi + // Lightning route estimate. + uint64 estimated_routing_fee_sat = 10; + + // routing_fee_budget_sat is the client-funded Lightning routing allowance + // that would be charged into the vHTLC as part of fee_sat. + uint64 routing_fee_budget_sat = 11; } // CreditQuote describes the credit component of a pay quote. diff --git a/swapwallet/router.go b/swapwallet/router.go index e98cc90e2..5ad5cba5c 100644 --- a/swapwallet/router.go +++ b/swapwallet/router.go @@ -16,6 +16,7 @@ import ( "github.com/lightninglabs/wavelength/credit" "github.com/lightninglabs/wavelength/rpc/swapclientrpc" "github.com/lightninglabs/wavelength/rpc/wavewalletrpc" + "github.com/lightninglabs/wavelength/sdk/swaps" "github.com/lightninglabs/wavelength/waverpc" "github.com/lightningnetwork/lnd/zpay32" "google.golang.org/grpc/codes" @@ -141,7 +142,7 @@ func (r *router) prepareInvoice(ctx context.Context, invoice string, ctx, &swapclientrpc.QuotePayRequest{ Invoice: invoice, MaxFeeSat: req.GetMaxFeeSat(), - MaxCreditSat: ^uint64(0), + MaxCreditSat: swaps.AllAvailableCredit, }, ) if err != nil { @@ -794,6 +795,9 @@ func prepareInvoicePreviewFromQuote(invoice, description, paymentHash string, paymentHash: quote.GetPaymentHash(), warning: warning, creditPreview: creditPreview, + serverFeeSat: quote.GetServerFeeSat(), + estimatedRoutingFeeSat: quote.GetEstimatedRoutingFeeSat(), + routingFeeBudgetSat: quote.GetRoutingFeeBudgetSat(), }, nil } diff --git a/swapwallet/router_test.go b/swapwallet/router_test.go index 39d1449e7..a8900fb62 100644 --- a/swapwallet/router_test.go +++ b/swapwallet/router_test.go @@ -150,10 +150,13 @@ func TestRouterPrepareSendInvoiceReturnsRemoteQuote(t *testing.T) { r, swap, rpc := newRouterFixture(t) invoice, paymentHash := testPreparedInvoice(t, 12_345, "coffee") swap.quotePayResp = &swapclientrpc.QuotePayResponse{ - PaymentHash: paymentHash, - InvoiceAmountSat: 12_345, - AmountSat: 12_555, - FeeSat: 210, + PaymentHash: paymentHash, + InvoiceAmountSat: 12_345, + AmountSat: 12_555, + FeeSat: 210, + ServerFeeSat: 190, + EstimatedRoutingFeeSat: 4, + RoutingFeeBudgetSat: 20, SettlementType: swapclientrpc. SwapSettlementType_SWAP_SETTLEMENT_TYPE_LIGHTNING, ExpiresAtUnix: time.Now().Add(time.Minute).Unix(), @@ -181,6 +184,9 @@ func TestRouterPrepareSendInvoiceReturnsRemoteQuote(t *testing.T) { require.True(t, resp.GetFeeKnown()) require.True(t, resp.GetTotalOutflowKnown()) require.Equal(t, int64(210), resp.GetExpectedFeeSat()) + require.Equal(t, uint64(190), resp.GetServerFeeSat()) + require.Equal(t, uint64(4), resp.GetEstimatedRoutingFeeSat()) + require.Equal(t, uint64(20), resp.GetRoutingFeeBudgetSat()) require.Equal(t, int64(12_555), resp.GetExpectedTotalOutflowSat()) require.Equal(t, "coffee", resp.GetInvoiceDescription()) require.Equal(t, paymentHash, resp.GetPaymentHash()) diff --git a/swapwallet/send_intents.go b/swapwallet/send_intents.go index ba0254e3a..49c9d1422 100644 --- a/swapwallet/send_intents.go +++ b/swapwallet/send_intents.go @@ -52,6 +52,9 @@ type prepareSendPreview struct { paymentHash string warning string creditPreview *wavewalletrpc.CreditPreview + serverFeeSat uint64 + estimatedRoutingFeeSat uint64 + routingFeeBudgetSat uint64 } type preparedSendStore struct { @@ -181,7 +184,10 @@ func prepareResponseFromIntent(intent *preparedSendIntent, SelectedOutpoints: append( []string(nil), intent.selectedOutpoints..., ), - Warning: preview.warning, - CreditPreview: preview.creditPreview, + Warning: preview.warning, + CreditPreview: preview.creditPreview, + ServerFeeSat: preview.serverFeeSat, + EstimatedRoutingFeeSat: preview.estimatedRoutingFeeSat, + RoutingFeeBudgetSat: preview.routingFeeBudgetSat, } } diff --git a/swapwallet/send_intents_test.go b/swapwallet/send_intents_test.go index ee15003a8..0e2963634 100644 --- a/swapwallet/send_intents_test.go +++ b/swapwallet/send_intents_test.go @@ -42,3 +42,16 @@ func TestPreparedSendStoreRejectsNilIntent(t *testing.T) { require.ErrorIs(t, err, ErrInvalidSendIntent) require.Empty(t, id) } + +// TestPrepareResponseUsesNegotiatedRoutingBudget verifies the response reports +// the routing allowance selected by the server. +func TestPrepareResponseUsesNegotiatedRoutingBudget(t *testing.T) { + t.Parallel() + + resp := prepareResponseFromIntent( + &preparedSendIntent{}, prepareSendPreview{ + routingFeeBudgetSat: 20, + }, + ) + require.Equal(t, uint64(20), resp.GetRoutingFeeBudgetSat()) +}