mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Refresh the active static-address deposit set against lnd's wallet view before quote, loop-in, withdrawal, channel-open, and autoloop selection paths. This prevents stale persisted Deposited records from being selected after replacement, reorg, or an external spend.
1012 lines
27 KiB
Go
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()
|
|
}
|