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) } // TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence protects the // persistence-first invariant: once the loop-in is stored, a later status // update failure must not roll back the action or state transition. func TestInitHtlcActionIgnoresSendUpdateErrorAfterPersistence(t *testing.T) { mockLnd := test.NewMockLnd() _, serverKey := test.CreateKey(22) server := &mockStaticAddressServer{ response: testStaticAddressLoopInResponse( serverKey.SerializeCompressed(), ), } dep := &deposit.Deposit{ OutPoint: wire.OutPoint{ Hash: chainhash.Hash{2}, Index: 0, }, Value: 500_000, } loopIn := &StaticAddressLoopIn{ Deposits: []*deposit.Deposit{dep}, DepositOutpoints: []string{dep.OutPoint.String()}, SelectedAmount: dep.Value, QuotedSwapFee: 1_000, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), PaymentTimeoutSeconds: 3_600, } sendUpdateErr := errors.New("status channel blocked") sendUpdateCalled := false 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, SendUpdate: func(context.Context, *StaticAddressLoopIn) error { sendUpdateCalled = true return sendUpdateErr }, }, loopIn: loopIn, } event := f.InitHtlcAction(t.Context(), nil) require.Equal(t, OnHtlcInitiated, event) require.Nil(t, f.LastActionError) require.True(t, sendUpdateCalled) } // 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 }