mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
LND v0.21 added the production TAPROOT commitment type while SIMPLE_TAPROOT remains available as the legacy enum. The static open-channel CLI previously used "taproot" for SIMPLE_TAPROOT. Keep both choices available by renaming that legacy spelling to "simple-taproot" and mapping "taproot" to TAPROOT. This makes the CLI spelling match the channel type it requests while still leaving an explicit path for users that need SIMPLE_TAPROOT.
912 lines
24 KiB
Go
912 lines
24 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: "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()
|
|
}
|