Skip to content

Commit 4ff06e2

Browse files
committed
staticaddr: allow rbf'ing withdrawal transactions
1 parent f4f6526 commit 4ff06e2

File tree

2 files changed

+179
-49
lines changed

2 files changed

+179
-49
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: 174 additions & 48 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,39 @@ 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+
// Start a goroutine that listens for the spent of the first deposit
394+
// input of the withdrawal tx. Each fee bump creates a new spent
395+
// notifier, so we can be sure to catch the confirmation of previous
396+
// withdrawal tx versions.
397+
err = m.handleWithdrawal(ctx, deposits, finalizedTx.TxHash())
398+
if err != nil {
399+
return "", "", err
400+
}
401+
402+
// If a previous withdrawal existed across the selected deposits, and
403+
// it isn't the same as the new withdrawal, we remove it from the
404+
// finalized withdrawals to stop republishing it, but we keep the
405+
// previous goroutine in handleWithdrawal running to monitor the
406+
// potential confirmation of the previous withdrawal.
407+
deposits[0].Lock()
408+
prevTx := deposits[0].FinalizedWithdrawalTx
409+
deposits[0].Unlock()
410+
411+
if prevTx != nil && prevTx.TxHash() != finalizedTx.TxHash() {
412+
m.mu.Lock()
413+
delete(m.finalizedWithdrawalTxns, prevTx.TxHash())
414+
m.mu.Unlock()
415+
}
416+
318417
// Attach the finalized withdrawal tx to the deposits. After a client
319418
// restart we can use this address as an indicator to republish the
320419
// withdrawal tx and continue the withdrawal.
@@ -325,37 +424,34 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
325424
d.Unlock()
326425
}
327426

427+
// Add the new withdrawal tx to the finalized withdrawals to republish
428+
// it on block arrivals.
429+
m.mu.Lock()
430+
m.finalizedWithdrawalTxns[finalizedTx.TxHash()] = finalizedTx
431+
m.mu.Unlock()
432+
328433
// Transition the deposits to the withdrawing state. This updates each
329434
// deposits withdrawal address. If a transition fails, we'll return an
330435
// error and abort the withdrawal. An error in transition is likely due
331436
// to an error in the state machine. The already transitioned deposits
332437
// should be reset to the Deposit state after a restart.
438+
// If the user fee bumped a withdrawal this results in a NOOP
439+
// transition.
333440
err = m.cfg.DepositManager.TransitionDeposits(
334441
ctx, deposits, deposit.OnWithdrawInitiated, deposit.Withdrawing,
335442
)
336443
if err != nil {
337444
return "", "", err
338445
}
339446

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
447+
// Update the deposits in the database.
448+
for _, d := range deposits {
449+
err = m.cfg.DepositManager.UpdateDeposit(ctx, d)
450+
if err != nil {
451+
return "", "", err
452+
}
355453
}
356454

357-
m.finalizedWithdrawalTxns[finalizedTx.TxHash()] = finalizedTx
358-
359455
return finalizedTx.TxID(), withdrawalAddress.String(), nil
360456
}
361457

@@ -452,44 +548,66 @@ func (m *Manager) createFinalizedWithdrawalTx(ctx context.Context,
452548
}
453549

454550
func (m *Manager) publishFinalizedWithdrawalTx(ctx context.Context,
455-
tx *wire.MsgTx) error {
551+
tx *wire.MsgTx) (bool, error) {
456552

457553
if tx == nil {
458-
return errors.New("can't publish, finalized withdrawal tx is " +
459-
"nil")
554+
return false, errors.New("can't publish, finalized " +
555+
"withdrawal tx is nil")
460556
}
461557

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

464560
// Publish the withdrawal sweep transaction.
465561
err := m.cfg.WalletKit.PublishTransaction(ctx, tx, txLabel)
466-
467562
if err != nil {
468-
if !strings.Contains(err.Error(), "output already spent") {
469-
log.Errorf("%v: %v", txLabel, err)
563+
if !strings.Contains(err.Error(), "output already spent") &&
564+
!strings.Contains(err.Error(), "insufficient fee") {
565+
566+
return false, err
567+
} else {
568+
return false, nil
470569
}
570+
} else {
571+
log.Debugf("published deposit withdrawal with txid: %v",
572+
tx.TxHash())
471573
}
472574

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

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

482-
confChan, errChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn(
483-
ctx, &txHash, withdrawalPkScript, MinConfs,
484-
int32(m.initiationHeight.Load()),
590+
address, err := btcutil.NewAddressTaproot(
591+
schnorr.SerializePubKey(staticAddress.TaprootKey),
592+
m.cfg.ChainParams,
485593
)
486594
if err != nil {
487595
return err
488596
}
489597

598+
script, err := txscript.PayToAddrScript(address)
599+
if err != nil {
600+
return err
601+
}
602+
603+
d := deposits[0]
604+
spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn(
605+
ctx, &d.OutPoint, script, int32(d.ConfirmationHeight),
606+
)
607+
490608
go func() {
491609
select {
492-
case <-confChan:
610+
case <-spentChan:
493611
err = m.cfg.DepositManager.TransitionDeposits(
494612
ctx, deposits, deposit.OnWithdrawn,
495613
deposit.Withdrawn,
@@ -499,10 +617,11 @@ func (m *Manager) handleWithdrawal(ctx context.Context,
499617
err)
500618
}
501619

502-
// Remove the withdrawal from the active withdrawals and
503-
// remove its finalized to stop republishing it on block
504-
// arrivals.
620+
// Remove the withdrawal tx from the active withdrawals
621+
// to stop republishing it on block arrivals.
622+
m.mu.Lock()
505623
delete(m.finalizedWithdrawalTxns, txHash)
624+
m.mu.Unlock()
506625

507626
case err := <-errChan:
508627
log.Errorf("Error waiting for confirmation: %v", err)
@@ -910,13 +1029,20 @@ func (m *Manager) toPrevOuts(deposits []*deposit.Deposit,
9101029
}
9111030

9121031
func (m *Manager) republishWithdrawals(ctx context.Context) error {
913-
for _, finalizedTx := range m.finalizedWithdrawalTxns {
1032+
m.mu.Lock()
1033+
txns := make([]*wire.MsgTx, 0, len(m.finalizedWithdrawalTxns))
1034+
for _, tx := range m.finalizedWithdrawalTxns {
1035+
txns = append(txns, tx)
1036+
}
1037+
m.mu.Unlock()
1038+
1039+
for _, finalizedTx := range txns {
9141040
if finalizedTx == nil {
9151041
log.Warnf("Finalized withdrawal tx is nil")
9161042
continue
9171043
}
9181044

919-
err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
1045+
_, err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
9201046
if err != nil {
9211047
log.Errorf("Error republishing withdrawal: %v", err)
9221048

0 commit comments

Comments
 (0)