mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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:
parent
e8dd3aa009
commit
557ba99513
5 changed files with 225 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue