loop/staticaddr/openchannel/manager_test.go
2026-03-09 14:39:45 +01:00

880 lines
23 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 {
openingDeposits []*deposit.Deposit
getErr error
transitionErrs map[fsm.EventType]error
calls []transitionCall
}
func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
fsm.StateType) ([]*deposit.Deposit, bool) {
return nil, false
}
func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) (
[]*deposit.Deposit, error) {
if stateFilter != deposit.OpeningChannel {
return nil, nil
}
if m.getErr != nil {
return nil, m.getErr
}
return m.openingDeposits, 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")
}
// 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: "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)
})
}
}
// ---------------------------------------------------------------------------
// 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()
}