loop/staticaddr/loopin/actions_test.go
Slyghtning fc3fba7c23
staticaddr/loopin: persist risk decisions
Store server confirmation-risk decisions with static loop-in swaps and
recover accepted payment-deadline timers after restart. Wire
notification persistence through loopd so recovered swaps do not lose
pending risk state.

Deduplicate notification fanout cache entries by swap hash.
2026-06-30 16:15:43 +02:00

2577 lines
70 KiB
Go

package loopin
import (
"context"
"errors"
"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/chainntnfs"
"github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
// 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(), 5*time.Second)
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())
}
}
// 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(), 5*time.Second)
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)
}
// 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
}
// TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted verifies that the
// payment timeout does not start until the server notifies us that confirmation
// risk was accepted.
func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
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,
),
}
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,
}
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:
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())
}
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(), 5*time.Second)
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(), 5*time.Second)
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")
}
}
// TestMonitorInvoiceAndHtlcTxContinuesAfterRiskRejected verifies that a
// server-side confirmation risk rejection cancels the invoice and unlocks the
// deposits, but keeps monitoring for a possible server-published HTLC until the
// timeout path is resolved.
func TestMonitorInvoiceAndHtlcTxContinuesAfterRiskRejected(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
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,
),
}
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)
}()
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())
}
transition := nextDepositTransition(t, ctx, depositMgr)
require.Equal(t, fsm.OnError, transition.event)
require.Equal(t, deposit.Deposited, transition.state)
select {
case event := <-resultChan:
t.Fatalf("monitor action exited before HTLC timeout: %v",
event)
case <-time.After(100 * time.Millisecond):
}
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
select {
case event := <-resultChan:
require.Equal(t, OnSwapTimedOut, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
require.Len(t, depositMgr.transitions, 1)
}
// TestMonitorInvoiceAndHtlcTxLocksDepositsWhenHtlcConfirmsAfterRiskRejected
// verifies that deposits unlocked after a risk rejection are moved back into the
// HTLC timeout sweep state if the server later publishes the HTLC.
func TestMonitorInvoiceAndHtlcTxLocksDepositsWhenHtlcConfirmsAfterRiskRejected(
t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
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{10},
Index: 0,
}
loopIn := &StaticAddressLoopIn{
SwapHash: swapHash,
HtlcCltvExpiry: mockLnd.Height + 2,
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,
),
}
depositMgr := &recordingDepositManager{
transitionChan: make(chan depositTransition, 2),
}
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)
}()
waitForMonitorSubscriptions(t, ctx, mockLnd)
notificationMgr.riskRejected <- &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
}
select {
case hash := <-mockLnd.FailInvoiceChannel:
require.Equal(t, swapHash, hash)
case <-ctx.Done():
t.Fatalf("invoice was not canceled: %v", ctx.Err())
}
unlockTransition := nextDepositTransition(t, ctx, depositMgr)
require.Equal(t, fsm.OnError, unlockTransition.event)
require.Equal(t, deposit.Deposited, unlockTransition.state)
htlc, err := loopIn.getHtlc(mockLnd.ChainParams)
require.NoError(t, err)
htlcTx := wire.NewMsgTx(2)
htlcTx.AddTxOut(&wire.TxOut{
PkScript: htlc.PkScript,
})
select {
case mockLnd.ConfChannel <- &chainntnfs.TxConfirmation{Tx: htlcTx}:
case <-ctx.Done():
t.Fatalf("htlc confirmation was not consumed: %v", ctx.Err())
}
timeoutTransition := nextDepositTransition(t, ctx, depositMgr)
require.Equal(
t, deposit.OnSweepingHtlcTimeout, timeoutTransition.event,
)
require.Equal(t, deposit.SweepHtlcTimeout, timeoutTransition.state)
select {
case event := <-resultChan:
t.Fatalf("monitor action exited before HTLC timeout: %v",
event)
case <-time.After(100 * time.Millisecond):
}
require.NoError(t, mockLnd.NotifyHeight(loopIn.HtlcCltvExpiry+1))
select {
case event := <-resultChan:
require.Equal(t, OnSweepHtlcTimeout, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
require.Len(t, depositMgr.transitions, 2)
}
// 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(), 5*time.Second)
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,
})
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)
}()
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())
}
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 and unlocks after restart without
// dropping HTLC timeout monitoring.
func TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
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())
}
transition := nextDepositTransition(t, ctx, depositMgr)
require.Equal(t, fsm.OnError, transition.event)
require.Equal(t, deposit.Deposited, transition.state)
select {
case event := <-resultChan:
t.Fatalf("monitor action exited before HTLC timeout: %v",
event)
case <-time.After(100 * time.Millisecond):
}
require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))
select {
case event := <-resultChan:
require.Equal(t, OnSwapTimedOut, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
require.Len(t, depositMgr.transitions, 1)
}
// 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(), 5*time.Second)
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 := &testTxOutChecker{}
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)
require.Empty(t, txOutChecker.includeMempool)
}
// 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(), 5*time.Second)
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: &testTxOutChecker{},
}
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(), 5*time.Second)
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(), 5*time.Second)
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(), 5*time.Second)
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,
})
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,
}
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 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(), 5*time.Second)
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")
}
}
// 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(), 5*time.Second)
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)
}
// TestMonitorInvoiceAndHtlcTxRetriesDeadlineUnlock verifies that a temporary
// deposit unlock failure after the payment deadline is retried without waiting
// for the HTLC timeout path or daemon restart.
func TestMonitorInvoiceAndHtlcTxRetriesDeadlineUnlock(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
oldRetryDelay := paymentDeadlineUnlockRetryDelay
paymentDeadlineUnlockRetryDelay = 20 * time.Millisecond
t.Cleanup(func() {
paymentDeadlineUnlockRetryDelay = oldRetryDelay
})
mockLnd := test.NewMockLnd()
clientKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
serverKey, err := btcec.NewPrivateKey()
require.NoError(t, err)
swapHash := lntypes.Hash{10, 11, 13}
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: 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{
errs: []error{
errors.New("temporary unlock failure"),
},
transitionChan: make(chan depositTransition, 2),
}
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)
firstTransition := nextDepositTransition(t, ctx, depositMgr)
require.Equal(t, fsm.OnError, firstTransition.event)
require.Equal(t, deposit.Deposited, firstTransition.state)
secondTransition := nextDepositTransition(t, ctx, depositMgr)
require.Equal(t, fsm.OnError, secondTransition.event)
require.Equal(t, deposit.Deposited, secondTransition.state)
cancel()
select {
case event := <-resultChan:
require.Equal(t, fsm.NoOp, event)
case <-time.After(time.Second):
t.Fatal("monitor action did not exit")
}
require.Len(t, depositMgr.transitions, 2)
}
// 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())
}
}
func nextDepositTransition(t *testing.T, ctx context.Context,
depositMgr *recordingDepositManager) depositTransition {
t.Helper()
select {
case transition := <-depositMgr.transitionChan:
return transition
case <-ctx.Done():
t.Fatalf("deposit transition not observed: %v", ctx.Err())
case <-time.After(time.Second):
t.Fatal("deposit transition not observed")
}
return depositTransition{}
}
// 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 := &testTxOutChecker{
txOut: &wire.TxOut{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)
require.Equal(t, []bool{true}, txOutChecker.includeMempool)
}
// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a
// pending loop-in is canceled before HTLC signing if GetTxOut with mempool
// awareness reports that one of the originally selected outpoints is gone.
func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
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 := &testTxOutChecker{}
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)
require.Equal(t, []bool{true}, txOutChecker.includeMempool)
}
// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup
// failures are treated as errors, but do not cancel the invoice. The invoice is
// only canceled when GetTxOut explicitly returns nil for an original outpoint.
func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
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 := &testTxOutChecker{
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(), 5*time.Second)
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(), 5*time.Second)
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(), 5*time.Second)
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
}
// EnsureDepositsFresh implements DepositManager with a no-op.
func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
// 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
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
}
if len(r.errs) > 0 {
err := r.errs[0]
r.errs = r.errs[1:]
return err
}
return r.err
}
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 testTxOutChecker struct {
txOut *wire.TxOut
err error
outpoints []wire.OutPoint
includeMempool []bool
}
// GetTxOut records lookup parameters and returns the configured result.
func (t *testTxOutChecker) GetTxOut(_ context.Context,
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
t.outpoints = append(t.outpoints, outpoint)
t.includeMempool = append(t.includeMempool, includeMempool)
return t.txOut, t.err
}
// 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
}