Skip to content

Commit eafef68

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 038c621 commit eafef68

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"
@@ -303,6 +304,12 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
303304
err)
304305
}
305306

307+
err = m.reconcileReplacementDeposits(ctx, utxos)
308+
if err != nil {
309+
return fmt.Errorf("unable to reconcile replacement "+
310+
"deposits: %w", err)
311+
}
312+
306313
// After handling reappearances, only still-missing outpoints contribute
307314
// towards replacement detection.
308315
err = m.invalidateVanishedDeposits(ctx, utxos)
@@ -391,6 +398,139 @@ func (m *Manager) listUnspentWithBestHeight(ctx context.Context) (
391398
return nil, 0, errors.New("unable to get stable best block while " +
392399
"listing deposits")
393400
}
401+
402+
// reconcileReplacementDeposits rebinds uniquely matchable unconfirmed
403+
// deposits to a replacement outpoint when the original outpoint vanished from
404+
// the wallet's unspent set. This preserves the logical deposit identity across
405+
// RBF replacements and keeps any attached loop-in state tied to the same
406+
// deposit record.
407+
func (m *Manager) reconcileReplacementDeposits(ctx context.Context,
408+
utxos []*lnwallet.Utxo) error {
409+
410+
knownOutpoints := make(map[wire.OutPoint]struct{}, len(utxos))
411+
unknownByValue := make(map[btcutil.Amount][]*lnwallet.Utxo)
412+
413+
m.mu.Lock()
414+
for _, utxo := range utxos {
415+
knownOutpoints[utxo.OutPoint] = struct{}{}
416+
417+
if _, ok := m.deposits[utxo.OutPoint]; ok {
418+
delete(m.missingDeposits, utxo.OutPoint)
419+
continue
420+
}
421+
422+
unknownByValue[utxo.Value] = append(
423+
unknownByValue[utxo.Value], utxo,
424+
)
425+
}
426+
427+
deposits := make([]*Deposit, 0, len(m.deposits))
428+
for _, deposit := range m.deposits {
429+
deposits = append(deposits, deposit)
430+
}
431+
m.mu.Unlock()
432+
433+
missingByValue := make(map[btcutil.Amount][]*Deposit)
434+
for _, deposit := range deposits {
435+
if _, ok := knownOutpoints[deposit.OutPoint]; ok {
436+
continue
437+
}
438+
439+
deposit.Lock()
440+
state := deposit.state
441+
unconfirmed := deposit.ConfirmationHeight == 0
442+
value := deposit.Value
443+
deposit.Unlock()
444+
445+
if !unconfirmed {
446+
continue
447+
}
448+
449+
switch state {
450+
case Deposited, LoopingIn:
451+
default:
452+
continue
453+
}
454+
455+
missingByValue[value] = append(missingByValue[value], deposit)
456+
}
457+
458+
for value, missingDeposits := range missingByValue {
459+
replacementUtxos := unknownByValue[value]
460+
if len(missingDeposits) != 1 || len(replacementUtxos) != 1 {
461+
continue
462+
}
463+
464+
err := m.rebindReplacementDeposit(
465+
ctx, missingDeposits[0], replacementUtxos[0],
466+
)
467+
if err != nil {
468+
return err
469+
}
470+
}
471+
472+
return nil
473+
}
474+
475+
// rebindReplacementDeposit moves a deposit's identity from a disappeared
476+
// outpoint to a replacement wallet UTXO while preserving its state and ID.
477+
func (m *Manager) rebindReplacementDeposit(ctx context.Context, deposit *Deposit,
478+
utxo *lnwallet.Utxo) error {
479+
480+
m.mu.Lock()
481+
defer m.mu.Unlock()
482+
483+
deposit.Lock()
484+
defer deposit.Unlock()
485+
if deposit.ConfirmationHeight != 0 {
486+
return nil
487+
}
488+
489+
switch deposit.state {
490+
case Deposited, LoopingIn:
491+
default:
492+
return nil
493+
}
494+
confirmationHeight, err := confirmationHeightForUtxo(
495+
int32(m.currentHeight.Load()), utxo,
496+
)
497+
if err != nil {
498+
return err
499+
}
500+
501+
oldOutpoint := deposit.OutPoint
502+
previousValue := deposit.Value
503+
previousConfirmationHeight := deposit.ConfirmationHeight
504+
505+
deposit.OutPoint = utxo.OutPoint
506+
deposit.Value = utxo.Value
507+
deposit.ConfirmationHeight = confirmationHeight
508+
509+
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
510+
if err != nil {
511+
deposit.OutPoint = oldOutpoint
512+
deposit.Value = previousValue
513+
deposit.ConfirmationHeight = previousConfirmationHeight
514+
return err
515+
}
516+
517+
delete(m.deposits, oldOutpoint)
518+
delete(m.missingDeposits, oldOutpoint)
519+
delete(m.missingDeposits, deposit.OutPoint)
520+
m.deposits[deposit.OutPoint] = deposit
521+
522+
fsm, ok := m.activeDeposits[oldOutpoint]
523+
if ok {
524+
delete(m.activeDeposits, oldOutpoint)
525+
m.activeDeposits[deposit.OutPoint] = fsm
526+
}
527+
528+
log.Infof("Rebound deposit %x from %v to replacement %v",
529+
deposit.ID, oldOutpoint, deposit.OutPoint)
530+
531+
return nil
532+
}
533+
394534
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
395535
// it in our database and manager memory.
396536
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)