mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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:
parent
cfbf74161b
commit
63a0bccf3d
13 changed files with 141 additions and 11 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,8 @@
|
|||
"Mu65fbhayEtRzougKLBnoeRN8f+tEM1+O9QuNvUIfbI="
|
||||
],
|
||||
"outgoing_chan_set": [],
|
||||
"dest_addr": ""
|
||||
"dest_addr": "",
|
||||
"max_swap_fee_sat": "4800"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@
|
|||
"outgoing_chan_set": [
|
||||
"125344325763072"
|
||||
],
|
||||
"dest_addr": ""
|
||||
"dest_addr": "",
|
||||
"max_swap_fee_sat": "3200"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@
|
|||
"cSfKVONNmsK9+p4Uc5nc3ZtE+37uOODHeq1vprhh/x4="
|
||||
],
|
||||
"outgoing_chan_set": [],
|
||||
"dest_addr": ""
|
||||
"dest_addr": "",
|
||||
"max_swap_fee_sat": "1600"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue