loop/staticaddr/loopin/actions_test.go
Slyghtning c718e9a9fb
staticaddr/loopin: retry failed deposit state transitions
Keep the loop-in monitor in its recoverable state when a required deposit transition or unlock fails. Only advance after every selected deposit reaches the expected state, and retry only deposits that remain pending after a partial transition.

Preserve shutdown semantics when observer cancellation races with a completed deposit update, and add regression coverage for transition, partial-transition, and unlock failures.
2026-07-15 16:52:39 +02:00

3511 lines
93 KiB
Go

package loopin
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
const testTimeout = 5 * time.Second
// TestHandleInvoiceUpdate verifies that invoice state updates map to the
// monitor events expected by the static address loop-in FSM.
func TestHandleInvoiceUpdate(t *testing.T) {
t.Parallel()
swapHash := lntypes.Hash{1, 2, 3}
tests := []struct {
name string
state invoices.ContractState
event fsm.EventType
done bool
errString string
}{
{
name: "open",
state: invoices.ContractOpen,
event: fsm.NoOp,
},
{
name: "accepted",
state: invoices.ContractAccepted,
event: fsm.NoOp,
},
{
name: "settled",
state: invoices.ContractSettled,
event: OnPaymentReceived,
done: true,
},
{
name: "canceled",
state: invoices.ContractCanceled,
event: fsm.NoOp,
},
{
name: "unexpected",
state: invoices.ContractState(99),
event: fsm.NoOp,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
f := &FSM{
StateMachine: &fsm.StateMachine{},
loopIn: &StaticAddressLoopIn{
SwapHash: swapHash,
},
}
event, done := f.handleInvoiceUpdate(
lndclient.InvoiceUpdate{
Invoice: lndclient.Invoice{
State: test.state,
},
},
)
require.Equal(t, test.event, event)
require.Equal(t, test.done, done)
if test.errString == "" {
require.Nil(t, f.LastActionError)
} else {
require.ErrorContains(
t, f.LastActionError, test.errString,
)
require.ErrorContains(
t, f.LastActionError, fmt.Sprint(swapHash),
)
}
})
}
}
// TestMonitorInvoiceSettledWinsOverRecoveredRiskRejection verifies that an
// authoritative settled state takes precedence over a persisted server risk
// rejection during recovery.
func TestMonitorInvoiceSettledWinsOverRecoveredRiskRejection(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{1, 2, 5}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractSettled,
})
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionRejected,
mockLnd.LndServices.Invoices,
)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case event := <-resultChan:
require.Equal(t, OnPaymentReceived, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.Empty(t, depositMgr.transitions)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("settled invoice was canceled: %v", hash)
default:
}
}
// TestMonitorInvoiceCancelErrorKeepsMonitoring verifies that cancellation
// failures neither unlock deposits nor stop invoice monitoring. Recovery
// rechecks the authoritative invoice state, and a later settlement wins.
func TestMonitorInvoiceCancelErrorKeepsMonitoring(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{1, 2, 6}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
cancelCalls := make(chan lntypes.Hash, 2)
releaseCancel := make(chan struct{})
invoicesClient := &failingCancelInvoices{
InvoicesClient: mockLnd.LndServices.Invoices,
cancelCalls: cancelCalls,
release: releaseCancel,
err: errors.New("invoice backend unavailable"),
}
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionRejected,
invoicesClient,
)
f.ActionEntryFunc = nil
resultChan := make(chan error, 1)
go func() {
resultChan <- f.SendEvent(ctx, OnRecover, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-cancelCalls:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("cancellation attempt not received: %v", ctx.Err())
}
select {
case transition := <-depositMgr.transitionChan:
t.Fatalf("deposits unlocked after cancellation error: %v",
transition)
default:
}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractSettled,
})
close(releaseCancel)
select {
case err := <-resultChan:
require.NoError(t, err)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.Equal(t, []fsm.StateType{deposit.LoopedIn}, depositMgr.states)
}
// TestMonitorInvoiceUnknownStateKeepsMonitoring verifies that a failed lookup
// and an unknown subscription state do not release deposits. A subsequent
// authoritative settlement still completes the swap.
func TestMonitorInvoiceUnknownStateKeepsMonitoring(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{1, 2, 7}
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone,
mockLnd.LndServices.Invoices,
)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
var invoiceSub *test.SingleInvoiceSubscription
select {
case invoiceSub = <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
select {
case <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
invoiceSub.Update <- lndclient.InvoiceUpdate{
Invoice: lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractState(99),
},
}
select {
case event := <-resultChan:
t.Fatalf("unknown invoice state ended monitor with %v", event)
case transition := <-depositMgr.transitionChan:
t.Fatalf("unknown invoice state unlocked deposits: %v", transition)
case <-time.After(100 * time.Millisecond):
}
invoiceSub.Update <- lndclient.InvoiceUpdate{
Invoice: lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractSettled,
},
}
select {
case event := <-resultChan:
require.Equal(t, OnPaymentReceived, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.Empty(t, depositMgr.transitions)
}
// TestMonitorInvoiceSetupFailureRecoversSettledInvoice verifies that a
// transient subscription failure cannot route an already-settled swap through
// deposit cleanup before the authoritative invoice lookup runs.
func TestMonitorInvoiceSetupFailureRecoversSettledInvoice(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{1, 2, 8}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractSettled,
})
invoicesClient := &flakySubscribeInvoices{
InvoicesClient: mockLnd.LndServices.Invoices,
err: errors.New("invoice backend unavailable"),
}
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionRejected,
invoicesClient,
)
f.ActionEntryFunc = nil
resultChan := make(chan error, 1)
go func() {
resultChan <- f.SendEvent(ctx, OnRecover, nil)
}()
select {
case err := <-resultChan:
require.NoError(t, err)
case <-ctx.Done():
t.Fatalf("monitor did not recover: %v", ctx.Err())
}
require.Equal(t, 1, invoicesClient.subscribeCalls)
require.Equal(t, []fsm.StateType{deposit.LoopedIn}, depositMgr.states)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("settled invoice was canceled: %v", hash)
default:
}
}
// TestMonitorInvoiceStreamErrorRecoversSettlement verifies that a dead invoice
// stream checks the latest invoice state before entering recovery.
func TestMonitorInvoiceStreamErrorRecoversSettlement(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{1, 2, 9}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone,
mockLnd.LndServices.Invoices,
)
lookupStarted := make(chan struct{})
releaseLookup := make(chan struct{})
f.cfg.LndClient = &firstLookupBarrier{
LightningClient: mockLnd.Client,
lookupStarted: lookupStarted,
release: releaseLookup,
}
f.ActionEntryFunc = nil
resultChan := make(chan error, 1)
go func() {
resultChan <- f.SendEvent(ctx, OnRecover, nil)
}()
var invoiceSub *test.SingleInvoiceSubscription
select {
case invoiceSub = <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
select {
case <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
select {
case <-lookupStarted:
case <-ctx.Done():
t.Fatalf("initial invoice lookup not received: %v", ctx.Err())
}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractSettled,
})
close(releaseLookup)
select {
case invoiceSub.Err <- errors.New("invoice stream failed"):
case <-ctx.Done():
t.Fatalf("invoice stream error was not consumed: %v", ctx.Err())
}
select {
case err := <-resultChan:
require.NoError(t, err)
case <-ctx.Done():
t.Fatalf("monitor did not recover: %v", ctx.Err())
}
require.Equal(t, []fsm.StateType{deposit.LoopedIn}, depositMgr.states)
}
// TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr ensures that an error from
// the HTLC confirmation subscription triggers a re-registration. Without the
// regression fix, only the initial registration would be performed and the
// test would time out waiting for the second one.
func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{1, 2, 3}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now().Add(-time.Hour),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
// Seed the mock invoice store so LookupInvoice succeeds.
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
// Capture the invoice subscription the action registers so we can feed
// an update later and let the action exit.
var invSub *test.SingleInvoiceSubscription
select {
case invSub = <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
// The first confirmation registration should happen immediately.
var firstReg *test.ConfRegistration
select {
case firstReg = <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
// Force the confirmation stream to error so the FSM re-registers.
firstReg.ErrChan <- errors.New("test htlc conf error")
// FSM registers again, otherwise it would time out.
var secondReg *test.ConfRegistration
select {
case secondReg = <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf was not re-registered: %v", ctx.Err())
}
require.NotEqual(t, firstReg, secondReg)
// Settle the invoice to let the action exit.
invSub.Update <- lndclient.InvoiceUpdate{
Invoice: lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractSettled,
},
}
select {
case event := <-resultChan:
require.Equal(t, OnPaymentReceived, event)
case <-ctx.Done():
t.Fatalf("fsm did not return: %v", ctx.Err())
}
}
// TestMonitorInvoiceAndHtlcTxNoOpOnShutdown ensures that a shutdown while the
// client is monitoring an HTLC-signed loop-in keeps the swap resumable instead
// of entering the generic unlock path.
func TestMonitorInvoiceAndHtlcTxNoOpOnShutdown(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
runCtx, stop := context.WithCancel(ctx)
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{4, 5, 6}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.Invoices[swapHash] = &lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
}
depositMgr := &recordingDepositManager{}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(runCtx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(runCtx, nil)
}()
select {
case <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
select {
case <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
stop()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.Nil(t, f.LastActionError)
require.Empty(t, depositMgr.transitions)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled on shutdown: %v", hash)
default:
}
}
// TestSweepHtlcTimeoutActionNoOpOnShutdown ensures that a shutdown during
// timeout sweep publication keeps the FSM in the same state so it can resume
// after restart.
func TestSweepHtlcTimeoutActionNoOpOnShutdown(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
cancel()
mockLnd := test.NewMockLnd()
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
LndClient: mockLnd.Client,
WalletKit: mockLnd.WalletKit,
},
loopIn: &StaticAddressLoopIn{},
}
event := f.SweepHtlcTimeoutAction(ctx, nil)
require.Equal(t, fsm.NoOp, event)
require.Nil(t, f.LastActionError)
}
// TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown ensures that a shutdown
// while waiting for the timeout sweep confirmation keeps the FSM resumable.
func TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
sweepAddr, err := mockLnd.WalletKit.NextAddr(ctx, "", 0, false)
require.NoError(t, err)
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
ChainNotifier: mockLnd.ChainNotifier,
},
loopIn: &StaticAddressLoopIn{
HtlcTimeoutSweepAddress: sweepAddr,
InitiationHeight: uint32(mockLnd.Height),
},
}
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorHtlcTimeoutSweepAction(ctx, nil)
}()
select {
case <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("timeout sweep conf registration not received: %v",
ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
require.Nil(t, f.LastActionError)
case <-time.After(testTimeout):
t.Fatal("timeout sweep monitor did not return")
}
}
// TestMonitorInvoiceAndHtlcTxShutdownDoesNotUnlock verifies that daemon
// shutdown exits the monitor action without treating the swap as failed.
func TestMonitorInvoiceAndHtlcTxShutdownDoesNotUnlock(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
runCtx, stop := context.WithCancel(ctx)
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{1, 2, 4}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now().Add(-time.Hour),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
Deposits: []*deposit.Deposit{{
Value: 200_000,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
depositMgr := &recordingDepositManager{
transitionChan: make(chan depositTransition, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(runCtx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(runCtx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
stop()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.NoError(t, f.LastActionError)
select {
case transition := <-depositMgr.transitionChan:
t.Fatalf("deposit transition on shutdown: %v", transition)
default:
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled on shutdown: %v", hash)
default:
}
}
// TestInitHtlcActionPreservesRouteHints asserts that static-address loop-in
// propagates explicit route hints into the encoded swap invoice sent to the
// server.
func TestInitHtlcActionPreservesRouteHints(t *testing.T) {
t.Parallel()
mockLnd := test.NewMockLnd()
_, serverKey := test.CreateKey(21)
server := &mockStaticAddressServer{
response: testStaticAddressLoopInResponse(
serverKey.SerializeCompressed(),
),
}
dep := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
},
Value: 500_000,
}
loopIn := &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{dep},
DepositOutpoints: []string{dep.OutPoint.String()},
SelectedAmount: dep.Value,
QuotedSwapFee: 1_000,
RouteHints: testStaticAddressRouteHints(),
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
PaymentTimeoutSeconds: 3_600,
}
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
Server: server,
DepositManager: &noopDepositManager{},
LndClient: mockLnd.Client,
WalletKit: mockLnd.WalletKit,
ChainParams: mockLnd.ChainParams,
Store: &mockStore{},
ValidateLoopInContract: testValidateLoopInContract,
MaxStaticAddrHtlcFeePercentage: 1,
MaxStaticAddrHtlcBackupFeePercentage: 1,
},
loopIn: loopIn,
}
event := f.InitHtlcAction(t.Context(), nil)
require.Equal(t, OnHtlcInitiated, event)
require.Nil(t, f.LastActionError)
require.NotNil(t, server.request)
_, routeHints, _, _, err := swap.DecodeInvoice(
mockLnd.ChainParams, server.request.SwapInvoice,
)
require.NoError(t, err)
test.RequireRouteHintsEqual(t, loopIn.RouteHints, routeHints)
}
func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) {
dep := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{0x77},
Index: 2,
},
Value: 200_000,
}
checker := &recordingTxOutChecker{}
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
TxOutChecker: checker,
},
loopIn: &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{dep},
},
}
event := f.SignHtlcTxAction(t.Context(), nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "deposit "+
dep.OutPoint.String()+" is no longer available",
)
require.Equal(t, [][]wire.OutPoint{{dep.OutPoint}}, checker.outpoints)
}
func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints(
t *testing.T) {
currentOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0x89},
Index: 1,
}
snapshotOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0x88},
Index: 0,
}
checker := &recordingTxOutChecker{}
f := &FSM{
cfg: &Config{
TxOutChecker: checker,
},
loopIn: &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{{
OutPoint: currentOutpoint,
Value: 200_000,
}},
DepositOutpoints: []string{snapshotOutpoint.String()},
},
}
err := f.checkDepositsAvailable(t.Context())
require.ErrorContains(t, err, "deposit outpoint snapshot mismatch")
require.Empty(t, checker.outpoints)
}
// mockStaticAddressServer captures static-address loop-in requests in tests.
type mockStaticAddressServer struct {
swapserverrpc.StaticAddressServerClient
request *swapserverrpc.ServerStaticAddressLoopInRequest
response *swapserverrpc.ServerStaticAddressLoopInResponse
}
// ServerStaticAddressLoopIn records the request and returns the prepared
// response.
func (m *mockStaticAddressServer) ServerStaticAddressLoopIn(
_ context.Context, in *swapserverrpc.ServerStaticAddressLoopInRequest,
_ ...grpc.CallOption) (*swapserverrpc.ServerStaticAddressLoopInResponse,
error) {
m.request = in
return m.response, nil
}
// testStaticAddressLoopInResponse returns a minimal successful server response
// for InitHtlcAction tests.
func testStaticAddressLoopInResponse(
serverPubKey []byte) *swapserverrpc.ServerStaticAddressLoopInResponse {
signingInfo := &swapserverrpc.ServerHtlcSigningInfo{
FeeRate: 1,
}
return &swapserverrpc.ServerStaticAddressLoopInResponse{
HtlcServerPubKey: serverPubKey,
HtlcExpiry: 1_000,
StandardHtlcInfo: signingInfo,
HighFeeHtlcInfo: signingInfo,
ExtremeFeeHtlcInfo: signingInfo,
}
}
// testStaticAddressRouteHints returns deterministic route hints for static
// loop-in invoice regression tests.
func testStaticAddressRouteHints() [][]zpay32.HopHint {
_, pubKey1 := test.CreateKey(31)
_, pubKey2 := test.CreateKey(32)
_, pubKey3 := test.CreateKey(33)
return [][]zpay32.HopHint{
{
{
NodeID: pubKey1,
ChannelID: 11,
FeeBaseMSat: 101,
FeeProportionalMillionths: 201,
CLTVExpiryDelta: 31,
},
{
NodeID: pubKey2,
ChannelID: 12,
FeeBaseMSat: 102,
FeeProportionalMillionths: 202,
CLTVExpiryDelta: 32,
},
},
{
{
NodeID: pubKey3,
ChannelID: 13,
FeeBaseMSat: 103,
FeeProportionalMillionths: 203,
CLTVExpiryDelta: 33,
},
},
}
}
// testValidateLoopInContract accepts all server contract parameters in tests.
func testValidateLoopInContract(_ int32, _ int32) error {
return nil
}
// TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline verifies that the
// payment timeout starts on risk acceptance and keeps confirmed HTLC deposits
// locked for timeout sweeping.
func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{4, 5, 6}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{7},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now().Add(-time.Hour),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
notificationMgr := &mockNotificationManager{
riskAccepted: make(
chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification, 1,
),
}
depositMgr := &recordingDepositManager{
transitionChan: make(chan depositTransition, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
select {
case <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
var confRegistration *test.ConfRegistration
select {
case confRegistration = <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
confRegistration.ConfChan <- nil
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled before risk acceptance: %v", hash)
case <-time.After(200 * time.Millisecond):
}
notificationMgr.riskAccepted <- &swapserverrpc.ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled immediately after risk acceptance: %v",
hash)
case <-time.After(200 * time.Millisecond):
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
select {
case transition := <-depositMgr.transitionChan:
require.Equal(t, []*deposit.Deposit{
loopIn.Deposits[0],
}, transition.deposits)
require.Equal(t, deposit.OnSweepingHtlcTimeout, transition.event)
require.Equal(t, deposit.SweepHtlcTimeout, transition.state)
case <-ctx.Done():
t.Fatalf("deposits were not locked for timeout sweeping: %v",
ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxIgnoresWrongHashRiskNotifications verifies that
// risk notifications for another swap do not start the payment deadline or
// persist a decision through the monitor action.
func TestMonitorInvoiceAndHtlcTxIgnoresWrongHashRiskNotifications(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{4, 5, 8}
otherHash := lntypes.Hash{8, 5, 4}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{9},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
notificationMgr := &mockNotificationManager{
riskAccepted: make(
chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification, 2,
),
riskRejected: make(
chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification, 1,
),
}
store := &recordingRiskStore{
mockStore: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: {},
},
},
decisions: make(chan ConfirmationRiskDecision, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
Store: store,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
notificationMgr.riskAccepted <- &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: otherHash[:],
}
notificationMgr.riskRejected <- &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: otherHash[:],
}
select {
case decision := <-store.decisions:
t.Fatalf("persisted wrong-hash risk decision: %v", decision)
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("canceled invoice for wrong-hash risk decision: %v", hash)
case event := <-resultChan:
t.Fatalf("monitor action exited after wrong-hash risk decision: %v",
event)
case <-time.After(200 * time.Millisecond):
}
notificationMgr.riskAccepted <- &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
}
select {
case decision := <-store.decisions:
require.Equal(t, ConfirmationRiskDecisionAccepted, decision)
case <-ctx.Done():
t.Fatalf("risk decision was not persisted: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime verifies that live
// risk notifications use the durable receipt time, not the local channel
// receive time, when reconstructing the payment deadline.
func TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{4, 5, 7}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{8},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
notificationMgr := &mockNotificationManager{
riskAccepted: make(
chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification, 1,
),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
Store: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: {
ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted,
ConfirmationRiskDecisionTime: time.Now().Add(
-time.Minute,
),
},
},
},
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled before risk acceptance: %v", hash)
case <-time.After(200 * time.Millisecond):
}
notificationMgr.riskAccepted <- &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxPersistsReplayedRiskAccepted verifies that a risk
// notification replayed after the swap row exists is written back to the store.
func TestMonitorInvoiceAndHtlcTxPersistsReplayedRiskAccepted(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{5, 6, 10}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{14},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
notificationMgr := &mockNotificationManager{
riskAccepted: make(
chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification, 1,
),
}
store := &recordingRiskStore{
mockStore: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: {},
},
},
decisions: make(chan ConfirmationRiskDecision, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
Store: store,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
notificationMgr.riskAccepted <- &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
}
select {
case decision := <-store.decisions:
require.Equal(t, ConfirmationRiskDecisionAccepted, decision)
case <-ctx.Done():
t.Fatalf("risk decision was not persisted: %v", ctx.Err())
}
stored := store.loopIns[swapHash]
require.Equal(t, ConfirmationRiskDecisionAccepted,
stored.ConfirmationRiskDecision)
require.False(t, stored.ConfirmationRiskDecisionTime.IsZero())
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxPersistsRiskRejected verifies that a server-side
// confirmation risk rejection is persisted and exits through the generic error
// path so the FSM unlocks deposits.
func TestMonitorInvoiceAndHtlcTxPersistsRiskRejected(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{5, 6, 7}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{9},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: mockLnd.Height,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
notificationMgr := &mockNotificationManager{
riskRejected: make(
chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification, 1,
),
}
store := &recordingRiskStore{
mockStore: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: {},
},
},
decisions: make(chan ConfirmationRiskDecision, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
Store: store,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
notificationMgr.riskRejected <- &swapserverrpc.ServerStaticLoopInRiskRejectedNotification{ // nolint: lll
SwapHash: swapHash[:],
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
select {
case decision := <-store.decisions:
require.Equal(t, ConfirmationRiskDecisionRejected, decision)
case <-ctx.Done():
t.Fatalf("risk decision was not persisted: %v", ctx.Err())
}
stored := store.loopIns[swapHash]
require.Equal(t, ConfirmationRiskDecisionRejected,
stored.ConfirmationRiskDecision)
require.False(t, stored.ConfirmationRiskDecisionTime.IsZero())
select {
case event := <-resultChan:
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError,
"server rejected confirmation risk wait",
)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxRecoversAcceptedRiskDecision verifies that a
// persisted risk acceptance restarts the payment deadline with elapsed time
// preserved after restart.
func TestMonitorInvoiceAndHtlcTxRecoversAcceptedRiskDecision(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{5, 6, 8}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{12},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted,
ConfirmationRiskDecisionTime: time.Now().Add(-time.Minute),
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
depositMgr := &recordingDepositManager{
transitionChan: make(chan depositTransition, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
select {
case transition := <-depositMgr.transitionChan:
require.Equal(t, fsm.OnError, transition.event)
require.Equal(t, deposit.Deposited, transition.state)
case <-ctx.Done():
t.Fatalf("deposits were not unlocked: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision verifies that a
// persisted risk rejection still cancels after restart and exits through the
// generic error path so the FSM unlocks deposits.
func TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{5, 6, 9}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{13},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: mockLnd.Height,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
ConfirmationRiskDecision: ConfirmationRiskDecisionRejected,
ConfirmationRiskDecisionTime: time.Now(),
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
depositMgr := &recordingDepositManager{
transitionChan: make(chan depositTransition, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
select {
case event := <-resultChan:
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError,
"server rejected confirmation risk wait",
)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes
// verifies that once the monitor state is reached, a missing original deposit
// outpoint does not cancel the invoice. After HTLC signatures are handed to the
// server, the outpoint can disappear because the server published the expected
// HTLC transaction.
func TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{5, 7, 9}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{10},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
txOutChecker := &recordingTxOutChecker{}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice should not have been canceled: %v", hash)
case <-time.After(200 * time.Millisecond):
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
require.Empty(t, txOutChecker.outpoints)
}
// TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint
// verifies that the outpoint-vanished fallback is only active before payment
// has started. Once the invoice is accepted, the original deposit may disappear
// because the server has moved forward with the swap.
func TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{6, 8, 10}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{11},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractAccepted,
})
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
TxOutChecker: &recordingTxOutChecker{},
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice should not have been canceled: %v", hash)
case <-time.After(200 * time.Millisecond):
}
cancel()
select {
case <-resultChan:
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs verifies that the
// monitor action preserves the legacy payment deadline fallback when no risk
// decision has been observed locally.
func TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{7, 8, 9}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{8},
Index: 0,
}
depositRecord := &deposit.Deposit{
OutPoint: depositOutpoint,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{depositRecord},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{
deposits: []*deposit.Deposit{depositRecord},
},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled before deposit confirmation: %v", hash)
case <-time.After(200 * time.Millisecond):
}
confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1
depositRecord.Lock()
depositRecord.ConfirmationHeight = confirmationHeight
depositRecord.Unlock()
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height))
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled immediately after deposit "+
"confirmation: %v", hash)
case <-time.After(200 * time.Millisecond):
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager
// verifies that the legacy payment deadline fallback still applies when the
// notification manager is configured but no risk decision has been observed.
func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{7, 8, 10}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{9},
Index: 0,
}
depositRecord := &deposit.Deposit{
OutPoint: depositOutpoint,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now().Add(-time.Hour),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{depositRecord},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
notificationMgr := &mockNotificationManager{
riskAccepted: make(
chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification,
1,
),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{
deposits: []*deposit.Deposit{depositRecord},
},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
NotificationManager: notificationMgr,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1
depositRecord.Lock()
depositRecord.ConfirmationHeight = confirmationHeight
depositRecord.Unlock()
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height))
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled before payment deadline: %v", hash)
case <-time.After(200 * time.Millisecond):
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight verifies that
// recovery can arm the legacy payment deadline without waiting for a later
// block notification.
func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{7, 8, 12}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{13},
Index: 0,
}
staleDeposit := &deposit.Deposit{
OutPoint: depositOutpoint,
ConfirmationHeight: 0,
}
confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1
freshDeposit := &deposit.Deposit{
OutPoint: depositOutpoint,
ConfirmationHeight: confirmationHeight,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now().Add(-time.Hour),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{staleDeposit},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
store := &recordingRiskStore{
mockStore: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: {},
},
},
decisions: make(chan ConfirmationRiskDecision, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: &silentBlockChainNotifier{
ChainNotifierClient: mockLnd.ChainNotifier,
},
DepositManager: &noopDepositManager{
deposits: []*deposit.Deposit{freshDeposit},
},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
Store: store,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
select {
case decision := <-store.decisions:
require.Equal(t, ConfirmationRiskDecisionAccepted, decision)
case <-ctx.Done():
t.Fatalf("legacy fallback decision was not persisted: %v",
ctx.Err())
}
require.Equal(t, ConfirmationRiskDecisionAccepted,
store.loopIns[swapHash].ConfirmationRiskDecision)
require.False(t,
store.loopIns[swapHash].ConfirmationRiskDecisionTime.IsZero())
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled before payment deadline: %v", hash)
case <-time.After(200 * time.Millisecond):
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestMonitorInvoiceAndHtlcTxRefreshesDepositsForLegacyFallback verifies that a
// recovered monitor state does not rely on stale selected-deposit snapshots when
// deciding whether the legacy payment deadline fallback has opened.
func TestMonitorInvoiceAndHtlcTxRefreshesDepositsForLegacyFallback(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{7, 8, 11}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{12},
Index: 0,
}
staleDeposit := &deposit.Deposit{
OutPoint: depositOutpoint,
ConfirmationHeight: 0,
}
confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1
freshDeposit := &deposit.Deposit{
OutPoint: depositOutpoint,
ConfirmationHeight: confirmationHeight,
}
type depositLookup struct {
outpoints []string
ignoreUnknown bool
}
depositLookups := make(chan depositLookup, 1)
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: 2_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now().Add(-time.Hour),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 1,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{staleDeposit},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: &noopDepositManager{
deposits: []*deposit.Deposit{freshDeposit},
depositsForOutpoints: func(outpoints []string,
ignoreUnknown bool) {
select {
case depositLookups <- depositLookup{
outpoints: append(
[]string(nil), outpoints...,
),
ignoreUnknown: ignoreUnknown,
}:
default:
}
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height))
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice canceled immediately after deposit "+
"confirmation: %v", hash)
case <-time.After(200 * time.Millisecond):
}
select {
case lookup := <-depositLookups:
require.Equal(t, []string{
depositOutpoint.String(),
}, lookup.outpoints)
require.False(t, lookup.ignoreUnknown)
case <-ctx.Done():
t.Fatalf("deposit refresh was not called: %v", ctx.Err())
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
}
// TestLegacyConfirmationFallbackStopsOnFreshnessFailure verifies that MinConfs
// is not evaluated from cached deposits when the wallet reconciliation fails.
func TestLegacyConfirmationFallbackStopsOnFreshnessFailure(t *testing.T) {
outpoint := wire.OutPoint{
Hash: chainhash.Hash{14},
Index: 0,
}
lookupCalled := false
depositManager := &noopDepositManager{
deposits: []*deposit.Deposit{{
OutPoint: outpoint,
ConfirmationHeight: 1,
}},
ensureFreshErr: errors.New("wallet unavailable"),
depositsForOutpoints: func([]string, bool) {
lookupCalled = true
},
}
f := &FSM{
cfg: &Config{
DepositManager: depositManager,
},
loopIn: &StaticAddressLoopIn{
DepositOutpoints: []string{outpoint.String()},
},
}
reached := f.shouldStartLegacyConfirmationFallback(
t.Context(), deposit.MinConfs,
)
require.False(t, reached)
require.False(t, lookupCalled)
}
// TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline verifies that
// deposits are unlocked even if the payment deadline never started before the
// HTLC timeout path opened.
func TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{10, 11, 12}
depositOutpoint := wire.OutPoint{
Hash: chainhash.Hash{10},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: mockLnd.Height,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
DepositOutpoints: []string{
depositOutpoint.String(),
},
Deposits: []*deposit.Deposit{{
OutPoint: depositOutpoint,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
depositMgr := &recordingDepositManager{}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
select {
case event := <-resultChan:
require.Equal(t, OnSwapTimedOut, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.Equal(t, []fsm.EventType{fsm.OnError}, depositMgr.events)
require.Equal(t, []fsm.StateType{deposit.Deposited}, depositMgr.states)
}
// TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails
// verifies that a failed deposit timeout transition keeps the loop-in in its
// recoverable monitor state.
func TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{20, 21, 22}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractCanceled,
})
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone,
mockLnd.LndServices.Invoices,
)
f.loopIn.HtlcCltvExpiry = mockLnd.Height
depositMgr.err = errors.New("transition failed")
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
select {
case <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
var confRegistration *test.ConfRegistration
select {
case confRegistration = <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
confRegistration.ConfChan <- nil
select {
case transition := <-depositMgr.transitionChan:
require.Equal(t, deposit.OnSweepingHtlcTimeout, transition.event)
require.Equal(t, deposit.SweepHtlcTimeout, transition.state)
case <-ctx.Done():
t.Fatalf("deposit timeout transition was not attempted: %v",
ctx.Err())
}
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
select {
case event := <-resultChan:
require.Equal(t, OnRecover, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
}
// TestMonitorInvoiceAndHtlcTxRetriesOnlyPendingTimeoutDeposits verifies that a
// retry after a partial timeout transition skips deposits that already reached
// the target state.
func TestMonitorInvoiceAndHtlcTxRetriesOnlyPendingTimeoutDeposits(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{26, 27, 28}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractCanceled,
})
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone,
mockLnd.LndServices.Invoices,
)
f.loopIn.HtlcCltvExpiry = mockLnd.Height
firstDeposit := f.loopIn.Deposits[0]
secondDeposit := &deposit.Deposit{Value: 300_000}
firstDeposit.SetState(deposit.LoopingIn)
secondDeposit.SetState(deposit.LoopingIn)
f.loopIn.Deposits = append(f.loopIn.Deposits, secondDeposit)
attempts := 0
depositMgr.transition = func(deposits []*deposit.Deposit,
_ fsm.EventType, state fsm.StateType) error {
attempts++
if attempts == 1 {
deposits[0].SetState(state)
return errors.New("partial transition")
}
for _, d := range deposits {
d.SetState(state)
}
return nil
}
depositMgr.transitionChan = make(chan depositTransition, 2)
runMonitor := func(runCtx context.Context) chan fsm.EventType {
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(runCtx, nil)
}()
select {
case <-mockLnd.SingleInvoiceSubcribeChannel:
case <-runCtx.Done():
t.Fatalf("invoice subscription not registered: %v",
runCtx.Err())
}
var confRegistration *test.ConfRegistration
select {
case confRegistration = <-mockLnd.RegisterConfChannel:
case <-runCtx.Done():
t.Fatalf("htlc conf registration not received: %v",
runCtx.Err())
}
confRegistration.ConfChan <- nil
return resultChan
}
firstCtx, cancelFirst := context.WithCancel(ctx)
firstResult := runMonitor(firstCtx)
select {
case event := <-firstResult:
require.Equal(t, OnRecover, event)
case <-ctx.Done():
t.Fatalf("first monitor attempt did not exit: %v", ctx.Err())
}
cancelFirst()
firstTransition := <-depositMgr.transitionChan
require.True(t, firstDeposit.IsInState(deposit.SweepHtlcTimeout))
require.True(t, secondDeposit.IsInState(deposit.LoopingIn))
secondCtx, cancelSecond := context.WithCancel(ctx)
defer cancelSecond()
secondResult := runMonitor(secondCtx)
secondTransition := <-depositMgr.transitionChan
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
select {
case event := <-secondResult:
require.Equal(t, OnSweepHtlcTimeout, event)
case <-ctx.Done():
t.Fatalf("second monitor attempt did not exit: %v", ctx.Err())
}
require.Equal(t, []*deposit.Deposit{
firstDeposit, secondDeposit,
}, firstTransition.deposits)
require.Equal(t, []*deposit.Deposit{
secondDeposit,
}, secondTransition.deposits)
require.True(t, firstDeposit.IsInState(deposit.SweepHtlcTimeout))
require.True(t, secondDeposit.IsInState(deposit.SweepHtlcTimeout))
}
// TestMonitorInvoiceAndHtlcTxDoesNotFailWhenTimeoutUnlockFails verifies that a
// failed unlock does not make the loop-in terminal while deposits remain
// locked.
func TestMonitorInvoiceAndHtlcTxDoesNotFailWhenTimeoutUnlockFails(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{23, 24, 25}
mockLnd.SetInvoice(&lndclient.Invoice{
Hash: swapHash,
State: invoices.ContractOpen,
})
f, depositMgr := newInvoiceMonitorTestFSM(
t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone,
mockLnd.LndServices.Invoices,
)
f.loopIn.HtlcCltvExpiry = mockLnd.Height
depositMgr.err = errors.New("transition failed")
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
select {
case event := <-resultChan:
require.Equal(t, OnRecover, event)
case <-ctx.Done():
t.Fatalf("monitor action did not exit: %v", ctx.Err())
}
require.Equal(t, []fsm.EventType{fsm.OnError}, depositMgr.events)
require.Equal(t, []fsm.StateType{deposit.Deposited}, depositMgr.states)
}
// waitForMonitorSubscriptions waits until invoice and HTLC watchers are active.
func waitForMonitorSubscriptions(t *testing.T, ctx context.Context,
mockLnd *test.LndMockServices) {
t.Helper()
select {
case <-mockLnd.SingleInvoiceSubcribeChannel:
case <-ctx.Done():
t.Fatalf("invoice subscription not registered: %v", ctx.Err())
}
select {
case <-mockLnd.RegisterConfChannel:
case <-ctx.Done():
t.Fatalf("htlc conf registration not received: %v", ctx.Err())
}
}
// newInvoiceMonitorTestFSM creates the minimal monitor-state setup shared by
// invoice precedence and cancellation tests.
func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context,
mockLnd *test.LndMockServices, swapHash lntypes.Hash,
decision ConfirmationRiskDecision,
invoicesClient lndclient.InvoicesClient) (*FSM, *recordingDepositManager) {
t.Helper()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: mockLnd.Height + 1_000,
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
ProtocolVersion: version.ProtocolVersion_V0,
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
PaymentTimeoutSeconds: 3_600,
ConfirmationRiskDecision: decision,
ConfirmationRiskDecisionTime: time.Now(),
Deposits: []*deposit.Deposit{{
Value: 200_000,
}},
}
loopIn.SetState(MonitorInvoiceAndHtlcTx)
depositMgr := &recordingDepositManager{
transitionChan: make(chan depositTransition, 1),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ClientPubkey: clientKey.PubKey(),
ServerPubkey: serverKey.PubKey(),
ProtocolVersion: version.ProtocolVersion_V0,
},
},
ChainNotifier: mockLnd.ChainNotifier,
DepositManager: depositMgr,
InvoicesClient: invoicesClient,
LndClient: mockLnd.Client,
ChainParams: mockLnd.ChainParams,
}
f, err := NewFSM(ctx, loopIn, cfg, true)
require.NoError(t, err)
return f, depositMgr
}
// failingCancelInvoices records cancellation attempts and returns a configured
// error after its release channel is closed.
type failingCancelInvoices struct {
lndclient.InvoicesClient
cancelCalls chan lntypes.Hash
release chan struct{}
err error
}
// flakySubscribeInvoices counts subscription attempts and returns a configured
// subscription error.
type flakySubscribeInvoices struct {
lndclient.InvoicesClient
subscribeCalls int
err error
}
// firstLookupBarrier blocks the first invoice lookup until its release channel
// is closed.
type firstLookupBarrier struct {
lndclient.LightningClient
lookupStarted chan struct{}
release chan struct{}
firstLookup bool
}
func (f *firstLookupBarrier) LookupInvoice(ctx context.Context,
hash lntypes.Hash) (*lndclient.Invoice, error) {
invoice, err := f.LightningClient.LookupInvoice(ctx, hash)
if f.firstLookup {
return invoice, err
}
f.firstLookup = true
close(f.lookupStarted)
select {
case <-f.release:
return invoice, err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (f *flakySubscribeInvoices) SubscribeSingleInvoice(ctx context.Context,
hash lntypes.Hash) (<-chan lndclient.InvoiceUpdate, <-chan error, error) {
f.subscribeCalls++
if f.subscribeCalls == 1 {
return nil, nil, f.err
}
return f.InvoicesClient.SubscribeSingleInvoice(ctx, hash)
}
func (f *failingCancelInvoices) CancelInvoice(ctx context.Context,
hash lntypes.Hash) error {
select {
case f.cancelCalls <- hash:
case <-ctx.Done():
return ctx.Err()
}
if f.release != nil {
select {
case <-f.release:
case <-ctx.Done():
return ctx.Err()
}
}
return f.err
}
// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a
// present txout does not trigger the RBF cancellation path.
func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) {
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
}
txOutChecker := &recordingTxOutChecker{
txOuts: map[wire.OutPoint]*wire.TxOut{
originalOutpoint: {Value: 10_000},
},
}
f := &FSM{
cfg: &Config{
TxOutChecker: txOutChecker,
},
loopIn: &StaticAddressLoopIn{
DepositOutpoints: []string{originalOutpoint.String()},
},
}
unavailable, err := f.originalDepositOutpointUnavailable(t.Context())
require.NoError(t, err)
require.False(t, unavailable)
require.Equal(t, [][]wire.OutPoint{{originalOutpoint}},
txOutChecker.outpoints)
}
// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a
// pending loop-in is canceled before HTLC signing if GetTxOuts reports that
// one of the originally selected outpoints is gone.
func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{9, 8, 7}
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{originalOutpoint.String()},
}
txOutChecker := &recordingTxOutChecker{}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
event := f.SignHtlcTxAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "original deposit outpoint no longer available",
)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
require.Equal(t, [][]wire.OutPoint{{originalOutpoint}},
txOutChecker.outpoints)
}
// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup
// failures are treated as errors, but do not cancel the invoice. The invoice is
// only canceled when GetTxOuts omits an original outpoint.
func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
swapHash := lntypes.Hash{9, 8, 6}
originalOutpoint := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
DepositOutpoints: []string{originalOutpoint.String()},
}
txOutChecker := &recordingTxOutChecker{
err: errors.New("backend unavailable"),
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
InvoicesClient: mockLnd.LndServices.Invoices,
TxOutChecker: txOutChecker,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
event := f.SignHtlcTxAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "unable to get txout",
)
select {
case hash := <-mockLnd.FailInvoiceChannel:
t.Fatalf("invoice should not have been canceled: %x", hash)
default:
}
}
// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
// created before a server-side rejection is canceled immediately.
func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
loopIn := &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{{
Value: 200_000,
}},
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
PaymentTimeoutSeconds: DefaultPaymentTimeoutSeconds,
ProtocolVersion: version.ProtocolVersion_V0,
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
DepositManager: &noopDepositManager{},
WalletKit: mockLnd.WalletKit,
LndClient: mockLnd.Client,
InvoicesClient: mockLnd.LndServices.Invoices,
Server: &initHtlcTestServer{
loopInErr: errors.New("server rejected swap"),
},
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
// The init step should fail and synchronously trigger deferred invoice
// cleanup.
event := f.InitHtlcAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, loopIn.SwapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
}
// TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure verifies that the early
// fee guard also cancels the pre-created invoice before returning an error.
func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
loopIn := &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{{
Value: 200_000,
}},
InitiationHeight: uint32(mockLnd.Height),
InitiationTime: time.Now(),
PaymentTimeoutSeconds: DefaultPaymentTimeoutSeconds,
ProtocolVersion: version.ProtocolVersion_V0,
}
cfg := &Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
},
},
DepositManager: &noopDepositManager{},
WalletKit: mockLnd.WalletKit,
LndClient: mockLnd.Client,
InvoicesClient: mockLnd.LndServices.Invoices,
Server: &initHtlcTestServer{
loopInResp: &swapserverrpc.ServerStaticAddressLoopInResponse{
HtlcServerPubKey: serverKey.PubKey().
SerializeCompressed(),
HtlcExpiry: mockLnd.Height +
DefaultLoopInOnChainCltvDelta,
StandardHtlcInfo: &swapserverrpc.ServerHtlcSigningInfo{
FeeRate: 1_000_000,
},
HighFeeHtlcInfo: &swapserverrpc.ServerHtlcSigningInfo{},
ExtremeFeeHtlcInfo: &swapserverrpc.
ServerHtlcSigningInfo{},
},
},
ValidateLoopInContract: func(int32, int32) error {
return nil
},
MaxStaticAddrHtlcFeePercentage: 0,
MaxStaticAddrHtlcBackupFeePercentage: 1,
}
f, err := NewFSM(ctx, loopIn, cfg, false)
require.NoError(t, err)
// The fee guard runs before persistence, so the deferred cleanup must
// cancel the invoice on this error path as well.
event := f.InitHtlcAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, loopIn.SwapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
}
// TestUnlockDepositsActionCancelsInvoice verifies that stored swaps that enter
// the generic error unlock path also clean up their swap invoice.
func TestUnlockDepositsActionCancelsInvoice(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
mockLnd := test.NewMockLnd()
dep := &deposit.Deposit{
Value: 200_000,
}
swapHash := lntypes.Hash{0x44, 0x55}
depositMgr := &recordingDepositManager{}
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
DepositManager: depositMgr,
InvoicesClient: mockLnd.LndServices.Invoices,
},
loopIn: &StaticAddressLoopIn{
SwapHash: swapHash,
SwapInvoice: "lnbc1test",
Deposits: []*deposit.Deposit{dep},
},
}
event := f.UnlockDepositsAction(ctx, nil)
require.Equal(t, fsm.OnError, event)
require.NoError(t, f.LastActionError)
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
require.Len(t, depositMgr.transitions, 1)
require.Equal(t, []*deposit.Deposit{dep}, depositMgr.transitions[0].deposits)
require.Equal(t, fsm.OnError, depositMgr.transitions[0].event)
require.Equal(t, deposit.Deposited, depositMgr.transitions[0].state)
}
// TestUnlockDepositsActionReportsTransitionError ensures the unlock path
// preserves the real deposit transition failure for callers that need to log it.
func TestUnlockDepositsActionReportsTransitionError(t *testing.T) {
depositMgr := &recordingDepositManager{
err: errors.New("transition failed"),
}
f := &FSM{
StateMachine: &fsm.StateMachine{},
cfg: &Config{
DepositManager: depositMgr,
},
loopIn: &StaticAddressLoopIn{
Deposits: []*deposit.Deposit{{Value: 200_000}},
},
}
event := f.UnlockDepositsAction(t.Context(), nil)
require.Equal(t, fsm.OnError, event)
require.ErrorContains(
t, f.LastActionError, "unable to unlock deposits",
)
require.ErrorContains(t, f.LastActionError, "transition failed")
}
// mockAddressManager is a minimal AddressManager implementation used by the
// test FSM setup.
type mockAddressManager struct {
params *script.Parameters
}
// GetStaticAddressParameters returns the configured address parameters.
func (m *mockAddressManager) GetStaticAddressParameters(_ context.Context) (
*script.Parameters, error) {
return m.params, nil
}
// GetStaticAddress is unused for this test and returns nil.
func (m *mockAddressManager) GetStaticAddress(_ context.Context) (
*script.StaticAddress, error) {
return nil, nil
}
// noopDepositManager is a stub DepositManager used to satisfy FSM config.
type noopDepositManager struct {
deposits []*deposit.Deposit
depositsForOutpoints func([]string, bool)
depositErr error
ensureFreshErr error
}
// EnsureDepositsFresh implements DepositManager with a no-op.
func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error {
return n.ensureFreshErr
}
// GetAllDeposits implements DepositManager with a no-op.
func (n *noopDepositManager) GetAllDeposits(_ context.Context) (
[]*deposit.Deposit, error) {
return nil, nil
}
// AllStringOutpointsActiveDeposits implements DepositManager with a no-op.
func (n *noopDepositManager) AllStringOutpointsActiveDeposits(
_ []string, _ fsm.StateType) ([]*deposit.Deposit, bool) {
return nil, false
}
// TransitionDeposits implements DepositManager with a no-op.
func (n *noopDepositManager) TransitionDeposits(context.Context,
[]*deposit.Deposit, fsm.EventType, fsm.StateType) error {
return nil
}
// DepositsForOutpoints implements DepositManager with a no-op.
func (n *noopDepositManager) DepositsForOutpoints(_ context.Context,
outpoints []string, ignoreUnknown bool) ([]*deposit.Deposit, error) {
if n.depositsForOutpoints != nil {
n.depositsForOutpoints(outpoints, ignoreUnknown)
}
return n.deposits, n.depositErr
}
// GetActiveDepositsInState implements DepositManager with a no-op.
func (n *noopDepositManager) GetActiveDepositsInState(fsm.StateType) (
[]*deposit.Deposit, error) {
return nil, nil
}
type depositTransition struct {
deposits []*deposit.Deposit
event fsm.EventType
state fsm.StateType
}
type recordingDepositManager struct {
noopDepositManager
err error
errs []error
transition func([]*deposit.Deposit, fsm.EventType, fsm.StateType) error
transitions []depositTransition
transitionChan chan depositTransition
events []fsm.EventType
states []fsm.StateType
}
// TransitionDeposits records the transition and returns the configured error.
func (r *recordingDepositManager) TransitionDeposits(_ context.Context,
deposits []*deposit.Deposit, event fsm.EventType,
state fsm.StateType) error {
transition := depositTransition{
deposits: deposits,
event: event,
state: state,
}
r.transitions = append(r.transitions, transition)
r.events = append(r.events, event)
r.states = append(r.states, state)
if r.transitionChan != nil {
r.transitionChan <- transition
}
switch {
case r.transition != nil:
if err := r.transition(deposits, event, state); err != nil {
return err
}
case len(r.errs) > 0:
err := r.errs[0]
r.errs = r.errs[1:]
if err != nil {
return err
}
case r.err != nil:
return r.err
}
for _, d := range deposits {
if d != nil {
d.SetState(state)
}
}
return nil
}
type recordingRiskStore struct {
*mockStore
decisions chan ConfirmationRiskDecision
}
// RecordStaticAddressRiskDecision records a risk decision in the mock store.
func (s *recordingRiskStore) RecordStaticAddressRiskDecision(
_ context.Context, swapHash lntypes.Hash,
decision ConfirmationRiskDecision) error {
loopIn, ok := s.loopIns[swapHash]
if !ok {
return ErrLoopInNotFound
}
loopIn.ConfirmationRiskDecision = decision
loopIn.ConfirmationRiskDecisionTime = time.Now()
select {
case s.decisions <- decision:
default:
}
return nil
}
// mockNotificationManager allows tests to push server notifications directly to
// monitor actions.
type mockNotificationManager struct {
riskAccepted chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification
riskRejected chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification
}
type silentBlockChainNotifier struct {
lndclient.ChainNotifierClient
}
// RegisterBlockEpochNtfn implements ChainNotifierClient without delivering an
// initial block. Tests use it to assert current-height recovery behavior without
// relying on a block notification.
func (s *silentBlockChainNotifier) RegisterBlockEpochNtfn(context.Context) (
chan int32, chan error, error) {
return make(chan int32), make(chan error), nil
}
// SubscribeStaticLoopInSweepRequests implements NotificationManager.
func (m *mockNotificationManager) SubscribeStaticLoopInSweepRequests(
context.Context) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification {
return make(chan *swapserverrpc.ServerStaticLoopInSweepNotification)
}
// SubscribeStaticLoopInRiskAccepted implements NotificationManager.
func (m *mockNotificationManager) SubscribeStaticLoopInRiskAccepted(
context.Context, lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
return m.riskAccepted
}
// SubscribeStaticLoopInRiskRejected implements NotificationManager.
func (m *mockNotificationManager) SubscribeStaticLoopInRiskRejected(
context.Context, lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
return m.riskRejected
}
type recordingTxOutChecker struct {
outpoints [][]wire.OutPoint
txOuts map[wire.OutPoint]*wire.TxOut
err error
}
// GetTxOuts records the request and returns the configured available outputs.
func (r *recordingTxOutChecker) GetTxOuts(_ context.Context,
outpoints []wire.OutPoint) (map[wire.OutPoint]*wire.TxOut, error) {
r.outpoints = append(
r.outpoints, append([]wire.OutPoint(nil), outpoints...),
)
if r.err != nil {
return nil, r.err
}
return r.txOuts, nil
}
// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
// response without standing up the full gRPC client.
type initHtlcTestServer struct {
swapserverrpc.StaticAddressServerClient
loopInResp *swapserverrpc.ServerStaticAddressLoopInResponse
loopInErr error
}
// ServerStaticAddressLoopIn returns the canned response configured by the test.
func (s *initHtlcTestServer) ServerStaticAddressLoopIn(context.Context,
*swapserverrpc.ServerStaticAddressLoopInRequest, ...grpc.CallOption,
) (*swapserverrpc.ServerStaticAddressLoopInResponse, error) {
return s.loopInResp, s.loopInErr
}
// PushStaticAddressHtlcSigs accepts the abandonment signal used by error-path
// tests without adding additional assertions.
func (s *initHtlcTestServer) PushStaticAddressHtlcSigs(context.Context,
*swapserverrpc.PushStaticAddressHtlcSigsRequest, ...grpc.CallOption,
) (*swapserverrpc.PushStaticAddressHtlcSigsResponse, error) {
return &swapserverrpc.PushStaticAddressHtlcSigsResponse{}, nil
}