loop/instantout/instantout_test.go
Slyghtning e772f8ccfa
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.
2026-08-11 21:27:35 +02:00

261 lines
6.4 KiB
Go

package instantout
import (
"context"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"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"
)
type invalidFinalSigSigner struct {
lndclient.SignerClient
}
func (s *invalidFinalSigSigner) MuSig2CombineSig(context.Context, [32]byte,
[][]byte) (bool, []byte, error) {
return true, make([]byte, 64), nil
}
type cleanupTrackingSigner struct {
lndclient.SignerClient
cleaned [][32]byte
}
type fixedHeightLightningClient struct {
lndclient.LightningClient
height uint32
err error
}
func (c *fixedHeightLightningClient) GetInfo(context.Context) (
*lndclient.Info, error) {
if c.err != nil {
return nil, c.err
}
return &lndclient.Info{BlockHeight: c.height}, nil
}
func (s *cleanupTrackingSigner) MuSig2Cleanup(_ context.Context,
sessionID [32]byte) error {
s.cleaned = append(s.cleaned, sessionID)
return nil
}
// TestMuSig2VectorLengthValidation verifies that malformed server-controlled
// vectors are rejected before they can be indexed.
func TestMuSig2VectorLengthValidation(t *testing.T) {
_, pubKey := btcec.PrivKeyFromBytes([]byte{1})
instantOut := &InstantOut{
Reservations: []*reservation.Reservation{
{
ClientPubkey: pubKey,
ServerPubkey: pubKey,
Value: btcutil.Amount(100_000),
Expiry: 200,
Outpoint: &wire.OutPoint{},
},
},
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{})
sessions := []*input.MuSig2SessionInfo{{}}
require.NotPanics(t, func() {
_, err := instantOut.signMusig2Tx(
context.Background(), nil, tx, sessions, nil,
)
require.ErrorContains(t, err, "server nonces")
})
require.NotPanics(t, func() {
_, err := instantOut.finalizeMusig2Transaction(
context.Background(), nil, sessions, tx, nil,
)
require.ErrorContains(t, err, "server signatures")
})
}
// TestFinalizeMuSig2TransactionVerifiesSignature verifies that a combined
// signature is validated locally before the transaction can be used as the
// instant-out safety net.
func TestFinalizeMuSig2TransactionVerifiesSignature(t *testing.T) {
_, pubKey := btcec.PrivKeyFromBytes([]byte{1})
res := &reservation.Reservation{
ClientPubkey: pubKey,
ServerPubkey: pubKey,
Value: btcutil.Amount(100_000),
Expiry: 200,
Outpoint: &wire.OutPoint{},
}
instantOut := &InstantOut{
Reservations: []*reservation.Reservation{res},
}
tx := wire.NewMsgTx(2)
tx.AddTxIn(&wire.TxIn{PreviousOutPoint: *res.Outpoint})
tx.AddTxOut(&wire.TxOut{Value: 90_000})
sessions := []*input.MuSig2SessionInfo{{}}
_, err := instantOut.finalizeMusig2Transaction(
context.Background(), &invalidFinalSigSigner{},
sessions, tx, [][]byte{{1}},
)
require.ErrorContains(t, err, "invalid final MuSig2 signature")
require.Nil(t, sessions[0])
}
// TestCleanupMuSig2Sessions verifies that all allocated sessions are released
// while nil entries from partial session creation are skipped.
func TestCleanupMuSig2Sessions(t *testing.T) {
firstID := [32]byte{1}
secondID := [32]byte{2}
signer := &cleanupTrackingSigner{}
err := cleanupMuSig2Sessions(
t.Context(), signer, []*input.MuSig2SessionInfo{
{SessionID: firstID}, nil, {SessionID: secondID},
},
)
require.NoError(t, err)
require.Equal(t, [][32]byte{firstID, secondID}, signer.cleaned)
}
// TestPushPreimageRejectsExpiringReservation verifies that recovery takes the
// on-chain fallback before revealing the preimage when a reservation is too
// close to its server-controlled timeout.
func TestPushPreimageRejectsExpiringReservation(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: &fixedHeightLightningClient{height: 100},
},
InstantOut: &InstantOut{
CltvExpiry: 200,
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 139,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError, "before recovery safety height",
)
}
// TestPushPreimageRejectsExpiringHtlc verifies that recovery uses a fresh
// chain height and leaves time to confirm both fallback transactions.
func TestPushPreimageRejectsExpiringHtlc(t *testing.T) {
instantOutFSM := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: &fixedHeightLightningClient{height: 100},
},
InstantOut: &InstantOut{
CltvExpiry: 105,
Reservations: []*reservation.Reservation{
{
ID: reservation.ID{1},
Expiry: 200,
},
},
},
}
event := instantOutFSM.PushPreimageAction(
t.Context(), &RecoverInstantOutCtx{},
)
require.Equal(t, OnErrorPublishHtlc, event)
require.ErrorContains(
t, instantOutFSM.LastActionError,
"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)
})
}
}