Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions txconfirm/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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,
},
)
}
Expand Down
54 changes: 54 additions & 0 deletions txconfirm/actor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions txconfirm/fsm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions txconfirm/fsm_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions txconfirm/funded_anchor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions txconfirm/states.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down