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. Preserve compatibility for requests that omit the cap while distinguishing an explicit zero.
This commit is contained in:
parent
b596eabab3
commit
e772f8ccfa
14 changed files with 260 additions and 12 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue