Skip to content

Commit 09ecf87

Browse files
committed
staticaddr: preserve deposit identity across RBF
When an unconfirmed static-address deposit was replaced, the client reconciler only knew how to mark the original outpoint gone and discover the descendant as a brand-new deposit. That split the logical deposit identity across two records: the old deposit, active FSM and pending loop-in still pointed at the dead outpoint while the replacement surfaced as unrelated new funds. Teach the deposit manager to rebind uniquely matched unconfirmed replacements in place, carrying the deposit ID, state and active FSM forward to the new outpoint. This keeps looping-in deposits attached to the replacement chain instead of leaving them stranded on the vanished outpoint. Keep the existing vanished-deposit invalidation path for cases that cannot be matched safely. Also reload static loop-ins from the current deposit rows rather than the stale outpoint snapshot stored at swap creation time, and add targeted tests for replacement rebinding and recovered outpoint loading.
1 parent cff1a83 commit 09ecf87

11 files changed

Lines changed: 1179 additions & 40 deletions

staticaddr/deposit/manager.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sync/atomic"
1010
"time"
1111

12+
"github.com/btcsuite/btcd/btcutil"
1213
"github.com/btcsuite/btcd/txscript"
1314
"github.com/btcsuite/btcd/wire"
1415
"github.com/lightninglabs/lndclient"
@@ -307,6 +308,12 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
307308
err)
308309
}
309310

311+
err = m.reconcileReplacementDeposits(ctx, utxos)
312+
if err != nil {
313+
return fmt.Errorf("unable to reconcile replacement "+
314+
"deposits: %w", err)
315+
}
316+
310317
// After handling reappearances, only still-missing outpoints contribute
311318
// towards replacement detection.
312319
err = m.invalidateVanishedDeposits(ctx, utxos)
@@ -395,6 +402,139 @@ func (m *Manager) listUnspentWithBestHeight(ctx context.Context) (
395402
return nil, 0, errors.New("unable to get stable best block while " +
396403
"listing deposits")
397404
}
405+
406+
// reconcileReplacementDeposits rebinds uniquely matchable unconfirmed
407+
// deposits to a replacement outpoint when the original outpoint vanished from
408+
// the wallet's unspent set. This preserves the logical deposit identity across
409+
// RBF replacements and keeps any attached loop-in state tied to the same
410+
// deposit record.
411+
func (m *Manager) reconcileReplacementDeposits(ctx context.Context,
412+
utxos []*lnwallet.Utxo) error {
413+
414+
knownOutpoints := make(map[wire.OutPoint]struct{}, len(utxos))
415+
unknownByValue := make(map[btcutil.Amount][]*lnwallet.Utxo)
416+
417+
m.mu.Lock()
418+
for _, utxo := range utxos {
419+
knownOutpoints[utxo.OutPoint] = struct{}{}
420+
421+
if _, ok := m.deposits[utxo.OutPoint]; ok {
422+
delete(m.missingDeposits, utxo.OutPoint)
423+
continue
424+
}
425+
426+
unknownByValue[utxo.Value] = append(
427+
unknownByValue[utxo.Value], utxo,
428+
)
429+
}
430+
431+
deposits := make([]*Deposit, 0, len(m.deposits))
432+
for _, deposit := range m.deposits {
433+
deposits = append(deposits, deposit)
434+
}
435+
m.mu.Unlock()
436+
437+
missingByValue := make(map[btcutil.Amount][]*Deposit)
438+
for _, deposit := range deposits {
439+
if _, ok := knownOutpoints[deposit.OutPoint]; ok {
440+
continue
441+
}
442+
443+
deposit.Lock()
444+
state := deposit.state
445+
unconfirmed := deposit.ConfirmationHeight == 0
446+
value := deposit.Value
447+
deposit.Unlock()
448+
449+
if !unconfirmed {
450+
continue
451+
}
452+
453+
switch state {
454+
case Deposited, LoopingIn:
455+
default:
456+
continue
457+
}
458+
459+
missingByValue[value] = append(missingByValue[value], deposit)
460+
}
461+
462+
for value, missingDeposits := range missingByValue {
463+
replacementUtxos := unknownByValue[value]
464+
if len(missingDeposits) != 1 || len(replacementUtxos) != 1 {
465+
continue
466+
}
467+
468+
err := m.rebindReplacementDeposit(
469+
ctx, missingDeposits[0], replacementUtxos[0],
470+
)
471+
if err != nil {
472+
return err
473+
}
474+
}
475+
476+
return nil
477+
}
478+
479+
// rebindReplacementDeposit moves a deposit's identity from a disappeared
480+
// outpoint to a replacement wallet UTXO while preserving its state and ID.
481+
func (m *Manager) rebindReplacementDeposit(ctx context.Context, deposit *Deposit,
482+
utxo *lnwallet.Utxo) error {
483+
484+
m.mu.Lock()
485+
defer m.mu.Unlock()
486+
487+
deposit.Lock()
488+
defer deposit.Unlock()
489+
if deposit.ConfirmationHeight != 0 {
490+
return nil
491+
}
492+
493+
switch deposit.state {
494+
case Deposited, LoopingIn:
495+
default:
496+
return nil
497+
}
498+
confirmationHeight, err := confirmationHeightForUtxo(
499+
int32(m.currentHeight.Load()), utxo,
500+
)
501+
if err != nil {
502+
return err
503+
}
504+
505+
oldOutpoint := deposit.OutPoint
506+
previousValue := deposit.Value
507+
previousConfirmationHeight := deposit.ConfirmationHeight
508+
509+
deposit.OutPoint = utxo.OutPoint
510+
deposit.Value = utxo.Value
511+
deposit.ConfirmationHeight = confirmationHeight
512+
513+
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
514+
if err != nil {
515+
deposit.OutPoint = oldOutpoint
516+
deposit.Value = previousValue
517+
deposit.ConfirmationHeight = previousConfirmationHeight
518+
return err
519+
}
520+
521+
delete(m.deposits, oldOutpoint)
522+
delete(m.missingDeposits, oldOutpoint)
523+
delete(m.missingDeposits, deposit.OutPoint)
524+
m.deposits[deposit.OutPoint] = deposit
525+
526+
fsm, ok := m.activeDeposits[oldOutpoint]
527+
if ok {
528+
delete(m.activeDeposits, oldOutpoint)
529+
m.activeDeposits[deposit.OutPoint] = fsm
530+
}
531+
532+
log.Infof("Rebound deposit %x from %v to replacement %v",
533+
deposit.ID, oldOutpoint, deposit.OutPoint)
534+
535+
return nil
536+
}
537+
398538
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
399539
// it in our database and manager memory.
400540
func (m *Manager) createNewDeposit(ctx context.Context,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package deposit
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/btcsuite/btcd/btcutil"
8+
"github.com/btcsuite/btcd/chaincfg/chainhash"
9+
"github.com/btcsuite/btcd/wire"
10+
"github.com/lightninglabs/loop/fsm"
11+
"github.com/lightningnetwork/lnd/lnwallet"
12+
"github.com/stretchr/testify/mock"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestRebindReplacementDepositRevalidatesCandidate(t *testing.T) {
17+
ctx := context.Background()
18+
oldOutpoint := wire.OutPoint{
19+
Hash: chainhash.Hash{6},
20+
Index: 2,
21+
}
22+
newOutpoint := wire.OutPoint{
23+
Hash: chainhash.Hash{7},
24+
Index: 3,
25+
}
26+
27+
testCases := []struct {
28+
name string
29+
confirmationHeight int64
30+
state fsm.StateType
31+
}{
32+
{
33+
name: "final state",
34+
state: LoopedIn,
35+
},
36+
{
37+
name: "confirmed",
38+
confirmationHeight: 44,
39+
state: LoopingIn,
40+
},
41+
}
42+
43+
for _, tc := range testCases {
44+
t.Run(tc.name, func(t *testing.T) {
45+
deposit := &Deposit{
46+
OutPoint: oldOutpoint,
47+
Value: btcutil.Amount(50_000),
48+
ConfirmationHeight: tc.confirmationHeight,
49+
}
50+
deposit.SetState(tc.state)
51+
52+
mockStore := new(mockStore)
53+
manager := NewManager(&ManagerConfig{
54+
Store: mockStore,
55+
})
56+
manager.deposits[oldOutpoint] = deposit
57+
fsm := &FSM{}
58+
manager.activeDeposits[oldOutpoint] = fsm
59+
manager.missingDeposits[oldOutpoint] = 1
60+
61+
err := manager.rebindReplacementDeposit(ctx, deposit,
62+
&lnwallet.Utxo{
63+
OutPoint: newOutpoint,
64+
Value: deposit.Value,
65+
},
66+
)
67+
require.NoError(t, err)
68+
69+
require.Same(t, deposit, manager.deposits[oldOutpoint])
70+
_, ok := manager.deposits[newOutpoint]
71+
require.False(t, ok)
72+
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
73+
_, ok = manager.activeDeposits[newOutpoint]
74+
require.False(t, ok)
75+
require.Equal(t, oldOutpoint, deposit.OutPoint)
76+
77+
mockStore.AssertNotCalled(
78+
t, "UpdateDeposit", mock.Anything, mock.Anything,
79+
)
80+
})
81+
}
82+
}

staticaddr/deposit/manager_reconcile_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,76 @@ func TestReconcileDepositsReactivatesReappearedReplacedDeposit(t *testing.T) {
344344
require.Zero(t, deposit.ConfirmationHeight)
345345
require.Len(t, manager.activeDeposits, 1)
346346
}
347+
348+
// TestReconcileReplacementDeposit ensures that an unconfirmed deposit keeps its
349+
// identity when its original funding transaction is replaced.
350+
func TestReconcileReplacementDeposit(t *testing.T) {
351+
ctx := context.Background()
352+
oldOutpoint := wire.OutPoint{
353+
Hash: chainhash.Hash{4},
354+
Index: 8,
355+
}
356+
newOutpoint := wire.OutPoint{
357+
Hash: chainhash.Hash{5},
358+
Index: 9,
359+
}
360+
361+
depositID, err := GetRandomDepositID()
362+
require.NoError(t, err)
363+
364+
deposit := &Deposit{
365+
ID: depositID,
366+
OutPoint: oldOutpoint,
367+
Value: btcutil.Amount(100_000),
368+
}
369+
deposit.SetState(LoopingIn)
370+
371+
utxo := &lnwallet.Utxo{
372+
OutPoint: newOutpoint,
373+
Value: deposit.Value,
374+
Confirmations: 0,
375+
}
376+
377+
mockAddressManager := new(mockAddressManager)
378+
mockAddressManager.On(
379+
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
380+
).Return([]*lnwallet.Utxo{utxo}, nil)
381+
382+
mockStore := new(mockStore)
383+
mockStore.On(
384+
"UpdateDeposit", mock.Anything, mock.Anything,
385+
).Return(nil).Run(func(args mock.Arguments) {
386+
updatedDeposit := args.Get(1).(*Deposit)
387+
require.Equal(t, depositID, updatedDeposit.ID)
388+
require.Equal(t, newOutpoint, updatedDeposit.OutPoint)
389+
require.True(t, updatedDeposit.IsInStateNoLock(LoopingIn))
390+
})
391+
392+
manager := NewManager(&ManagerConfig{
393+
AddressManager: mockAddressManager,
394+
Store: mockStore,
395+
})
396+
manager.deposits[oldOutpoint] = deposit
397+
fsm := &FSM{}
398+
manager.activeDeposits[oldOutpoint] = fsm
399+
manager.missingDeposits[oldOutpoint] = 1
400+
401+
require.NoError(t, manager.reconcileDeposits(ctx))
402+
403+
_, ok := manager.deposits[oldOutpoint]
404+
require.False(t, ok)
405+
406+
rebinding, ok := manager.deposits[newOutpoint]
407+
require.True(t, ok)
408+
require.Same(t, deposit, rebinding)
409+
require.Equal(t, newOutpoint, rebinding.OutPoint)
410+
require.Equal(t, LoopingIn, rebinding.GetState())
411+
require.Zero(t, rebinding.ConfirmationHeight)
412+
413+
_, ok = manager.activeDeposits[oldOutpoint]
414+
require.False(t, ok)
415+
require.Same(t, fsm, manager.activeDeposits[newOutpoint])
416+
417+
_, ok = manager.missingDeposits[oldOutpoint]
418+
require.False(t, ok)
419+
}

0 commit comments

Comments
 (0)