instantout: enforce the accepted swap fee

Carry the accepted quote into each request, persist it, and reject
invoices above that limit while retaining millisatoshi precision.
This commit is contained in:
Slyghtning 2026-08-11 11:43:16 +02:00
parent cfbf74161b
commit 63a0bccf3d
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
13 changed files with 141 additions and 11 deletions

View file

@ -190,6 +190,7 @@ func instantOut(ctx context.Context, cmd *cli.Command) error {
ReservationIds: selectedReservations,
OutgoingChanSet: outgoingChanSet,
DestAddr: cmd.String("addr"),
MaxSwapFeeSat: quote.ServiceFeeSat,
},
)
if err != nil {

View file

@ -139,7 +139,8 @@
"Mu65fbhayEtRzougKLBnoeRN8f+tEM1+O9QuNvUIfbI="
],
"outgoing_chan_set": [],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "4800"
}
}
},

View file

@ -148,7 +148,8 @@
"outgoing_chan_set": [
"125344325763072"
],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "3200"
}
}
},

View file

@ -162,7 +162,8 @@
"cSfKVONNmsK9+p4Uc5nc3ZtE+37uOODHeq1vprhh/x4="
],
"outgoing_chan_set": [],
"dest_addr": ""
"dest_addr": "",
"max_swap_fee_sat": "1600"
}
}
},

View file

@ -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 (
@ -61,6 +62,7 @@ type InitInstantOutCtx struct {
outgoingChanSet loopdb.ChannelSet
protocolVersion ProtocolVersion
sweepAddress btcutil.Address
maxSwapFee btcutil.Amount
}
// RecoverInstantOutCtx contains the chain height at which an instant out is
@ -84,7 +86,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),
@ -105,7 +107,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)
@ -167,6 +169,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)
@ -194,7 +201,8 @@ func (f *FSM) InitInstantOutAction(ctx context.Context,
CltvExpiry: initCtx.cltvExpiry,
clientPubkey: keyRes.PubKey,
serverPubkey: serverPubkey,
Value: btcutil.Amount(reservationAmt),
Value: reservationAmt,
MaxSwapFee: initCtx.maxSwapFee,
htlcFeeRate: feeRate,
swapInvoice: instantOutResponse.SwapInvoice,
Reservations: reservations,
@ -212,6 +220,31 @@ 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, maxSwapFee btcutil.Amount) error {
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,

View file

@ -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

View file

@ -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"
)
@ -139,3 +140,64 @@ func TestPushPreimageRejectsExpiringReservation(t *testing.T) {
t, instantOutFSM.LastActionError, "before recovery safety height",
)
}
// 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)
)
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: 0,
},
{
name: "negative cap",
invoiceAmount: lnwire.NewMSatFromSatoshis(
swapAmount,
),
maxSwapFee: -1,
expectErr: true,
},
}
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)
})
}
}

View file

@ -138,7 +138,12 @@ 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,
maxSwapFee btcutil.Amount) (*FSM, error) {
if maxSwapFee < 0 {
return nil, fmt.Errorf("maximum swap fee must not be negative")
}
var (
sweepAddr btcutil.Address
@ -161,6 +166,7 @@ func (m *Manager) NewInstantOut(ctx context.Context,
initationHeight: m.currentHeight,
protocolVersion: CurrentProtocolVersion(),
sweepAddress: sweepAddr,
maxSwapFee: maxSwapFee,
}
instantOut, err := NewFSM(m.cfg, ProtocolVersionFullReservation)

View file

@ -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),

View file

@ -1765,6 +1765,7 @@ func (s *swapClientServer) InstantOut(ctx context.Context,
instantOutFsm, err := s.instantOutManager.NewInstantOut(
ctx, reservationIds, req.DestAddr,
btcutil.Amount(req.MaxSwapFeeSat),
)
if err != nil {
return nil, err

View file

@ -4467,7 +4467,9 @@ 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"`
// The maximum off-chain swap fee that may be charged for the swap.
MaxSwapFeeSat int64 `protobuf:"varint,4,opt,name=max_swap_fee_sat,json=maxSwapFeeSat,proto3" json:"max_swap_fee_sat,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4523,6 +4525,13 @@ func (x *InstantOutRequest) GetDestAddr() string {
return ""
}
func (x *InstantOutRequest) GetMaxSwapFeeSat() int64 {
if x != nil {
return x.MaxSwapFeeSat
}
return 0
}
type InstantOutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The hash of the swap preimage.
@ -6964,11 +6973,12 @@ 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\"\xae\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(\x03R\rmaxSwapFeeSat\"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" +

View file

@ -1685,6 +1685,11 @@ message InstantOutRequest {
will be swept to the wallet's internal address.
*/
string dest_addr = 3;
/*
The maximum off-chain swap fee that may be charged for the swap.
*/
int64 max_swap_fee_sat = 4;
}
message InstantOutResponse {

View file

@ -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."
}
}
},