loop/staticaddr/deposit/manager_reconcile_test.go
Slyghtning 5506d28f79
staticaddr: sync active deposits with wallet
Treat lnd wallet view as the source of spendable static-address
outputs while keeping historical deposit records in the DB. Reconcile
active FSMs against the current wallet view and reactivate known
deposits when their outpoints are visible again.

Refresh deposits before selection, withdrawal, loop-in, and channel-open
paths, and filter list and summary responses through the live active set
so stale Deposited records are not exposed as available funds.
2026-06-30 16:15:04 +02:00

703 lines
19 KiB
Go

package deposit
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// expectStableBestBlock configures two stable best-block lookups.
func expectStableBestBlock(mockChainKit *MockChainKit, height int32) {
mockChainKit.On(
"GetBestBlock", mock.Anything,
).Return(chainhash.Hash{}, height, nil).Twice()
}
// TestReconcileDepositsSerialized verifies reconciliation is serialized across
// concurrent callers.
func TestReconcileDepositsSerialized(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
utxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100_000),
Confirmations: 0,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
mockStore := new(mockStore)
var createCalls atomic.Int32
createEntered := make(chan struct{})
releaseCreate := make(chan struct{})
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(mock.Arguments) {
if createCalls.Add(1) == 1 {
close(createEntered)
}
<-releaseCreate
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
var wg sync.WaitGroup
wg.Add(2)
errs := make(chan error, 2)
go func() {
defer wg.Done()
errs <- manager.reconcileDeposits(ctx)
}()
<-createEntered
go func() {
defer wg.Done()
errs <- manager.reconcileDeposits(ctx)
}()
time.Sleep(100 * time.Millisecond)
close(releaseCreate)
wg.Wait()
close(errs)
var gotErrs []error
for err := range errs {
gotErrs = append(gotErrs, err)
}
require.EqualValues(t, 1, createCalls.Load())
require.Len(t, manager.deposits, 1)
require.Empty(t, manager.activeDeposits)
require.Len(t, gotErrs, 2)
var errCount int
for _, err := range gotErrs {
if err == nil {
continue
}
errCount++
errMsg := err.Error()
require.True(
t,
strings.Contains(
errMsg, "unable to start new deposit FSM",
) || strings.Contains(
errMsg, "unable to sync active deposits",
),
"unexpected error: %v", err,
)
}
require.Equal(t, 2, errCount)
}
// TestReconcileConfirmedDepositUsesBestBlockHeight verifies confirmation
// heights are derived from a stable chain tip.
func TestReconcileConfirmedDepositUsesBestBlockHeight(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
utxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100_000),
Confirmations: 3,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{8},
Index: 1,
},
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
mockChainKit := new(MockChainKit)
expectStableBestBlock(mockChainKit, 100)
mockStore := new(mockStore)
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
createdDeposit := args.Get(1).(*Deposit)
require.EqualValues(t, 98, createdDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
ChainKit: mockChainKit,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to start new deposit FSM")
}
// TestReconcileConfirmedDepositRelistsOnBlockChange verifies that a block
// arriving while listing deposits does not fail reconciliation. Instead we
// relist deposits and use the latest height.
func TestReconcileConfirmedDepositRelistsOnBlockChange(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
initialUtxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100_000),
Confirmations: 1,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{18},
Index: 1,
},
}
latestUtxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100_000),
Confirmations: 2,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{18},
Index: 1,
},
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{initialUtxo}, nil).Once()
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{initialUtxo}, nil).Once()
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{latestUtxo}, nil).Once()
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
mockChainKit := new(MockChainKit)
mockChainKit.On(
"GetBestBlock", mock.Anything,
).Return(chainhash.Hash{}, int32(100), nil).Once()
mockChainKit.On(
"GetBestBlock", mock.Anything,
).Return(chainhash.Hash{}, int32(101), nil).Once()
mockStore := new(mockStore)
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
createdDeposit := args.Get(1).(*Deposit)
require.EqualValues(t, 100, createdDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
ChainKit: mockChainKit,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to start new deposit FSM")
mockAddressManager.AssertExpectations(t)
mockChainKit.AssertExpectations(t)
}
// TestUpdateDepositConfirmationsResetsReorgedDeposit verifies that a deposit
// which remains wallet-visible but loses confirmations has its confirmation
// height reset. This can happen if a confirmed transaction is reorged back into
// the mempool.
func TestUpdateDepositConfirmationsResetsReorgedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{7},
Index: 2,
}
deposit := &Deposit{
OutPoint: outpoint,
ConfirmationHeight: 99,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Confirmations: 0,
}
mockStore := new(mockStore)
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
require.Zero(t, updatedDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
Store: mockStore,
})
manager.deposits[outpoint] = deposit
err := manager.updateDepositConfirmations(ctx, []*lnwallet.Utxo{utxo}, 0)
require.NoError(t, err)
require.Zero(t, deposit.ConfirmationHeight)
mockStore.AssertExpectations(t)
}
// TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit verifies that a
// missing wallet outpoint is removed from the live active set without mutating
// its historical DB state.
func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 7,
}
deposit := &Deposit{
OutPoint: outpoint,
}
deposit.SetState(Deposited)
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{}, nil)
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: new(mockStore),
})
manager.deposits[outpoint] = deposit
fsm := &FSM{
deposit: deposit,
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-fsm.stopChan
close(fsm.quitChan)
}()
manager.activeDeposits[outpoint] = fsm
require.NoError(t, manager.reconcileDeposits(ctx))
require.Equal(t, Deposited, deposit.GetState())
require.Empty(t, manager.activeDeposits)
select {
case <-fsm.quitChan:
case <-time.After(time.Second):
t.Fatal("fsm did not stop after deposit vanished")
}
}
// TestReconcileDepositsDeactivatesVanishedConfirmedDeposit verifies that a
// previously confirmed deposit is also removed from the live active set if it
// vanishes from the wallet view.
func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{9},
Index: 4,
}
deposit := &Deposit{
OutPoint: outpoint,
ConfirmationHeight: 123,
}
deposit.SetState(Deposited)
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{}, nil)
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: new(mockStore),
})
manager.deposits[outpoint] = deposit
fsm := &FSM{
deposit: deposit,
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-fsm.stopChan
close(fsm.quitChan)
}()
manager.activeDeposits[outpoint] = fsm
require.NoError(t, manager.reconcileDeposits(ctx))
require.Equal(t, Deposited, deposit.GetState())
require.EqualValues(t, 123, deposit.ConfirmationHeight)
require.Empty(t, manager.activeDeposits)
select {
case <-fsm.quitChan:
case <-time.After(time.Second):
t.Fatal("fsm did not stop after confirmed deposit vanished")
}
}
// TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a
// duplicated selection is rejected before the manager tries to lock the same
// deposit twice.
func TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints(t *testing.T) {
outpoint := wire.OutPoint{
Hash: chainhash.Hash{12},
Index: 6,
}
deposit := &Deposit{
OutPoint: outpoint,
}
deposit.SetState(Deposited)
manager := NewManager(&ManagerConfig{})
manager.deposits[outpoint] = deposit
manager.activeDeposits[outpoint] = &FSM{
deposit: deposit,
}
deposits, ok := manager.AllOutpointsActiveDeposits(
[]wire.OutPoint{outpoint, outpoint}, Deposited,
)
require.False(t, ok)
require.Nil(t, deposits)
}
// TestTransitionDepositsRejectsDuplicateOutpoints verifies that transition
// callers cannot deadlock the manager by passing the same deposit twice.
func TestTransitionDepositsRejectsDuplicateOutpoints(t *testing.T) {
outpoint := wire.OutPoint{
Hash: chainhash.Hash{13},
Index: 6,
}
deposit := &Deposit{
OutPoint: outpoint,
}
deposit.SetState(Deposited)
manager := NewManager(&ManagerConfig{})
err := manager.TransitionDeposits(
t.Context(), []*Deposit{deposit, deposit}, OnLoopInInitiated,
LoopingIn,
)
require.ErrorContains(t, err, "duplicate deposit outpoint")
require.Equal(t, Deposited, deposit.GetState())
}
// TestReconcileDepositsReactivatesReappearedDeposit verifies that the same
// outpoint can become active again if lnd reports it after a prior wallet-view
// miss.
func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 5,
}
deposit := &Deposit{
OutPoint: outpoint,
Value: btcutil.Amount(100_000),
ConfirmationHeight: 77,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
}, nil)
mockAddressManager.On(
"GetStaticAddress", mock.Anything,
).Return((*script.StaticAddress)(nil), nil)
mockStore := new(mockStore)
var updateStates []fsm.StateType
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
updateStates = append(updateStates, updatedDeposit.state)
if updatedDeposit.IsInStateNoLock(Deposited) {
require.Zero(t, updatedDeposit.ConfirmationHeight)
}
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
// Reconciliation should reactivate the existing record instead of
// creating a second deposit entry for the same outpoint.
require.NoError(t, manager.reconcileDeposits(ctx))
require.Equal(t, Deposited, deposit.GetState())
require.Zero(t, deposit.ConfirmationHeight)
require.Len(t, manager.activeDeposits, 1)
require.Equal(t, []fsm.StateType{Deposited}, updateStates)
}
// TestReconcileDepositsKeepsInactiveOnFSMStartFailure verifies that a failed
// reactivation does not leave memory saying a deposit is active without an FSM.
func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{11},
Index: 5,
}
deposit := &Deposit{
OutPoint: outpoint,
Value: btcutil.Amount(100_000),
ConfirmationHeight: 77,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
var (
updateStates []fsm.StateType
updateHeights []int64
)
mockStore := new(mockStore)
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
updateStates = append(updateStates, updatedDeposit.state)
updateHeights = append(
updateHeights, updatedDeposit.ConfirmationHeight,
)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to sync active deposits")
require.Equal(t, Deposited, deposit.GetState())
require.Zero(t, deposit.ConfirmationHeight)
require.Empty(t, manager.activeDeposits)
require.Equal(t, []fsm.StateType{Deposited}, updateStates)
require.EqualValues(t, []int64{0}, updateHeights)
}
// TestReconcileDepositsDeactivatesBeforeActivationFailure verifies that a
// failed reactivation of one visible deposit does not leave another vanished
// deposit in the live active set.
func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) {
ctx := context.Background()
visibleOutpoint := wire.OutPoint{
Hash: chainhash.Hash{21},
Index: 5,
}
vanishedOutpoint := wire.OutPoint{
Hash: chainhash.Hash{22},
Index: 6,
}
visibleDeposit := &Deposit{
OutPoint: visibleOutpoint,
Value: btcutil.Amount(100_000),
}
visibleDeposit.SetState(Deposited)
vanishedDeposit := &Deposit{
OutPoint: vanishedOutpoint,
Value: btcutil.Amount(100_000),
}
vanishedDeposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: visibleOutpoint,
Value: visibleDeposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: new(mockStore),
})
manager.deposits[visibleOutpoint] = visibleDeposit
manager.deposits[vanishedOutpoint] = vanishedDeposit
vanishedFsm := &FSM{
deposit: vanishedDeposit,
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-vanishedFsm.stopChan
close(vanishedFsm.quitChan)
}()
manager.activeDeposits[vanishedOutpoint] = vanishedFsm
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to sync active deposits")
require.Empty(t, manager.activeDeposits)
select {
case <-vanishedFsm.quitChan:
case <-time.After(time.Second):
t.Fatal("vanished deposit fsm did not stop")
}
}
// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement
// UTXO is retained as a new deposit while an in-flight deposit remains tied to
// the outpoint selected by a loop-in.
func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 8,
}
newOutpoint := wire.OutPoint{
Hash: chainhash.Hash{5},
Index: 9,
}
depositID, err := GetRandomDepositID()
require.NoError(t, err)
deposit := &Deposit{
ID: depositID,
OutPoint: oldOutpoint,
Value: btcutil.Amount(100_000),
}
deposit.SetState(LoopingIn)
utxo := &lnwallet.Utxo{
OutPoint: newOutpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
}, nil)
mockAddressManager.On(
"GetStaticAddress", mock.Anything,
).Return((*script.StaticAddress)(nil), nil)
mockStore := new(mockStore)
var createdDeposit *Deposit
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
createdDeposit = args.Get(1).(*Deposit)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
manager.deposits[oldOutpoint] = deposit
fsm := &FSM{}
manager.activeDeposits[oldOutpoint] = fsm
require.NoError(t, manager.reconcileDeposits(ctx))
require.Same(t, deposit, manager.deposits[oldOutpoint])
require.Equal(t, oldOutpoint, deposit.OutPoint)
require.Equal(t, LoopingIn, deposit.GetState())
replacement, ok := manager.deposits[newOutpoint]
require.True(t, ok)
require.Same(t, createdDeposit, replacement)
require.NotEqual(t, depositID, replacement.ID)
require.Equal(t, newOutpoint, replacement.OutPoint)
require.Equal(t, Deposited, replacement.GetState())
require.Zero(t, replacement.ConfirmationHeight)
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
require.NotSame(t, fsm, manager.activeDeposits[newOutpoint])
mockStore.AssertNotCalled(
t, "UpdateDeposit", mock.Anything, mock.Anything,
)
}