staticaddr: require confirmed deposits for withdraws and channel opens

Deposited can now include mempool outputs for static loop-ins, but
withdrawals and static channel opens still require confirmed funding
inputs. Filter automatic channel-open selection to confirmed deposits
and reject explicit unconfirmed selections, including withdraw-all
requests that would otherwise silently include mempool deposits.
This commit is contained in:
Slyghtning 2026-07-08 13:53:07 +02:00
parent e8dd3aa009
commit 557ba99513
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
5 changed files with 225 additions and 9 deletions

View file

@ -1805,8 +1805,9 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
return nil, err
}
for _, d := range deposits {
outpoints = append(outpoints, d.OutPoint)
outpoints, err = withdrawAllDepositOutpoints(deposits)
if err != nil {
return nil, err
}
case isUtxoSelected:
@ -1829,6 +1830,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
}, err
}
// withdrawAllDepositOutpoints returns all deposit outpoints for an `all`
// withdrawal request. The request must fail if any deposited output is still
// unconfirmed because `all` should not silently downgrade to a subset.
func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint,
error) {
outpoints := make([]wire.OutPoint, 0, len(deposits))
for _, d := range deposits {
if d.GetConfirmationHeight() <= 0 {
return nil, fmt.Errorf("can't withdraw all deposits while " +
"some deposits are unconfirmed")
}
outpoints = append(outpoints, d.OutPoint)
}
return outpoints, nil
}
// ListStaticAddressDeposits returns a list of all sufficiently confirmed
// deposits behind the static address and displays properties like value,
// state or blocks til expiry.

View file

@ -2,6 +2,10 @@ package loopd
import (
"testing"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/staticaddr/deposit"
)
// TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for
@ -21,3 +25,62 @@ func TestDepositBlocksUntilExpiry(t *testing.T) {
}
})
}
// TestWithdrawAllDepositOutpoints checks `all` withdrawal handling for
// confirmed and unconfirmed deposits.
func TestWithdrawAllDepositOutpoints(t *testing.T) {
t.Run("rejects unconfirmed", func(t *testing.T) {
deposits := []*deposit.Deposit{
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
},
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
ConfirmationHeight: 123,
},
}
_, err := withdrawAllDepositOutpoints(deposits)
if err == nil {
t.Fatal("expected unconfirmed deposit to fail all withdrawal")
}
})
t.Run("returns all confirmed", func(t *testing.T) {
first := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
}
second := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 4,
}
deposits := []*deposit.Deposit{
{
OutPoint: first,
ConfirmationHeight: 123,
},
{
OutPoint: second,
ConfirmationHeight: 124,
},
}
outpoints, err := withdrawAllDepositOutpoints(deposits)
if err != nil {
t.Fatalf("expected confirmed deposits to succeed: %v", err)
}
if len(outpoints) != 2 {
t.Fatalf("expected 2 outpoints, got %d", len(outpoints))
}
if outpoints[0] != first || outpoints[1] != second {
t.Fatal("expected all confirmed outpoints to remain selected")
}
})
}

View file

@ -305,6 +305,10 @@ func (m *Manager) OpenChannel(ctx context.Context,
return nil, err
}
// Automatic channel funding must ignore mempool deposits because
// they cannot yet be used as funding inputs.
deposits = filterConfirmedDeposits(deposits)
// If a local funding amount is set, coin-select deposits to
// cover it. Otherwise fundmax uses all available deposits.
if req.LocalFundingAmount != 0 {
@ -319,6 +323,14 @@ func (m *Manager) OpenChannel(ctx context.Context,
}
}
for _, d := range deposits {
// Deposited now includes mempool outputs for static loop-ins, but
// channel opens still require the deposit input to be confirmed.
if d.GetConfirmationHeight() <= 0 {
return nil, ErrOpeningChannelUnavailableDeposits
}
}
// Pre-check: calculate the channel funding amount and the optional
// change before locking deposits. This ensures the selected deposits
// can cover the funding amount plus fees.
@ -394,6 +406,22 @@ func (m *Manager) OpenChannel(ctx context.Context,
return nil, err
}
// filterConfirmedDeposits filters the given deposits and returns only those
// that have a positive confirmation height, i.e. deposits that have been
// confirmed on-chain.
func filterConfirmedDeposits(deposits []*deposit.Deposit) []*deposit.Deposit {
confirmed := make([]*deposit.Deposit, 0, len(deposits))
for _, d := range deposits {
if d.GetConfirmationHeight() <= 0 {
continue
}
confirmed = append(confirmed, d)
}
return confirmed
}
// openChannelPsbt starts an interactive channel open protocol that uses a
// partially signed bitcoin transaction (PSBT) to fund the channel output. The
// protocol involves several steps between the loop client and the server:

View file

@ -29,6 +29,7 @@ type transitionCall struct {
}
type mockDepositManager struct {
activeDeposits []*deposit.Deposit
openingDeposits []*deposit.Deposit
getErr error
transitionErrs map[fsm.EventType]error
@ -44,15 +45,19 @@ func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) (
[]*deposit.Deposit, error) {
if stateFilter != deposit.OpeningChannel {
return nil, nil
switch stateFilter {
case deposit.Deposited:
return m.activeDeposits, nil
case deposit.OpeningChannel:
if m.getErr != nil {
return nil, m.getErr
}
return m.openingDeposits, nil
}
if m.getErr != nil {
return nil, m.getErr
}
return m.openingDeposits, nil
return nil, nil
}
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
@ -464,6 +469,97 @@ func TestOpenChannelDuplicateOutpoints(t *testing.T) {
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) {

View file

@ -381,6 +381,15 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
}
}
for _, d := range deposits {
// Deposited now includes mempool outputs for static loop-ins, but
// withdrawals still require the deposit input to be confirmed.
if d.GetConfirmationHeight() <= 0 {
return "", "", fmt.Errorf("can't withdraw, " +
"unconfirmed deposits can't be withdrawn")
}
}
var (
withdrawalAddress btcutil.Address
err error