Skip to content

Commit c3b9f8c

Browse files
committed
staticaddr: allow rbf'ing withdrawal transactions
1 parent 84820f2 commit c3b9f8c

File tree

2 files changed

+181
-54
lines changed

2 files changed

+181
-54
lines changed

staticaddr/deposit/fsm.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ func (f *FSM) DepositStatesV0() fsm.States {
297297
Withdrawing: fsm.State{
298298
Transitions: fsm.Transitions{
299299
OnWithdrawn: Withdrawn,
300+
// OnWithdrawInitiated is sent if a fee bump was
301+
// requested and the withdrawal was republished.
302+
OnWithdrawInitiated: Withdrawing,
300303
// Upon recovery, we go back to the Deposited
301304
// state. The deposit by then has a withdrawal
302305
// address stamped to it which will cause it to
@@ -358,7 +361,8 @@ func (f *FSM) DepositStatesV0() fsm.States {
358361
},
359362
Withdrawn: fsm.State{
360363
Transitions: fsm.Transitions{
361-
OnExpiry: Expired,
364+
OnExpiry: Expired,
365+
OnWithdrawn: Withdrawn,
362366
},
363367
Action: f.FinalizeDepositAction,
364368
},

staticaddr/withdraw/manager.go

Lines changed: 176 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"reflect"
88
"strings"
9+
"sync"
910
"sync/atomic"
1011

1112
"github.com/btcsuite/btcd/btcec/v2/schnorr"
@@ -26,10 +27,21 @@ import (
2627
)
2728

2829
var (
29-
// ErrWithdrawingInactiveDeposits is returned when the user tries to
30-
// withdraw inactive deposits.
31-
ErrWithdrawingInactiveDeposits = errors.New("deposits to be " +
32-
"withdrawn are unknown or inactive")
30+
// ErrWithdrawingMixedDeposits is returned when a withdrawal is
31+
// requested for deposits in different states.
32+
ErrWithdrawingMixedDeposits = errors.New("need to withdraw deposits " +
33+
"having the same state, either all deposited or all " +
34+
"withdrawing")
35+
36+
// ErrDiffPreviousWithdrawalTx signals that the user selected new
37+
// deposits that have different previous withdrawal transactions.
38+
ErrDiffPreviousWithdrawalTx = errors.New("can't bump fee for " +
39+
"deposits with different previous withdrawal tx hash")
40+
41+
// ErrMissingPreviousWithdrawn is returned when the user tries to bump
42+
// the fee for a subset of previously selected deposits to withdraw.
43+
ErrMissingPreviousWithdrawn = errors.New("can't bump fee for subset " +
44+
"of clustered deposits")
3345

3446
// MinConfs is the minimum number of confirmations we require for a
3547
// deposit to be considered withdrawn.
@@ -92,6 +104,9 @@ type newWithdrawalResponse struct {
92104
type Manager struct {
93105
cfg *ManagerConfig
94106

107+
// mu protects access to finalizedWithdrawalTxns.
108+
mu sync.Mutex
109+
95110
// initChan signals the daemon that the withdrawal manager has completed
96111
// its initialization.
97112
initChan chan struct{}
@@ -237,19 +252,19 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error {
237252
return err
238253
}
239254

240-
err = m.publishFinalizedWithdrawalTx(ctx, tx)
255+
_, err = m.publishFinalizedWithdrawalTx(ctx, tx)
241256
if err != nil {
242257
return err
243258
}
244259

245-
err = m.handleWithdrawal(
246-
ctx, deposits, tx.TxHash(), tx.TxOut[0].PkScript,
247-
)
260+
err = m.handleWithdrawal(ctx, deposits, tx.TxHash())
248261
if err != nil {
249262
return err
250263
}
251264

265+
m.mu.Lock()
252266
m.finalizedWithdrawalTxns[tx.TxHash()] = tx
267+
m.mu.Unlock()
253268
}
254269

255270
return nil
@@ -274,14 +289,65 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
274289
"withdraw, unconfirmed deposits can't be withdrawn")
275290
}
276291

292+
var (
293+
deposits []*deposit.Deposit
294+
allDeposited bool
295+
allWithdrawing bool
296+
)
297+
277298
// Ensure that the deposits are in a state in which they can be
278299
// withdrawn.
279-
deposits, allActive := m.cfg.DepositManager.AllOutpointsActiveDeposits(
300+
deposits, allDeposited = m.cfg.DepositManager.AllOutpointsActiveDeposits(
280301
outpoints, deposit.Deposited,
281302
)
282303

283-
if !allActive {
284-
return "", "", ErrWithdrawingInactiveDeposits
304+
// If not all passed outpoints are in state Deposited, we'll check if
305+
// they are all in state Withdrawing. If they are, then the user is
306+
// requesting a fee bump, if not we'll return an error as we only allow
307+
// fee bumping deposits in state Withdrawing.
308+
if !allDeposited {
309+
deposits, allWithdrawing = m.cfg.DepositManager.AllOutpointsActiveDeposits(
310+
outpoints, deposit.Withdrawing,
311+
)
312+
313+
if !allWithdrawing {
314+
return "", "", ErrWithdrawingMixedDeposits
315+
}
316+
317+
// If republishing of an existing withdrawal is requested we
318+
// ensure that all deposits remain clustered in the context of
319+
// the same withdrawal tx. We do this by checking that they have
320+
// the same previous withdrawal tx hash. This ensures that the
321+
// shape of the transaction stays the same.
322+
hash := deposits[0].FinalizedWithdrawalTx.TxHash()
323+
for i := 1; i < len(deposits); i++ {
324+
if deposits[i].FinalizedWithdrawalTx.TxHash() != hash {
325+
return "", "", ErrDiffPreviousWithdrawalTx
326+
}
327+
}
328+
329+
// We also avoid that the user selects a subset of previously
330+
// clustered deposits for a fee bump. This would result in a
331+
// different transaction shape.
332+
allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState(
333+
deposit.Withdrawing,
334+
)
335+
if err != nil {
336+
return "", "", err
337+
}
338+
339+
allDepositsWithHash := make(map[chainhash.Hash][]*deposit.Deposit)
340+
for _, d := range allDeposits {
341+
if d.FinalizedWithdrawalTx.TxHash() == hash {
342+
allDepositsWithHash[hash] = append(
343+
allDepositsWithHash[hash], d,
344+
)
345+
}
346+
}
347+
348+
if len(allDepositsWithHash[hash]) != len(deposits) {
349+
return "", "", ErrMissingPreviousWithdrawn
350+
}
285351
}
286352

287353
var (
@@ -315,6 +381,41 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
315381
return "", "", err
316382
}
317383

384+
published, err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
385+
if err != nil {
386+
return "", "", err
387+
}
388+
389+
if !published {
390+
return "", "", nil
391+
}
392+
393+
// If this is the first time this cluster of deposits is withdrawn, we
394+
// start a goroutine that listens for the spent of the first input of
395+
// the withdrawal transaction.
396+
// Since we ensure above that the same ensemble of deposits is
397+
// republished in case of a fee bump, it suffices if only one spent
398+
// notifier is run, since if the first input is spent.
399+
if allDeposited {
400+
err = m.handleWithdrawal(ctx, deposits, finalizedTx.TxHash())
401+
if err != nil {
402+
return "", "", err
403+
}
404+
}
405+
406+
// If a previous withdrawal existed across the selected deposits, and
407+
// it isn't the same as the new withdrawal, we remove it from the
408+
// finalized withdrawals to stop republishing it on block arrivals.
409+
deposits[0].Lock()
410+
prevTx := deposits[0].FinalizedWithdrawalTx
411+
deposits[0].Unlock()
412+
413+
if prevTx != nil && prevTx.TxHash() != finalizedTx.TxHash() {
414+
m.mu.Lock()
415+
delete(m.finalizedWithdrawalTxns, prevTx.TxHash())
416+
m.mu.Unlock()
417+
}
418+
318419
// Attach the finalized withdrawal tx to the deposits. After a client
319420
// restart we can use this address as an indicator to republish the
320421
// withdrawal tx and continue the withdrawal.
@@ -325,37 +426,29 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
325426
d.Unlock()
326427
}
327428

328-
// Transition the deposits to the withdrawing state. This updates each
329-
// deposits withdrawal address. If a transition fails, we'll return an
330-
// error and abort the withdrawal. An error in transition is likely due
331-
// to an error in the state machine. The already transitioned deposits
332-
// should be reset to the Deposit state after a restart.
429+
// Add the new withdrawal tx to the finalized withdrawals to republish
430+
// it on block arrivals.
431+
m.mu.Lock()
432+
m.finalizedWithdrawalTxns[finalizedTx.TxHash()] = finalizedTx
433+
m.mu.Unlock()
434+
435+
// Transition the deposits to the withdrawing state. If the user fee
436+
// bumped a withdrawal this results in a NOOP transition.
333437
err = m.cfg.DepositManager.TransitionDeposits(
334438
ctx, deposits, deposit.OnWithdrawInitiated, deposit.Withdrawing,
335439
)
336440
if err != nil {
337441
return "", "", err
338442
}
339443

340-
err = m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
341-
if err != nil {
342-
return "", "", err
343-
}
344-
345-
withdrawalPkScript, err := txscript.PayToAddrScript(withdrawalAddress)
346-
if err != nil {
347-
return "", "", err
348-
}
349-
350-
err = m.handleWithdrawal(
351-
ctx, deposits, finalizedTx.TxHash(), withdrawalPkScript,
352-
)
353-
if err != nil {
354-
return "", "", err
444+
// Update the deposits in the database.
445+
for _, d := range deposits {
446+
err = m.cfg.DepositManager.UpdateDeposit(ctx, d)
447+
if err != nil {
448+
return "", "", err
449+
}
355450
}
356451

357-
m.finalizedWithdrawalTxns[finalizedTx.TxHash()] = finalizedTx
358-
359452
return finalizedTx.TxID(), withdrawalAddress.String(), nil
360453
}
361454

@@ -452,44 +545,66 @@ func (m *Manager) createFinalizedWithdrawalTx(ctx context.Context,
452545
}
453546

454547
func (m *Manager) publishFinalizedWithdrawalTx(ctx context.Context,
455-
tx *wire.MsgTx) error {
548+
tx *wire.MsgTx) (bool, error) {
456549

457550
if tx == nil {
458-
return errors.New("can't publish, finalized withdrawal tx is " +
459-
"nil")
551+
return false, errors.New("can't publish, finalized " +
552+
"withdrawal tx is nil")
460553
}
461554

462555
txLabel := fmt.Sprintf("deposit-withdrawal-%v", tx.TxHash())
463556

464557
// Publish the withdrawal sweep transaction.
465558
err := m.cfg.WalletKit.PublishTransaction(ctx, tx, txLabel)
466-
467559
if err != nil {
468-
if !strings.Contains(err.Error(), "output already spent") {
469-
log.Errorf("%v: %v", txLabel, err)
560+
if !strings.Contains(err.Error(), "output already spent") &&
561+
!strings.Contains(err.Error(), "insufficient fee") {
562+
563+
return false, err
564+
} else {
565+
return false, nil
470566
}
567+
} else {
568+
log.Debugf("published deposit withdrawal with txid: %v",
569+
tx.TxHash())
471570
}
472571

473-
log.Debugf("published deposit withdrawal with txid: %v", tx.TxHash())
474-
475-
return nil
572+
return true, nil
476573
}
477574

575+
// handleWithdrawal starts a goroutine that listens for the spent of the first
576+
// input of the withdrawal transaction.
478577
func (m *Manager) handleWithdrawal(ctx context.Context,
479-
deposits []*deposit.Deposit, txHash chainhash.Hash,
480-
withdrawalPkScript []byte) error {
578+
deposits []*deposit.Deposit, txHash chainhash.Hash) error {
579+
580+
staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx)
581+
if err != nil {
582+
log.Errorf("error retrieving taproot address %w", err)
583+
584+
return fmt.Errorf("withdrawal failed")
585+
}
481586

482-
confChan, errChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn(
483-
ctx, &txHash, withdrawalPkScript, MinConfs,
484-
int32(m.initiationHeight.Load()),
587+
address, err := btcutil.NewAddressTaproot(
588+
schnorr.SerializePubKey(staticAddress.TaprootKey),
589+
m.cfg.ChainParams,
485590
)
486591
if err != nil {
487592
return err
488593
}
489594

595+
script, err := txscript.PayToAddrScript(address)
596+
if err != nil {
597+
return err
598+
}
599+
600+
d := deposits[0]
601+
spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn(
602+
ctx, &d.OutPoint, script, int32(d.ConfirmationHeight),
603+
)
604+
490605
go func() {
491606
select {
492-
case <-confChan:
607+
case <-spentChan:
493608
err = m.cfg.DepositManager.TransitionDeposits(
494609
ctx, deposits, deposit.OnWithdrawn,
495610
deposit.Withdrawn,
@@ -499,10 +614,11 @@ func (m *Manager) handleWithdrawal(ctx context.Context,
499614
err)
500615
}
501616

502-
// Remove the withdrawal from the active withdrawals and
503-
// remove its finalized to stop republishing it on block
504-
// arrivals.
617+
// Remove the withdrawal tx from the active withdrawals
618+
// to stop republishing it on block arrivals.
619+
m.mu.Lock()
505620
delete(m.finalizedWithdrawalTxns, txHash)
621+
m.mu.Unlock()
506622

507623
case err := <-errChan:
508624
log.Errorf("Error waiting for confirmation: %v", err)
@@ -910,13 +1026,20 @@ func (m *Manager) toPrevOuts(deposits []*deposit.Deposit,
9101026
}
9111027

9121028
func (m *Manager) republishWithdrawals(ctx context.Context) error {
913-
for _, finalizedTx := range m.finalizedWithdrawalTxns {
1029+
m.mu.Lock()
1030+
txns := make([]*wire.MsgTx, 0, len(m.finalizedWithdrawalTxns))
1031+
for _, tx := range m.finalizedWithdrawalTxns {
1032+
txns = append(txns, tx)
1033+
}
1034+
m.mu.Unlock()
1035+
1036+
for _, finalizedTx := range txns {
9141037
if finalizedTx == nil {
9151038
log.Warnf("Finalized withdrawal tx is nil")
9161039
continue
9171040
}
9181041

919-
err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
1042+
_, err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
9201043
if err != nil {
9211044
log.Errorf("Error republishing withdrawal: %v", err)
9221045

0 commit comments

Comments
 (0)