diff --git a/txconfirm/actor.go b/txconfirm/actor.go index df3424bcf..2c7e9d8e7 100644 --- a/txconfirm/actor.go +++ b/txconfirm/actor.go @@ -982,12 +982,8 @@ func (a *TxBroadcasterActor) handleBumpNow(ctx context.Context, "txid", entry.data.Txid) _ = a.advanceTrackedTxFSM( - ctx, entry, &trackedTxBroadcastAccepted{ - Progress: trackedTxProgress{ - LastBroadcastHeight: fn.Some( - a.bestHeight, - ), - }, + ctx, entry, &trackedTxFeeBumpFailed{ + AttemptHeight: a.bestHeight, }, ) @@ -1337,14 +1333,9 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, "txid", entry.data.Txid) } - progress := trackedTxProgress{ - LastBroadcastHeight: fn.Some( - a.bestHeight, - ), - } _ = a.advanceTrackedTxFSM( - ctx, entry, &trackedTxBroadcastAccepted{ - Progress: progress, + ctx, entry, &trackedTxFeeBumpFailed{ + AttemptHeight: a.bestHeight, }, ) } diff --git a/txconfirm/actor_test.go b/txconfirm/actor_test.go index 78e7f27ab..647d783af 100644 --- a/txconfirm/actor_test.go +++ b/txconfirm/actor_test.go @@ -1510,6 +1510,60 @@ func TestFeeBumpOnNewBlocks(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmed) } +// TestFailedFeeBumpPreservesProgress verifies that a rejected fee-bump +// attempt updates retry pacing without counting the failed attempt as a +// successful bump or discarding the last successful broadcast metadata. +func TestFailedFeeBumpPreservesProgress(t *testing.T) { + chain := newFakeChainSourceRef(100) + walletRef := &fakeWallet{ + utxos: []*walletcore.Utxo{ + makeWalletUTXO(t), + }, + } + ref, behavior := newTestActor(t, Config{ + ChainSource: chain, + Wallet: walletRef, + FeeBumpIntervalBlocks: 2, + }) + + tx := makeTestTx(true) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.Equal(t, TxStateAwaitingConfirmation, resp.State) + require.Equal(t, 1, chain.packageCallCount()) + + entry := behavior.tracked[tx.TxHash()] + initialState, err := entry.currentFSMState() + require.NoError(t, err) + initial, ok := initialState.(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + + chain.packageErr = fmt.Errorf("package rejected") + chain.emitBlock(t, 102) + require.Equal( + t, TxStateAwaitingConfirmation, + mustTrackedState( + t, ref.Ref(), tx, sub, + ), + ) + + failedState, err := entry.currentFSMState() + require.NoError(t, err) + afterFailure, ok := failedState.(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + require.Equal(t, initial.BumpCount, afterFailure.BumpCount) + require.Equal(t, initial.CurrentFeeRate, afterFailure.CurrentFeeRate) + require.Equal(t, initial.ChildTxid, afterFailure.ChildTxid) + require.Equal( + t, fn.Some[int32](102), afterFailure.LastBroadcastHeight, + ) + require.Equal(t, 2, chain.packageCallCount()) + mustHaveNoNotification(t, sub) +} + // TestEnsureConfirmedWaitsForInitialCPFPInput verifies that an anchor parent // whose first broadcast attempt reaches no mempool (no confirmed fee input) // stays in the Broadcasting state — not AwaitingConfirmation — and is diff --git a/txconfirm/fsm_test.go b/txconfirm/fsm_test.go index 39bdb02a3..a509eecbe 100644 --- a/txconfirm/fsm_test.go +++ b/txconfirm/fsm_test.go @@ -148,6 +148,7 @@ func TestTrackedTxFSMFeeBumpFlow(t *testing.T) { Progress: trackedTxProgress{ LastBroadcastHeight: fn.Some[int32](103), CurrentFeeRate: 11, + ChildTxid: copyHash(&data.Txid), }, }, ).Await(t.Context()).Unpack() @@ -160,6 +161,28 @@ func TestTrackedTxFSMFeeBumpFlow(t *testing.T) { require.Equal(t, fn.Some[int32](103), awaiting.LastBroadcastHeight) require.Equal(t, int64(11), awaiting.CurrentFeeRate) require.Equal(t, 1, awaiting.BumpCount) + require.Equal(t, &data.Txid, awaiting.ChildTxid) + + _, err = fsm.AskEvent( + t.Context(), &trackedTxFeeBumpStarted{}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + _, err = fsm.AskEvent( + t.Context(), &trackedTxFeeBumpFailed{ + AttemptHeight: 106, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + awaiting, ok = mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + require.Equal(t, fn.Some[int32](106), awaiting.LastBroadcastHeight) + require.Equal(t, int64(11), awaiting.CurrentFeeRate) + require.Equal(t, 1, awaiting.BumpCount) + require.Equal(t, &data.Txid, awaiting.ChildTxid) } // TestTrackedTxFSMFailureAndInvalidTransitions verifies terminal failure diff --git a/txconfirm/fsm_types.go b/txconfirm/fsm_types.go index 5b07aea27..fcb729f71 100644 --- a/txconfirm/fsm_types.go +++ b/txconfirm/fsm_types.go @@ -150,6 +150,18 @@ type trackedTxFeeBumpStarted struct{} // trackedTxEventSealed marks trackedTxFeeBumpStarted as a tracked-tx event. func (e *trackedTxFeeBumpStarted) trackedTxEventSealed() {} +// trackedTxFeeBumpFailed records that a fee-bump rebroadcast failed while the +// original transaction remains live and awaiting confirmation. +type trackedTxFeeBumpFailed struct { + // AttemptHeight is the chain height of the failed fee-bump attempt. It + // advances retry pacing without replacing the last successful broadcast + // metadata or incrementing the successful bump count. + AttemptHeight int32 +} + +// trackedTxEventSealed marks trackedTxFeeBumpFailed as a tracked-tx event. +func (e *trackedTxFeeBumpFailed) trackedTxEventSealed() {} + // trackedTxConfirmed records terminal confirmation of the tracked txid. type trackedTxConfirmed struct { // BlockHeight is the block height where the tx confirmed. diff --git a/txconfirm/funded_anchor_test.go b/txconfirm/funded_anchor_test.go index 8a6f2c2c7..6e70f1eb3 100644 --- a/txconfirm/funded_anchor_test.go +++ b/txconfirm/funded_anchor_test.go @@ -14,6 +14,7 @@ import ( "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/walletcore" + fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" ) @@ -446,6 +447,58 @@ func TestBumpNowFundedParent(t *testing.T) { require.Zero(t, behavior.tracked[txid].pendingTargetFeeRate) } +// TestBumpNowFailurePreservesProgress verifies that a rejected forced bump +// returns to AwaitingConfirmation without counting the attempt or replacing +// metadata from the last successful broadcast. +func TestBumpNowFailurePreservesProgress(t *testing.T) { + t.Parallel() + + chain := newFakeChainSourceRef(100) + wallet := &fakeWallet{utxos: []*walletcore.Utxo{makeWalletUTXO(t)}} + behavior := newBumpTestBehavior(t, chain, wallet) + + sub := actor.NewChannelTellOnlyRef[Notification]("failed-bump-sub", 8) + tx := makeFundedAnchorTx(17, 330) + txid := tx.TxHash() + + ensureResp := mustReceiveEnsure(t, behavior, &EnsureConfirmedReq{ + Tx: tx, + Label: "failed-bump", + ParentFee: 500, + Subscriber: sub, + }) + require.Equal(t, TxStateAwaitingConfirmation, ensureResp.State) + + entry := behavior.tracked[txid] + initialState, err := entry.currentFSMState() + require.NoError(t, err) + initial, ok := initialState.(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + + behavior.bestHeight = 102 + chain.packageErr = fmt.Errorf("package rejected") + bumpResp := mustReceiveBump(t, behavior, &BumpNowReq{ + Txid: txid, + TargetFeeRateSatPerVByte: 25, + }) + require.False(t, bumpResp.Bumped) + require.Equal(t, TxStateAwaitingConfirmation, bumpResp.State) + require.Contains(t, bumpResp.Reason, "package rejected") + + failedState, err := entry.currentFSMState() + require.NoError(t, err) + afterFailure, ok := failedState.(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + require.Equal(t, initial.BumpCount, afterFailure.BumpCount) + require.Equal(t, initial.CurrentFeeRate, afterFailure.CurrentFeeRate) + require.Equal(t, initial.ChildTxid, afterFailure.ChildTxid) + require.Equal( + t, fn.Some[int32](102), afterFailure.LastBroadcastHeight, + ) + require.Equal(t, 1, chain.packageCallCount()) + require.Zero(t, entry.pendingTargetFeeRate) +} + // TestBumpNowClampedTarget asserts an over-ceiling operator target is clamped // to the broadcaster maximum and the clamp is surfaced in the response rather // than silently reported as a full success. diff --git a/txconfirm/states.go b/txconfirm/states.go index 34dee8479..b8434e3eb 100644 --- a/txconfirm/states.go +++ b/txconfirm/states.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/btcsuite/btcd/chainhash/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" ) // trackedTxStateNew is the initial tracked-tx FSM state. @@ -230,6 +231,17 @@ func (s *trackedTxStateFeeBumping) ProcessEvent(_ context.Context, }, }, nil + case *trackedTxFeeBumpFailed: + progress := s.trackedTxProgress + progress.LastBroadcastHeight = fn.Some(e.AttemptHeight) + + return &trackedTxStateTransition{ + NextState: &trackedTxStateAwaitingConfirmation{ + trackedTxData: s.trackedTxData, + trackedTxProgress: progress, + }, + }, nil + case *trackedTxConfirmed: return &trackedTxStateTransition{ NextState: &trackedTxStateConfirmed{