From ee5d84b3237d4f212d80da6af278b4701c491dcc Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 07:16:34 +0200 Subject: [PATCH] staticaddr/deposit: reject duplicate outpoints Reject duplicate static-address deposit outpoints before creating withdrawal, loop-in, or channel-open requests. Use the shared outpoint duplicate helper so each flow reports the same input validation failure. --- staticaddr/deposit/manager.go | 7 +++ staticaddr/deposit/manager_reconcile_test.go | 58 ++++++++++++++++++++ staticaddr/deposit/outpoint.go | 21 +++++++ staticaddr/deposit/outpoint_test.go | 40 ++++++++++++++ staticaddr/openchannel/manager.go | 9 +-- staticaddr/staticutil/utils.go | 19 ++++--- 6 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 staticaddr/deposit/manager_reconcile_test.go create mode 100644 staticaddr/deposit/outpoint.go create mode 100644 staticaddr/deposit/outpoint_test.go diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index af882030..aaadc798 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -439,6 +439,10 @@ func (m *Manager) GetActiveDepositsInState(stateFilter fsm.StateType) ( func (m *Manager) AllOutpointsActiveDeposits(outpoints []wire.OutPoint, targetState fsm.StateType) ([]*Deposit, bool) { + if CheckDuplicates(outpoints) != nil { + return nil, false + } + m.mu.Lock() defer m.mu.Unlock() @@ -497,6 +501,9 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, for i, d := range deposits { outpoints[i] = d.OutPoint } + if err := CheckDuplicates(outpoints); err != nil { + return fmt.Errorf("duplicate deposit outpoint: %w", err) + } m.mu.Lock() stateMachines, _ := m.toActiveDeposits(&outpoints) diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go new file mode 100644 index 00000000..eb68acb8 --- /dev/null +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -0,0 +1,58 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a +// duplicated selection is rejected before the manager tries to lock the same +// deposit twice. +func TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints(t *testing.T) { + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{12}, + Index: 6, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Deposited) + + manager := NewManager(&ManagerConfig{}) + manager.deposits[outpoint] = deposit + manager.activeDeposits[outpoint] = &FSM{ + deposit: deposit, + } + + deposits, ok := manager.AllOutpointsActiveDeposits( + []wire.OutPoint{outpoint, outpoint}, Deposited, + ) + require.False(t, ok) + require.Nil(t, deposits) +} + +// TestTransitionDepositsRejectsDuplicateOutpoints verifies that transition +// callers cannot deadlock the manager by passing the same deposit twice. +func TestTransitionDepositsRejectsDuplicateOutpoints(t *testing.T) { + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{13}, + Index: 6, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Deposited) + + manager := NewManager(&ManagerConfig{}) + err := manager.TransitionDeposits( + t.Context(), []*Deposit{deposit, deposit}, OnLoopInInitiated, + LoopingIn, + ) + require.ErrorContains(t, err, "duplicate deposit outpoint") + require.Equal(t, Deposited, deposit.GetState()) +} diff --git a/staticaddr/deposit/outpoint.go b/staticaddr/deposit/outpoint.go new file mode 100644 index 00000000..64a3a701 --- /dev/null +++ b/staticaddr/deposit/outpoint.go @@ -0,0 +1,21 @@ +package deposit + +import ( + "fmt" + + "github.com/btcsuite/btcd/wire" +) + +// CheckDuplicates returns an error if the outpoint list contains duplicates. +func CheckDuplicates(outpoints []wire.OutPoint) error { + seen := make(map[wire.OutPoint]struct{}, len(outpoints)) + for _, outpoint := range outpoints { + if _, ok := seen[outpoint]; ok { + return fmt.Errorf("duplicate outpoint %v", outpoint) + } + + seen[outpoint] = struct{}{} + } + + return nil +} diff --git a/staticaddr/deposit/outpoint_test.go b/staticaddr/deposit/outpoint_test.go new file mode 100644 index 00000000..fff86458 --- /dev/null +++ b/staticaddr/deposit/outpoint_test.go @@ -0,0 +1,40 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +func TestCheckDuplicates(t *testing.T) { + duplicate := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 2, + } + + outpoints := []wire.OutPoint{{ + Hash: chainhash.Hash{3}, + Index: 4, + }, duplicate, { + Hash: chainhash.Hash{5}, + Index: 6, + }, duplicate} + + err := CheckDuplicates(outpoints) + require.ErrorContains(t, err, "duplicate outpoint") + require.ErrorContains(t, err, duplicate.String()) +} + +func TestCheckDuplicatesNoDuplicate(t *testing.T) { + outpoints := []wire.OutPoint{{ + Hash: chainhash.Hash{1}, + Index: 2, + }, { + Hash: chainhash.Hash{3}, + Index: 4, + }} + + require.NoError(t, CheckDuplicates(outpoints)) +} diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 17f6c6a1..ca7f5f1c 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -284,13 +284,8 @@ func (m *Manager) OpenChannel(ctx context.Context, // Check for duplicate outpoints which would lead to fee // miscalculation and an invalid PSBT with the same input // listed twice. - seen := make(map[wire.OutPoint]struct{}, len(outpoints)) - for _, op := range outpoints { - if _, ok := seen[op]; ok { - return nil, fmt.Errorf("duplicate outpoint "+ - "%v in request", op) - } - seen[op] = struct{}{} + if err := deposit.CheckDuplicates(outpoints); err != nil { + return nil, fmt.Errorf("%w in request", err) } deposits, allActive = diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index a8a5e404..a2509333 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -24,20 +24,21 @@ import ( func ToPrevOuts(deposits []*deposit.Deposit, pkScript []byte) (map[wire.OutPoint]*wire.TxOut, error) { + outpoints := make([]wire.OutPoint, len(deposits)) + for i, d := range deposits { + outpoints[i] = d.OutPoint + } + if err := deposit.CheckDuplicates(outpoints); err != nil { + return nil, err + } + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) - for _, d := range deposits { - outpoint := wire.OutPoint{ - Hash: d.Hash, - Index: d.Index, - } + for i, d := range deposits { + outpoint := outpoints[i] txOut := &wire.TxOut{ Value: int64(d.Value), PkScript: pkScript, } - if _, ok := prevOuts[outpoint]; ok { - return nil, fmt.Errorf("duplicate outpoint %v", - outpoint) - } prevOuts[outpoint] = txOut }