fix(payout): recover EVM payouts from out-of-gas reverts#3697
Merged
Conversation
Three issues combined could leave an EVM payout order stuck in PayoutPending indefinitely: 1. Gas estimation buffer (x1.2) was too small to absorb the EIP-2200 SSTORE swing (~15k gas) that occurs when the recipient's token balance slot changes between estimate-time and execution-time (cold zero-to-nonzero costs 22.1k vs warm nonzero-to-nonzero 7.1k). 2. isTxComplete threw on reverted receipts, leaving the order silently in PayoutPending while the cron logged errors every 30s. 3. canRetryFailedPayout only handled mempool-expired transactions, not mined-but-reverted ones. Changes: - getTokenGasLimitForContact uses a 50% buffer plus an 80k floor; estimation failures now fall back to 150k. sendToken passes the real transfer amount through so the estimate reflects execution. - getPayoutCompletionData returns a tri-state PayoutTxStatus (pending / complete / failed with isOutOfGas) instead of throwing on revert. - EvmStrategy.checkPayoutCompletionData treats reverted out-of-gas as recoverable (rollbackPayoutDesignation + clear payoutTxId so the next cron pass retries with a fresh nonce and re-estimated gas) and non-OOG reverts as needing investigation (designatePayout, picked up by processFailedOrders). - Redundant getPayoutCompletionData overrides removed from base/gnosis/optimism/polygon coin+token strategies.
Encapsulate the payout-state rollback in a dedicated entity method symmetric to pendingPayout(txId), instead of misusing rollbackPayoutDesignation() (intended for PayoutDesignated state) and mutating payoutTxId directly from the strategy.
Keep cross-file types collocated with PayoutRequest and FeeResult in the payout interfaces module, consistent with the existing codebase convention.
Revert the buffer (50%) and floor (80k) introduced earlier; the existing 20% buffer is sufficient in practice. Recovery from the rare OOG case is now handled exclusively by the checkPayoutCompletionData rollback/retry path.
davidleomay
approved these changes
May 12, 2026
…dPayout Address review feedback: route both recoverable cases (mined-OOG and mempool-expired) through canRetryFailedPayout, with the OOG-specific nonce rollback handled in the caller. Non-OOG reverts continue to short-circuit to designatePayout.
…er order Check status.isOutOfGas explicitly inside the retry branch so the intent is self-documenting and robust against future changes to canRetryFailedPayout that could let non-OOG failures through.
davidleomay
approved these changes
May 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the failure mode where an EVM payout TX runs out of gas on-chain and the order stays in
PayoutPendingindefinitely.Root cause
Two defects had to coincide:
isTxCompletethrew on reverted receipts. The outertry/catchinPayoutService.checkPayoutCompletionlogged the error every 30 s but left the order inPayoutPending.canRetryFailedPayoutonly handles mempool-expired TXs (isTxExpired); a mined receipt withstatus=0is not expired and thus not retryable.The underlying race itself — gas estimate vs execution diverging because of an EIP-2200 SSTORE swing on the recipient's balance slot — is rare in practice; the failure mode that needs fixing is the stuck order, not the rare gas estimate miss.
Fix
getPayoutCompletionData— returns a tri-statePayoutTxStatus(pending/complete/failedwithisOutOfGas) read directly from the receipt instead of relying on the throwingisTxComplete.EvmStrategy.checkPayoutCompletionData— handlesfailedexplicitly:isOutOfGas→rollbackPayout()→ next cron pass retries with a fresh nonce and a re-estimated gas limit.designatePayout()→processFailedOrdersalready mails and moves the order toPayoutUncertain.PayoutOrder.rollbackPayout()— new entity method symmetric topendingPayout(txId): clearspayoutTxIdand resets status toPreparationConfirmed. Unit test added.sendTokennow passes the real transfer amount through togetTokenGasLimitForContact(consistent withprepareTxData).getPayoutCompletionDataoverrides inbase-{coin,token},gnosis-{coin,token},optimism-{coin,token},polygon-{coin,token}strategies removed — all 18 EVM strategies now consistently use the parent implementation.PayoutTxStatuscollocated withPayoutRequest/FeeResultinpayout/interfaces/index.ts.No DB migration, no new status, no new entity columns, no change to gas estimation values — everything routes through existing entity methods (
rollbackPayout,designatePayout,pendingInvestigation) and the existingprocessFailedOrdersmail+investigation path.Test plan
PayoutUncertainwith mail