Skip to content

fix(payout): bring the EVM expired-tx retry under the designate-before-broadcast protection#4237

Merged
TaprootFreak merged 1 commit into
developfrom
fix/payout-evm-expired-retry
Jul 17, 2026
Merged

fix(payout): bring the EVM expired-tx retry under the designate-before-broadcast protection#4237
TaprootFreak merged 1 commit into
developfrom
fix/payout-evm-expired-retry

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What

Brings the EVM expired-tx retry under the same designate-before-broadcast protection as the first broadcast.

Closes #4229 (follow-up to #4181).

Why

#4181 deliberately exempted re-entries with payoutTxId set, assuming they replace the pending tx by reusing its nonce. That holds for the manual speedup, but not for the expired-tx retry: isTxExpired is only true when getTransaction(hash) returns null, so getTxNonce on the vanished hash resolves undefined and dispatch falls back to a fresh nonce. The retry was therefore an independent second transaction with the pre-#4181 semantics:

  • a crash between the retry broadcast and the subsequent save left the order PayoutPending with the old expired hash → the next cron run retried again → two live transactions;
  • an ambiguous broadcast failure persisted nothing (handleBroadcastError is a deliberate no-op when payoutTxId is set), so the order silently re-broadcast every 30 s — and once one attempt confirmed between two runs, the next attempt double-paid.

How

The expired branch in EvmStrategy.checkPayoutCompletionData now mirrors the out-of-gas branch directly above it: rollbackPayout() (clear payoutTxId, status back to PreparationConfirmed) is persisted before re-entering doPayout. The retry then goes through the regular protected flow: designation is persisted before dispatch, a crash re-selects the order safely, and an ambiguous failure stays PayoutDesignated and escalates to PayoutUncertain via processFailedOrders instead of looping.

Comment-only cleanup in payout.strategy.ts: after this change the manual speedup is the only re-entry that keeps payoutTxId, and the comments on designateBeforeBroadcast/handleBroadcastError say so.

The rollback persist is conditional (update({ id, status: PayoutPending, payoutTxId: <the tx this run saw> }, { status: PreparationConfirmed, payoutTxId: null })): the payout cron lock times out after 1800 s, so runs can overlap — a stale run must not roll back an order a concurrent run has already re-claimed and re-broadcast (that would null the fresh payoutTxId and re-open the order for a second broadcast). Losing the claim skips the retry. Since both retry branches now end in the identical release, the rollback is hoisted behind the OOG/expired distinction (only the log lines differ; both warn, per the logging convention for retries).

Released hashes stay reconstructable from the DB: a new append-only column releasedPayoutTxIds (migration included) records every hash the release replaces, written in the same conditional UPDATE. When a retry's dispatch later fails ambiguously and the order parks in PayoutUncertain with payoutTxId = null, the investigation still has the full list of previously broadcast hashes — the primary evidence for "did a vanished tx confirm after all?" (a mempool-vanished tx can be re-gossiped and mine later). The column is exposed via the debug allowlist.

The 1 h cooldown in canRetryFailedPayout and the speedup path are unchanged.

Tests

  • expired + retryable: the rollback is persisted before the re-dispatch (call-order assertion), the retry re-designates, dispatch draws a fresh nonce
  • expired retry + broadcast-boundary error: the order stays PayoutDesignated (fail-closed, no rollback save) instead of silently looping
  • manual speedup re-entry: still skips designation and never rolls back on plain errors (nonce reuse preserved)
  • lost conditional release (affected: 0) → no re-entry, no dispatch, no save (concurrent-run skip)
  • OOG release asserts the exact conditional criteria (pinned status + payoutTxId)
  • released-hash history: first release records the hash, a second release appends (;-separated), asserted on the exact update payload
  • obsolete expired-nonce-reuse test variants replaced

@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Review protocol: 4 full review passes (conformity + logic lens each) until zero findings. Pass 1: log-level/DRY cleanups plus making the retry release a conditional transition pinned to the exact tx (4bcc12f). Pass 2: the released hash must stay reconstructable from the DB — added the append-only releasedPayoutTxIds column with migration (4ce2015). Pass 3: aligned the in-memory history with the persisted slice and documented the stale-full-save limit (f4f51df). Pass 4: clean on both lenses.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 16, 2026 20:06
…e-broadcast protection

The expired-retry path re-entered doPayout with payoutTxId still set, assuming
nonce reuse would replace the pending tx. For an expired tx the hash has
vanished, getTxNonce resolves undefined and dispatch draws a fresh nonce - the
retry was an independent second transaction outside the designate-before-
broadcast protection. It now goes through the shared rollbackBroadcastForRetry
release like the out-of-gas path, so a crash window stays closed and an
ambiguous failure escalates instead of silently looping.

The release also records every replaced tx hash in a new append-only
releasedPayoutTxIds column (migration included), so a PayoutUncertain
investigation can still reconstruct the vanished hash from the DB after the
retry nulls payoutTxId. The column is exposed via the debug allowlist.

Closes #4229
@TaprootFreak
TaprootFreak force-pushed the fix/payout-evm-expired-retry branch from f4f51df to 39fa68e Compare July 17, 2026 15:58
@TaprootFreak
TaprootFreak merged commit f246c08 into develop Jul 17, 2026
8 checks passed
@TaprootFreak
TaprootFreak deleted the fix/payout-evm-expired-retry branch July 17, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(payout): EVM expired-tx retry re-broadcasts outside the designate-before-broadcast protection (#4181 follow-up)

1 participant