loop/staticaddr/openchannel/manager_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

1012 lines
27 KiB
Go

package openchannel
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"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/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
type transitionCall struct {
event fsm.EventType
expectedState fsm.StateType
outpoints []wire.OutPoint
}
type mockDepositManager struct {
activeDeposits []*deposit.Deposit
openingDeposits []*deposit.Deposit
getErr error
transitionErrs map[fsm.EventType]error
calls []transitionCall
}
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
fsm.StateType) ([]*deposit.Deposit, bool) {
return nil, false
}
func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) (
[]*deposit.Deposit, error) {
switch stateFilter {
case deposit.Deposited:
return m.activeDeposits, nil
case deposit.OpeningChannel:
if m.getErr != nil {
return nil, m.getErr
}
return m.openingDeposits, nil
}
return nil, nil
}
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
deposits []*deposit.Deposit, event fsm.EventType,
expectedFinalState fsm.StateType) error {
call := transitionCall{
event: event,
expectedState: expectedFinalState,
outpoints: make([]wire.OutPoint, len(deposits)),
}
for i, d := range deposits {
call.outpoints[i] = d.OutPoint
}
m.calls = append(m.calls, call)
if err, ok := m.transitionErrs[event]; ok {
return err
}
return nil
}
type mockWalletKit struct {
lndclient.WalletKitClient
utxos []*lnwallet.Utxo
err error
calls int
}
func (m *mockWalletKit) ListUnspent(_ context.Context, _, _ int32,
_ ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) {
m.calls++
if m.err != nil {
return nil, m.err
}
return m.utxos, nil
}
// TestRecoverOpeningChannelDepositsMixed verifies that recovery correctly
// classifies deposits based on UTXO status: unspent deposits are moved back to
// Deposited and spent deposits are transitioned to ChannelPublished.
func TestRecoverOpeningChannelDepositsMixed(t *testing.T) {
t.Parallel()
unspentDeposit := &deposit.Deposit{OutPoint: testOutPoint(1)}
spentDeposit := &deposit.Deposit{OutPoint: testOutPoint(2)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{unspentDeposit, spentDeposit},
}
walletKit := &mockWalletKit{
utxos: []*lnwallet.Utxo{
{OutPoint: unspentDeposit.OutPoint},
{OutPoint: testOutPoint(99)},
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.NoError(t, err)
require.Equal(t, 1, walletKit.calls)
require.Len(t, depositManager.calls, 2)
require.Equal(t, fsm.OnError, depositManager.calls[0].event)
require.Equal(t, deposit.Deposited, depositManager.calls[0].expectedState)
require.Equal(
t, []wire.OutPoint{unspentDeposit.OutPoint},
depositManager.calls[0].outpoints,
)
require.Equal(t, deposit.OnChannelPublished, depositManager.calls[1].event)
require.Equal(
t, deposit.ChannelPublished,
depositManager.calls[1].expectedState,
)
require.Equal(
t, []wire.OutPoint{spentDeposit.OutPoint},
depositManager.calls[1].outpoints,
)
}
// TestRecoverOpeningChannelDepositsNoDeposits verifies that recovery is a
// no-op when there are no deposits in the OpeningChannel state.
func TestRecoverOpeningChannelDepositsNoDeposits(t *testing.T) {
t.Parallel()
depositManager := &mockDepositManager{}
walletKit := &mockWalletKit{}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.NoError(t, err)
require.Zero(t, walletKit.calls)
require.Empty(t, depositManager.calls)
}
// TestRecoverOpeningChannelDepositsListUnspentError verifies that a
// ListUnspent failure during recovery is propagated to the caller.
func TestRecoverOpeningChannelDepositsListUnspentError(t *testing.T) {
t.Parallel()
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{
{OutPoint: testOutPoint(1)},
},
}
walletKit := &mockWalletKit{
err: errors.New("list unspent failed"),
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.ErrorContains(t, err, "unable to list unspent outputs")
require.Empty(t, depositManager.calls)
}
// TestRecoverOpeningChannelDepositsTransitionError verifies that a transition
// failure when moving unspent deposits back to Deposited is propagated.
func TestRecoverOpeningChannelDepositsTransitionError(t *testing.T) {
t.Parallel()
unspentDeposit := &deposit.Deposit{OutPoint: testOutPoint(1)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{unspentDeposit},
transitionErrs: map[fsm.EventType]error{
fsm.OnError: errors.New("transition failed"),
},
}
walletKit := &mockWalletKit{
utxos: []*lnwallet.Utxo{
{OutPoint: unspentDeposit.OutPoint},
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.ErrorContains(t, err, "unable to recover unspent opening deposits")
require.Len(t, depositManager.calls, 1)
}
// TestRecoverAfterReorg simulates a reorg where a channel funding transaction
// was confirmed but then reorged out. After the reorg the deposit UTXOs
// reappear as unspent, so recovery should move all deposits back to Deposited.
func TestRecoverAfterReorg(t *testing.T) {
t.Parallel()
d1 := &deposit.Deposit{OutPoint: testOutPoint(1)}
d2 := &deposit.Deposit{OutPoint: testOutPoint(2)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{d1, d2},
}
walletKit := &mockWalletKit{
utxos: []*lnwallet.Utxo{
// After reorg both UTXOs are unspent again.
{OutPoint: d1.OutPoint},
{OutPoint: d2.OutPoint},
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.NoError(t, err)
require.Len(t, depositManager.calls, 1)
// Both deposits should transition back to Deposited.
require.Equal(t, fsm.OnError, depositManager.calls[0].event)
require.Equal(
t, deposit.Deposited, depositManager.calls[0].expectedState,
)
require.ElementsMatch(
t,
[]wire.OutPoint{d1.OutPoint, d2.OutPoint},
depositManager.calls[0].outpoints,
)
}
// TestRecoverAfterMempoolEviction simulates the case where the channel funding
// transaction was evicted from the mempool. The deposit UTXOs reappear as
// unspent, so recovery should move them back to Deposited.
func TestRecoverAfterMempoolEviction(t *testing.T) {
t.Parallel()
d := &deposit.Deposit{OutPoint: testOutPoint(1)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{d},
}
walletKit := &mockWalletKit{
utxos: []*lnwallet.Utxo{
// UTXO reappears after mempool eviction.
{OutPoint: d.OutPoint},
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.NoError(t, err)
require.Len(t, depositManager.calls, 1)
require.Equal(t, fsm.OnError, depositManager.calls[0].event)
require.Equal(
t, deposit.Deposited, depositManager.calls[0].expectedState,
)
}
// TestRecoverAfterMempoolRejection simulates the case where the channel
// funding transaction was rejected from the mempool (e.g. fee too low). The
// deposit UTXOs were never spent, so recovery should move them back to
// Deposited.
func TestRecoverAfterMempoolRejection(t *testing.T) {
t.Parallel()
d1 := &deposit.Deposit{OutPoint: testOutPoint(1)}
d2 := &deposit.Deposit{OutPoint: testOutPoint(2)}
d3 := &deposit.Deposit{OutPoint: testOutPoint(3)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{d1, d2, d3},
}
walletKit := &mockWalletKit{
utxos: []*lnwallet.Utxo{
// All UTXOs still unspent since tx was never accepted.
{OutPoint: d1.OutPoint},
{OutPoint: d2.OutPoint},
{OutPoint: d3.OutPoint},
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.NoError(t, err)
require.Len(t, depositManager.calls, 1)
require.Equal(t, fsm.OnError, depositManager.calls[0].event)
require.Equal(
t, deposit.Deposited, depositManager.calls[0].expectedState,
)
require.Len(t, depositManager.calls[0].outpoints, 3)
}
// TestRecoverDaemonRestartChannelPublished simulates a daemon restart where
// the channel funding tx was successfully broadcast and the deposit UTXOs are
// all spent. Recovery should move them to ChannelPublished.
func TestRecoverDaemonRestartChannelPublished(t *testing.T) {
t.Parallel()
d1 := &deposit.Deposit{OutPoint: testOutPoint(1)}
d2 := &deposit.Deposit{OutPoint: testOutPoint(2)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{d1, d2},
}
walletKit := &mockWalletKit{
// No UTXOs returned - all deposit outpoints have been spent.
utxos: []*lnwallet.Utxo{},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.NoError(t, err)
require.Len(t, depositManager.calls, 1)
require.Equal(
t, deposit.OnChannelPublished,
depositManager.calls[0].event,
)
require.Equal(
t, deposit.ChannelPublished,
depositManager.calls[0].expectedState,
)
require.ElementsMatch(
t,
[]wire.OutPoint{d1.OutPoint, d2.OutPoint},
depositManager.calls[0].outpoints,
)
}
// TestRecoverChannelPublishedTransitionError verifies that an error
// transitioning deposits to ChannelPublished during recovery is returned.
func TestRecoverChannelPublishedTransitionError(t *testing.T) {
t.Parallel()
d := &deposit.Deposit{OutPoint: testOutPoint(1)}
depositManager := &mockDepositManager{
openingDeposits: []*deposit.Deposit{d},
transitionErrs: map[fsm.EventType]error{
deposit.OnChannelPublished: errors.New(
"transition failed",
),
},
}
walletKit := &mockWalletKit{
// UTXO is spent, so recovery tries ChannelPublished transition.
utxos: []*lnwallet.Utxo{},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
WalletKit: walletKit,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.ErrorContains(t, err, "unable to recover spent opening deposits")
}
// TestRecoverGetActiveDepositsError verifies that a failure to fetch opening
// channel deposits is surfaced.
func TestRecoverGetActiveDepositsError(t *testing.T) {
t.Parallel()
depositManager := &mockDepositManager{
getErr: errors.New("db connection lost"),
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
},
}
err := manager.recoverOpeningChannelDeposits(context.Background())
require.ErrorContains(t, err, "unable to fetch opening channel deposits")
}
func testOutPoint(b byte) wire.OutPoint {
return wire.OutPoint{
Hash: chainhash.Hash{b},
Index: uint32(b),
}
}
// TestOpenChannelDuplicateOutpoints verifies that OpenChannel rejects requests
// containing duplicate outpoints, which would cause fee miscalculation and an
// invalid PSBT with the same input listed twice.
func TestOpenChannelDuplicateOutpoints(t *testing.T) {
t.Parallel()
op := testOutPoint(1)
manager := &Manager{
cfg: &Config{},
}
req := &lnrpc.OpenChannelRequest{
NodePubkey: make([]byte, 33),
LocalFundingAmount: 100000,
SatPerVbyte: 10,
Outpoints: []*lnrpc.OutPoint{
{
TxidStr: op.Hash.String(),
OutputIndex: op.Index,
},
{
TxidStr: op.Hash.String(),
OutputIndex: op.Index,
},
},
}
_, err := manager.OpenChannel(context.Background(), req)
require.ErrorContains(t, err, "duplicate outpoint")
}
// TestOpenChannelSkipsUnconfirmedAutoSelection verifies that automatic coin
// selection ignores mempool deposits and keeps using confirmed ones.
func TestOpenChannelSkipsUnconfirmedAutoSelection(t *testing.T) {
t.Parallel()
confirmedA := &deposit.Deposit{
OutPoint: testOutPoint(1),
Value: 160_000,
ConfirmationHeight: 10,
}
confirmedB := &deposit.Deposit{
OutPoint: testOutPoint(2),
Value: 140_000,
ConfirmationHeight: 11,
}
unconfirmed := &deposit.Deposit{
OutPoint: testOutPoint(3),
Value: 500_000,
}
depositManager := &mockDepositManager{
activeDeposits: []*deposit.Deposit{
unconfirmed, confirmedA, confirmedB,
},
transitionErrs: map[fsm.EventType]error{
deposit.OnOpeningChannel: errors.New("stop after selection"),
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
},
}
req := &lnrpc.OpenChannelRequest{
NodePubkey: make([]byte, 33),
LocalFundingAmount: 100_000,
SatPerVbyte: 10,
}
_, err := manager.OpenChannel(context.Background(), req)
require.ErrorContains(t, err, "stop after selection")
require.Len(t, depositManager.calls, 1)
require.Equal(t, deposit.OnOpeningChannel, depositManager.calls[0].event)
require.NotContains(t, depositManager.calls[0].outpoints, unconfirmed.OutPoint)
}
// TestOpenChannelFundMaxSkipsUnconfirmed verifies that fundmax only locks
// confirmed deposits.
func TestOpenChannelFundMaxSkipsUnconfirmed(t *testing.T) {
t.Parallel()
confirmed := &deposit.Deposit{
OutPoint: testOutPoint(1),
Value: 200_000,
ConfirmationHeight: 10,
}
unconfirmed := &deposit.Deposit{
OutPoint: testOutPoint(2),
Value: 300_000,
}
depositManager := &mockDepositManager{
activeDeposits: []*deposit.Deposit{
unconfirmed, confirmed,
},
transitionErrs: map[fsm.EventType]error{
deposit.OnOpeningChannel: errors.New("stop after selection"),
},
}
manager := &Manager{
cfg: &Config{
DepositManager: depositManager,
},
}
req := &lnrpc.OpenChannelRequest{
NodePubkey: make([]byte, 33),
FundMax: true,
SatPerVbyte: 10,
}
_, err := manager.OpenChannel(context.Background(), req)
require.ErrorContains(t, err, "stop after selection")
require.Len(t, depositManager.calls, 1)
require.Equal(
t, []wire.OutPoint{confirmed.OutPoint},
depositManager.calls[0].outpoints,
)
}
// TestValidateInitialPsbtFlags verifies that request fields incompatible with
// PSBT funding are rejected early, before any deposits are locked.
func TestValidateInitialPsbtFlags(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req *lnrpc.OpenChannelRequest
expectedErrSubstr string
}{
{
name: "default min confs accepted",
req: &lnrpc.OpenChannelRequest{},
},
{
name: "explicit default min confs accepted",
req: &lnrpc.OpenChannelRequest{
MinConfs: defaultUtxoMinConf,
},
},
{
name: "custom min confs rejected",
req: &lnrpc.OpenChannelRequest{
MinConfs: defaultUtxoMinConf + 1,
},
expectedErrSubstr: "custom MinConfs not supported",
},
{
name: "spend unconfirmed rejected",
req: &lnrpc.OpenChannelRequest{
MinConfs: defaultUtxoMinConf,
SpendUnconfirmed: true,
},
expectedErrSubstr: "SpendUnconfirmed is not supported",
},
{
name: "target conf rejected",
req: &lnrpc.OpenChannelRequest{
TargetConf: 6,
},
expectedErrSubstr: "TargetConf is not supported",
},
{
name: "sat per byte rejected",
req: &lnrpc.OpenChannelRequest{
SatPerByte: 10, //nolint:staticcheck
},
expectedErrSubstr: "SatPerByte is deprecated",
},
{
name: "node pubkey string rejected",
req: &lnrpc.OpenChannelRequest{
NodePubkeyString: "abc", //nolint:staticcheck
},
expectedErrSubstr: "NodePubkeyString is not supported",
},
{
name: "funding shim rejected",
req: &lnrpc.OpenChannelRequest{
FundingShim: &lnrpc.FundingShim{},
},
expectedErrSubstr: "FundingShim is not supported",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateInitialPsbtFlags(tc.req)
if tc.expectedErrSubstr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.expectedErrSubstr)
})
}
}
// TestResolveCommitmentType verifies that supported commitment types are
// resolved correctly and unsupported types are rejected.
func TestResolveCommitmentType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
commitmentType lnrpc.CommitmentType
expectedType lnrpc.CommitmentType
expectedErrSubstr string
}{
{
name: "unknown defaults to static remote key",
commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE,
expectedType: lnrpc.CommitmentType_STATIC_REMOTE_KEY,
},
{
name: "static remote key supported",
commitmentType: lnrpc.CommitmentType_STATIC_REMOTE_KEY,
expectedType: lnrpc.CommitmentType_STATIC_REMOTE_KEY,
},
{
name: "anchors supported",
commitmentType: lnrpc.CommitmentType_ANCHORS,
expectedType: lnrpc.CommitmentType_ANCHORS,
},
{
name: "simple taproot supported",
commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
expectedType: lnrpc.CommitmentType_SIMPLE_TAPROOT,
},
{
name: "production taproot supported",
commitmentType: lnrpc.CommitmentType_TAPROOT,
expectedType: lnrpc.CommitmentType_TAPROOT,
},
{
name: "legacy rejected",
commitmentType: lnrpc.CommitmentType_LEGACY,
expectedErrSubstr: "unsupported commitment type",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
commitmentType, err := resolveCommitmentType(
tc.commitmentType,
)
if tc.expectedErrSubstr == "" {
require.NoError(t, err)
require.Equal(t, tc.expectedType, commitmentType)
return
}
require.ErrorContains(t, err, tc.expectedErrSubstr)
})
}
}
// TestMaybeWrapTaprootUnsupportedError verifies that generic old-lnd channel
// type rejections become actionable for Loop users selecting production
// taproot channels.
func TestMaybeWrapTaprootUnsupportedError(t *testing.T) {
t.Parallel()
baseErr := errors.New("got error from server: rpc error: " +
"code = Unknown desc = unhandled request channel type 7")
req := &lnrpc.OpenChannelRequest{
CommitmentType: lnrpc.CommitmentType_TAPROOT,
}
err := maybeWrapTaprootUnsupportedError(req, baseErr)
require.ErrorContains(
t, err, "channel_type=taproot is not supported",
)
require.ErrorContains(
t, err, "update LND to v0.21.0-beta or later",
)
require.ErrorIs(t, err, baseErr)
req.CommitmentType = lnrpc.CommitmentType_SIMPLE_TAPROOT
err = maybeWrapTaprootUnsupportedError(req, baseErr)
require.ErrorIs(t, err, baseErr)
require.NotContains(t, err.Error(), "update LND")
}
// ---------------------------------------------------------------------------
// Mock types for PSBT channel open flow tests.
// ---------------------------------------------------------------------------
// mockLndClient implements lndclient.LightningClient for testing. Embedding
// the interface means unimplemented methods panic if called, which is
// desirable in tests to surface unexpected interactions.
type mockLndClient struct {
lndclient.LightningClient
rawClient lnrpc.LightningClient
mu sync.Mutex
fundingStepIdx int
fundingStepErr error
fundingStepCtxErrs []error
}
func (m *mockLndClient) RawClientWithMacAuth(
ctx context.Context) (context.Context, time.Duration,
lnrpc.LightningClient) {
return ctx, 0, m.rawClient
}
func (m *mockLndClient) FundingStateStep(ctx context.Context,
_ *lnrpc.FundingTransitionMsg) (*lnrpc.FundingStateStepResp, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.fundingStepIdx++
m.fundingStepCtxErrs = append(m.fundingStepCtxErrs, ctx.Err())
return &lnrpc.FundingStateStepResp{}, m.fundingStepErr
}
// mockRawLnrpcClient implements the raw gRPC lnrpc.LightningClient.
type mockRawLnrpcClient struct {
lnrpc.LightningClient
stream lnrpc.Lightning_OpenChannelClient
openErr error
}
func (m *mockRawLnrpcClient) OpenChannel(_ context.Context,
_ *lnrpc.OpenChannelRequest,
_ ...grpc.CallOption) (lnrpc.Lightning_OpenChannelClient, error) {
return m.stream, m.openErr
}
// mockClientStream implements grpc.ClientStream for embedding in
// mockOpenChanStream.
type mockClientStream struct{}
func (m *mockClientStream) Header() (metadata.MD, error) {
return nil, nil
}
func (m *mockClientStream) Trailer() metadata.MD { return nil }
func (m *mockClientStream) CloseSend() error { return nil }
func (m *mockClientStream) Context() context.Context {
return context.Background()
}
func (m *mockClientStream) SendMsg(_ any) error { return nil }
func (m *mockClientStream) RecvMsg(_ any) error { return nil }
// mockOpenChanStream implements lnrpc.Lightning_OpenChannelClient. It returns
// queued messages from Recv(), then returns finalErr once the queue is
// exhausted.
type mockOpenChanStream struct {
*mockClientStream
mu sync.Mutex
msgs []*lnrpc.OpenStatusUpdate
finalErr error
idx int
}
func (m *mockOpenChanStream) Recv() (*lnrpc.OpenStatusUpdate, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.idx >= len(m.msgs) {
return nil, m.finalErr
}
msg := m.msgs[m.idx]
m.idx++
return msg, nil
}
// mockWithdrawManager implements the WithdrawalManager interface.
type mockWithdrawManager struct {
tx *wire.MsgTx
psbt []byte
err error
}
func (m *mockWithdrawManager) CreateFinalizedWithdrawalTx(
_ context.Context, _ []*deposit.Deposit,
_ btcutil.Address, _ chainfee.SatPerKWeight, _ int64,
_ lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) {
return m.tx, m.psbt, m.err
}
// testFundingAddress returns a valid regtest P2WPKH address for use in tests.
func testFundingAddress() string {
addr, _ := btcutil.NewAddressWitnessPubKeyHash(
make([]byte, 20), &chaincfg.RegressionNetParams,
)
return addr.EncodeAddress()
}
// ---------------------------------------------------------------------------
// Stream-level tests for the PSBT channel open flow.
// ---------------------------------------------------------------------------
// TestStreamOpenError verifies that when the lnd OpenChannel stream fails to
// open, the error is returned and the shim is cleaned up.
func TestStreamOpenError(t *testing.T) {
t.Parallel()
mockRaw := &mockRawLnrpcClient{
openErr: errors.New("connection refused"),
}
lnClient := &mockLndClient{rawClient: mockRaw}
manager := &Manager{
cfg: &Config{
LightningClient: lnClient,
ChainParams: &chaincfg.RegressionNetParams,
},
}
req := &lnrpc.OpenChannelRequest{
LocalFundingAmount: 100000,
MinConfs: defaultUtxoMinConf,
}
_, err := manager.openChannelPsbt(
context.Background(), req, nil, 0,
)
require.ErrorContains(t, err, "opening stream to server failed")
require.False(t, errors.Is(err, errPsbtFinalized))
// Verify that the shim was canceled via FundingStateStep.
lnClient.mu.Lock()
require.Equal(t, 1, lnClient.fundingStepIdx)
require.Len(t, lnClient.fundingStepCtxErrs, 1)
require.NoError(t, lnClient.fundingStepCtxErrs[0])
lnClient.mu.Unlock()
}
// TestStreamOpenErrorWithCanceledContext verifies that deferred shim cleanup
// still uses a live context even if the caller canceled the original request.
func TestStreamOpenErrorWithCanceledContext(t *testing.T) {
t.Parallel()
mockRaw := &mockRawLnrpcClient{
openErr: errors.New("connection refused"),
}
lnClient := &mockLndClient{rawClient: mockRaw}
manager := &Manager{
cfg: &Config{
LightningClient: lnClient,
ChainParams: &chaincfg.RegressionNetParams,
},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := &lnrpc.OpenChannelRequest{
LocalFundingAmount: 100000,
MinConfs: defaultUtxoMinConf,
}
_, err := manager.openChannelPsbt(ctx, req, nil, 0)
require.ErrorContains(t, err, "opening stream to server failed")
lnClient.mu.Lock()
require.Equal(t, 1, lnClient.fundingStepIdx)
require.Len(t, lnClient.fundingStepCtxErrs, 1)
require.NoError(t, lnClient.fundingStepCtxErrs[0])
lnClient.mu.Unlock()
}
// TestStreamErrorBeforePsbtFinalize verifies that when the lnd stream returns
// an error before the PSBT is finalized, deposits are NOT wrapped in
// errPsbtFinalized so the caller can safely roll them back.
func TestStreamErrorBeforePsbtFinalize(t *testing.T) {
t.Parallel()
stream := &mockOpenChanStream{
mockClientStream: &mockClientStream{},
finalErr: errors.New("peer disconnected"),
}
mockRaw := &mockRawLnrpcClient{stream: stream}
lnClient := &mockLndClient{rawClient: mockRaw}
manager := &Manager{
cfg: &Config{
LightningClient: lnClient,
ChainParams: &chaincfg.RegressionNetParams,
},
}
req := &lnrpc.OpenChannelRequest{
LocalFundingAmount: 100000,
MinConfs: defaultUtxoMinConf,
}
_, err := manager.openChannelPsbt(
context.Background(), req, nil, 0,
)
require.Error(t, err)
require.False(t, errors.Is(err, errPsbtFinalized))
}
// TestPsbtFinalizeThenStreamAbort verifies that when the PSBT finalize step
// succeeds but the stream dies before ChanPending, the error is wrapped with
// errPsbtFinalized so that the caller knows deposits must not be blindly
// rolled back.
func TestPsbtFinalizeThenStreamAbort(t *testing.T) {
t.Parallel()
fundingAmt := int64(100000)
fundingAddr := testFundingAddress()
stream := &mockOpenChanStream{
mockClientStream: &mockClientStream{},
msgs: []*lnrpc.OpenStatusUpdate{
{
Update: &lnrpc.OpenStatusUpdate_PsbtFund{
PsbtFund: &lnrpc.ReadyForPsbtFunding{
FundingAmount: fundingAmt,
FundingAddress: fundingAddr,
},
},
},
},
finalErr: errors.New("stream died after finalize"),
}
mockRaw := &mockRawLnrpcClient{stream: stream}
lnClient := &mockLndClient{rawClient: mockRaw}
// Provide a minimal transaction that can be serialized.
withdrawMgr := &mockWithdrawManager{
tx: wire.NewMsgTx(2),
psbt: []byte("unsigned-psbt"),
}
manager := &Manager{
cfg: &Config{
LightningClient: lnClient,
WithdrawalManager: withdrawMgr,
ChainParams: &chaincfg.RegressionNetParams,
},
}
req := &lnrpc.OpenChannelRequest{
LocalFundingAmount: fundingAmt,
MinConfs: defaultUtxoMinConf,
}
_, err := manager.openChannelPsbt(
context.Background(), req, nil, 0,
)
require.Error(t, err)
require.True(t, errors.Is(err, errPsbtFinalized))
// FundingStateStep should have been called 3 times: verify, finalize,
// and shim cancel (from defer).
lnClient.mu.Lock()
require.Equal(t, 3, lnClient.fundingStepIdx)
lnClient.mu.Unlock()
}