diff --git a/cmd/loop/instantout.go b/cmd/loop/instantout.go index 9783f005..3cbcda08 100644 --- a/cmd/loop/instantout.go +++ b/cmd/loop/instantout.go @@ -183,6 +183,8 @@ func instantOut(ctx context.Context, cmd *cli.Command) error { fmt.Println("Starting instant swap out") + maxSwapFee := quote.ServiceFeeSat + // Now we can request the instant out swap. instantOutRes, err := client.InstantOut( ctx, @@ -190,6 +192,9 @@ func instantOut(ctx context.Context, cmd *cli.Command) error { ReservationIds: selectedReservations, OutgoingChanSet: outgoingChanSet, DestAddr: cmd.String("addr"), + MaxSwapFee: &looprpc.InstantOutRequest_MaxSwapFeeSat{ + MaxSwapFeeSat: maxSwapFee, + }, }, ) if err != nil { diff --git a/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json b/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json index 6dae20b1..257f27c7 100644 --- a/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json +++ b/cmd/loop/testdata/sessions/instantout/02_loop-instantout.json @@ -139,7 +139,8 @@ "Mu65fbhayEtRzougKLBnoeRN8f+tEM1+O9QuNvUIfbI=" ], "outgoing_chan_set": [], - "dest_addr": "" + "dest_addr": "", + "max_swap_fee_sat": "4800" } } }, diff --git a/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json b/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json index 205ae43e..07038f8e 100644 --- a/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json +++ b/cmd/loop/testdata/sessions/instantout/07_loop-instantout-channel.json @@ -148,7 +148,8 @@ "outgoing_chan_set": [ "125344325763072" ], - "dest_addr": "" + "dest_addr": "", + "max_swap_fee_sat": "3200" } } }, diff --git a/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json b/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json index 9a6d103b..89f13c31 100644 --- a/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json +++ b/cmd/loop/testdata/sessions/instantout/08_loop-instantout-select-index.json @@ -162,7 +162,8 @@ "cSfKVONNmsK9+p4Uc5nc3ZtE+37uOODHeq1vprhh/x4=" ], "outgoing_chan_set": [], - "dest_addr": "" + "dest_addr": "", + "max_swap_fee_sat": "1600" } } }, diff --git a/instantout/actions.go b/instantout/actions.go index 2d623811..329add5e 100644 --- a/instantout/actions.go +++ b/instantout/actions.go @@ -20,6 +20,7 @@ import ( "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" ) const ( @@ -65,6 +66,7 @@ type InitInstantOutCtx struct { outgoingChanSet loopdb.ChannelSet protocolVersion ProtocolVersion sweepAddress btcutil.Address + maxSwapFee *btcutil.Amount } // RecoverInstantOutCtx marks an action as being resumed after restart. @@ -85,7 +87,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, } var ( - reservationAmt uint64 + reservationAmt btcutil.Amount reservationIds = make([][]byte, 0, len(initCtx.reservations)) reservations = make( []*reservation.Reservation, 0, len(initCtx.reservations), @@ -106,7 +108,7 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, "locked", reservationId)) } - reservationAmt += uint64(res.Value) + reservationAmt += res.Value reservationIds = append(reservationIds, resId[:]) reservations = append(reservations, res) @@ -168,6 +170,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, return f.HandleError(fmt.Errorf("invalid swap invoice hash: "+ "expected %x got %x", preimage.Hash(), payReq.Hash)) } + if err := validateInstantOutInvoiceAmount( + payReq.Value, reservationAmt, initCtx.maxSwapFee, + ); err != nil { + return f.HandleError(err) + } serverPubkey, err := btcec.ParsePubKey(instantOutResponse.SenderKey) if err != nil { return f.HandleError(err) @@ -186,6 +193,11 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, } // Now we can create the instant out. + var maxSwapFee btcutil.Amount + if initCtx.maxSwapFee != nil { + maxSwapFee = *initCtx.maxSwapFee + } + instantOut := &InstantOut{ SwapHash: swapHash, swapPreimage: preimage, @@ -195,7 +207,8 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, CltvExpiry: initCtx.cltvExpiry, clientPubkey: keyRes.PubKey, serverPubkey: serverPubkey, - Value: btcutil.Amount(reservationAmt), + Value: reservationAmt, + MaxSwapFee: maxSwapFee, htlcFeeRate: feeRate, swapInvoice: instantOutResponse.SwapInvoice, Reservations: reservations, @@ -213,6 +226,37 @@ func (f *FSM) InitInstantOutAction(ctx context.Context, return OnInit } +// validateInstantOutInvoiceAmount verifies that the server invoice doesn't +// charge more than the client-approved swap fee. Sub-satoshi fees are rounded +// up so the cap cannot be bypassed with millisatoshi precision. +func validateInstantOutInvoiceAmount(invoiceAmount lnwire.MilliSatoshi, + swapAmount btcutil.Amount, maxSwapFee *btcutil.Amount) error { + + // Omitting the cap preserves the behavior of clients that predate this + // field. In-tree callers set it explicitly after accepting a quote. + if maxSwapFee == nil { + return nil + } + + if *maxSwapFee < 0 { + return fmt.Errorf("maximum swap fee must not be negative") + } + + swapAmountMsat := lnwire.NewMSatFromSatoshis(swapAmount) + if invoiceAmount <= swapAmountMsat { + return nil + } + + swapFeeMsat := invoiceAmount - swapAmountMsat + swapFeeSat := btcutil.Amount((int64(swapFeeMsat)-1)/1000 + 1) + if swapFeeSat > *maxSwapFee { + return fmt.Errorf("instant out swap fee %d exceeds maximum %d", + swapFeeSat, *maxSwapFee) + } + + return nil +} + // PollPaymentAcceptedAction locks the reservations, sends the payment to the // server and polls the server for the payment status. func (f *FSM) PollPaymentAcceptedAction(ctx context.Context, diff --git a/instantout/instantout.go b/instantout/instantout.go index eade26e0..c700ee91 100644 --- a/instantout/instantout.go +++ b/instantout/instantout.go @@ -60,6 +60,9 @@ type InstantOut struct { // Value is the amount that is swapped. Value btcutil.Amount + // MaxSwapFee is the maximum off-chain swap fee accepted by the client. + MaxSwapFee btcutil.Amount + // keyLocator is the key locator that is used for the swap. keyLocator keychain.KeyLocator diff --git a/instantout/instantout_test.go b/instantout/instantout_test.go index 48195c32..66301a22 100644 --- a/instantout/instantout_test.go +++ b/instantout/instantout_test.go @@ -11,6 +11,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/instantout/reservation" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -189,3 +190,72 @@ func TestPushPreimageRejectsExpiringHtlc(t *testing.T) { "instant out HTLC expires at height 105", ) } + +// TestValidateInstantOutInvoiceAmount verifies enforcement of the fee cap at +// millisatoshi precision. +func TestValidateInstantOutInvoiceAmount(t *testing.T) { + const swapAmount = btcutil.Amount(100_000) + + maxSwapFee := btcutil.Amount(200) + zeroSwapFee := btcutil.Amount(0) + negativeSwapFee := btcutil.Amount(-1) + + tests := []struct { + name string + invoiceAmount lnwire.MilliSatoshi + maxSwapFee *btcutil.Amount + expectErr bool + }{ + { + name: "exact fee cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount + maxSwapFee, + ), + maxSwapFee: &maxSwapFee, + }, + { + name: "one millisatoshi over fee cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount+maxSwapFee, + ) + 1, + maxSwapFee: &maxSwapFee, + expectErr: true, + }, + { + name: "discounted invoice", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount - 1, + ), + maxSwapFee: &zeroSwapFee, + }, + { + name: "negative cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount, + ), + maxSwapFee: &negativeSwapFee, + expectErr: true, + }, + { + name: "omitted cap", + invoiceAmount: lnwire.NewMSatFromSatoshis( + swapAmount + maxSwapFee + 1, + ), + maxSwapFee: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateInstantOutInvoiceAmount( + tc.invoiceAmount, swapAmount, tc.maxSwapFee, + ) + if tc.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/instantout/manager.go b/instantout/manager.go index 0d806251..e9ecb352 100644 --- a/instantout/manager.go +++ b/instantout/manager.go @@ -20,6 +20,20 @@ var ( ErrSwapDoesNotExist = errors.New("swap does not exist") ) +type newInstantOutOptions struct { + maxSwapFee *btcutil.Amount +} + +// NewInstantOutOption customizes an instant out request. +type NewInstantOutOption func(*newInstantOutOptions) + +// WithMaxSwapFee limits the off-chain fee accepted for an instant out. +func WithMaxSwapFee(maxSwapFee btcutil.Amount) NewInstantOutOption { + return func(options *newInstantOutOptions) { + options.maxSwapFee = &maxSwapFee + } +} + // Manager manages the instantout state machines. type Manager struct { sync.Mutex @@ -136,7 +150,21 @@ func (m *Manager) recoverInstantOuts(ctx context.Context) error { // NewInstantOut creates a new instantout. func (m *Manager) NewInstantOut(ctx context.Context, - reservations []reservation.ID, sweepAddress string) (*FSM, error) { + reservations []reservation.ID, sweepAddress string, + options ...NewInstantOutOption) (*FSM, error) { + + requestOptions := &newInstantOutOptions{} + for _, option := range options { + if option != nil { + option(requestOptions) + } + } + + if requestOptions.maxSwapFee != nil && + *requestOptions.maxSwapFee < 0 { + + return nil, fmt.Errorf("maximum swap fee must not be negative") + } var ( sweepAddr btcutil.Address @@ -159,6 +187,7 @@ func (m *Manager) NewInstantOut(ctx context.Context, initationHeight: m.currentHeight, protocolVersion: CurrentProtocolVersion(), sweepAddress: sweepAddr, + maxSwapFee: requestOptions.maxSwapFee, } instantOut, err := NewFSM(m.cfg, ProtocolVersionFullReservation) diff --git a/instantout/store.go b/instantout/store.go index 25d7fe70..0e2a5066 100644 --- a/instantout/store.go +++ b/instantout/store.go @@ -104,7 +104,7 @@ func (s *SQLStore) CreateInstantLoopOut(ctx context.Context, AmountRequested: int64(instantOut.Value), CltvExpiry: instantOut.CltvExpiry, MaxMinerFee: 0, - MaxSwapFee: 0, + MaxSwapFee: int64(instantOut.MaxSwapFee), InitiationHeight: instantOut.initiationHeight, ProtocolVersion: int32(instantOut.protocolVersion), Label: "", @@ -368,6 +368,7 @@ func (s *SQLStore) sqlInstantOutToInstantOut(ctx context.Context, protocolVersion: ProtocolVersion(row.ProtocolVersion), initiationHeight: row.InitiationHeight, Value: btcutil.Amount(row.AmountRequested), + MaxSwapFee: btcutil.Amount(row.MaxSwapFee), keyLocator: keychain.KeyLocator{ Family: keychain.KeyFamily(row.ClientKeyFamily), Index: uint32(row.ClientKeyIndex), diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index a0559d41..497fe3a8 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1763,8 +1763,15 @@ func (s *swapClientServer) InstantOut(ctx context.Context, reservationIds[i] = resId } + var options []instantout.NewInstantOutOption + if req.GetMaxSwapFee() != nil { + options = append(options, instantout.WithMaxSwapFee( + btcutil.Amount(req.GetMaxSwapFeeSat()), + )) + } + instantOutFsm, err := s.instantOutManager.NewInstantOut( - ctx, reservationIds, req.DestAddr, + ctx, reservationIds, req.DestAddr, options..., ) if err != nil { return nil, err diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index ec324648..04b35f1e 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -4467,7 +4467,11 @@ type InstantOutRequest struct { OutgoingChanSet []uint64 `protobuf:"varint,2,rep,packed,name=outgoing_chan_set,json=outgoingChanSet,proto3" json:"outgoing_chan_set,omitempty"` // An optional address to sweep the onchain funds to. If not set, the funds // will be swept to the wallet's internal address. - DestAddr string `protobuf:"bytes,3,opt,name=dest_addr,json=destAddr,proto3" json:"dest_addr,omitempty"` + DestAddr string `protobuf:"bytes,3,opt,name=dest_addr,json=destAddr,proto3" json:"dest_addr,omitempty"` + // Types that are valid to be assigned to MaxSwapFee: + // + // *InstantOutRequest_MaxSwapFeeSat + MaxSwapFee isInstantOutRequest_MaxSwapFee `protobuf_oneof:"max_swap_fee"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4523,6 +4527,36 @@ func (x *InstantOutRequest) GetDestAddr() string { return "" } +func (x *InstantOutRequest) GetMaxSwapFee() isInstantOutRequest_MaxSwapFee { + if x != nil { + return x.MaxSwapFee + } + return nil +} + +func (x *InstantOutRequest) GetMaxSwapFeeSat() int64 { + if x != nil { + if x, ok := x.MaxSwapFee.(*InstantOutRequest_MaxSwapFeeSat); ok { + return x.MaxSwapFeeSat + } + } + return 0 +} + +type isInstantOutRequest_MaxSwapFee interface { + isInstantOutRequest_MaxSwapFee() +} + +type InstantOutRequest_MaxSwapFeeSat struct { + // The maximum off-chain swap fee that may be charged for the swap. If + // this field is omitted, no fee cap is applied for compatibility with + // clients that predate this field. An explicitly set value of zero + // rejects any positive swap fee. + MaxSwapFeeSat int64 `protobuf:"varint,4,opt,name=max_swap_fee_sat,json=maxSwapFeeSat,proto3,oneof"` +} + +func (*InstantOutRequest_MaxSwapFeeSat) isInstantOutRequest_MaxSwapFee() {} + type InstantOutResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The hash of the swap preimage. @@ -6964,11 +6998,13 @@ const file_client_proto_rawDesc = "" + "\x06amount\x18\x03 \x01(\x04R\x06amount\x12\x13\n" + "\x05tx_id\x18\x04 \x01(\tR\x04txId\x12\x12\n" + "\x04vout\x18\x05 \x01(\rR\x04vout\x12\x16\n" + - "\x06expiry\x18\x06 \x01(\rR\x06expiry\"\x85\x01\n" + + "\x06expiry\x18\x06 \x01(\rR\x06expiry\"\xc0\x01\n" + "\x11InstantOutRequest\x12'\n" + "\x0freservation_ids\x18\x01 \x03(\fR\x0ereservationIds\x12*\n" + "\x11outgoing_chan_set\x18\x02 \x03(\x04R\x0foutgoingChanSet\x12\x1b\n" + - "\tdest_addr\x18\x03 \x01(\tR\bdestAddr\"t\n" + + "\tdest_addr\x18\x03 \x01(\tR\bdestAddr\x12)\n" + + "\x10max_swap_fee_sat\x18\x04 \x01(\x03H\x00R\rmaxSwapFeeSatB\x0e\n" + + "\fmax_swap_fee\"t\n" + "\x12InstantOutResponse\x12(\n" + "\x10instant_out_hash\x18\x01 \x01(\fR\x0einstantOutHash\x12\x1e\n" + "\vsweep_tx_id\x18\x02 \x01(\tR\tsweepTxId\x12\x14\n" + @@ -7490,6 +7526,9 @@ func file_client_proto_init() { (*SweepHtlcResponse_Published)(nil), (*SweepHtlcResponse_Failed)(nil), } + file_client_proto_msgTypes[48].OneofWrappers = []any{ + (*InstantOutRequest_MaxSwapFeeSat)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/looprpc/client.proto b/looprpc/client.proto index 844dade7..f2a592f1 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1685,6 +1685,16 @@ message InstantOutRequest { will be swept to the wallet's internal address. */ string dest_addr = 3; + + oneof max_swap_fee { + /* + The maximum off-chain swap fee that may be charged for the swap. If + this field is omitted, no fee cap is applied for compatibility with + clients that predate this field. An explicitly set value of zero + rejects any positive swap fee. + */ + int64 max_swap_fee_sat = 4; + } } message InstantOutResponse { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 1c94febb..57d6dfd6 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1917,6 +1917,11 @@ "dest_addr": { "type": "string", "description": "An optional address to sweep the onchain funds to. If not set, the funds\nwill be swept to the wallet's internal address." + }, + "max_swap_fee_sat": { + "type": "string", + "format": "int64", + "description": "The maximum off-chain swap fee that may be charged for the swap. If\nthis field is omitted, no fee cap is applied for compatibility with\nclients that predate this field. An explicitly set value of zero\nrejects any positive swap fee." } } }, diff --git a/looprpc/client_test.go b/looprpc/client_test.go new file mode 100644 index 00000000..4a19c68f --- /dev/null +++ b/looprpc/client_test.go @@ -0,0 +1,32 @@ +package looprpc + +import ( + "testing" + + "google.golang.org/protobuf/proto" +) + +// TestInstantOutMaxSwapFeePresence verifies that an omitted fee cap remains +// distinguishable from an explicitly encoded zero while retaining the scalar +// field's original wire representation. +func TestInstantOutMaxSwapFeePresence(t *testing.T) { + request := &InstantOutRequest{} + if err := proto.Unmarshal(nil, request); err != nil { + t.Fatalf("unable to unmarshal omitted cap: %v", err) + } + if request.GetMaxSwapFee() != nil { + t.Fatal("omitted cap unexpectedly has presence") + } + + // Field four, encoded as a varint with value zero. This is the same wire + // representation used before the field gained presence semantics. + if err := proto.Unmarshal([]byte{0x20, 0x00}, request); err != nil { + t.Fatalf("unable to unmarshal explicit zero cap: %v", err) + } + if request.GetMaxSwapFee() == nil { + t.Fatal("explicit zero cap lost presence") + } + if request.GetMaxSwapFeeSat() != 0 { + t.Fatalf("expected zero cap, got %d", request.GetMaxSwapFeeSat()) + } +}