From 86a366dc10c1ad660910768753a3c46d8812dd66 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 13 Feb 2026 16:04:00 -0300 Subject: [PATCH] fix(ethereum): check timeout before consuming nonce in L1TxUtils Move the txTimeoutAt check before nonceManager.consume() to prevent nonce leaks. When a transaction timed out before sending, the nonce was consumed but never used, creating a permanent gap (e.g. nonce 107 consumed but unsent). All subsequent transactions (108, 109, ...) would then fail because the chain expects nonce 107 first. This caused an infinite prune-rebuild loop in the sequencer in e2e_bot tests. Co-Authored-By: Claude Opus 4.6 --- .../src/l1_tx_utils/l1_tx_utils.test.ts | 15 +++++++++++++++ .../ethereum/src/l1_tx_utils/l1_tx_utils.ts | 17 ++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts index 152abf04e680..8670113a7ed7 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.test.ts @@ -918,6 +918,21 @@ describe('L1TxUtils', () => { expect(result.receipt.status).toBe('reverted'); }); + it('does not consume nonce when transaction times out before sending', async () => { + // Get the expected nonce before any transaction + const expectedNonce = await l1Client.getTransactionCount({ address: l1Client.account.address }); + + // Try to send with an already-expired timeout (epoch 0 is well in the past) + const pastTimeout = new Date(0); + await expect(gasUtils.sendTransaction(request, { txTimeoutAt: pastTimeout })).rejects.toThrow( + /timed out before sending/, + ); + + // The next transaction should use the same nonce (not skip one due to a leaked consume) + const { state } = await gasUtils.sendTransaction(request); + expect(state.nonce).toBe(expectedNonce); + }, 10_000); + it('stops trying after timeout once block is mined', async () => { await cheatCodes.setAutomine(false); await cheatCodes.setIntervalMining(0); diff --git a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts index f91539bfb946..5c5c0f776db2 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts @@ -244,6 +244,16 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { throw new InterruptError(`Transaction sending is interrupted`); } + // Check timeout before consuming nonce to avoid leaking a nonce that was never sent. + // A leaked nonce creates a gap (e.g. nonce 107 consumed but unsent), so all subsequent + // transactions (108, 109, ...) can never be mined since the chain expects 107 first. + const now = new Date(await this.getL1Timestamp()); + if (gasConfig.txTimeoutAt && now > gasConfig.txTimeoutAt) { + throw new TimeoutError( + `Transaction timed out before sending (now ${now.toISOString()} > timeoutAt ${gasConfig.txTimeoutAt.toISOString()})`, + ); + } + const nonce = await this.nonceManager.consume({ client: this.client, address: account, @@ -253,13 +263,6 @@ export class L1TxUtils extends ReadOnlyL1TxUtils { const baseState = { request, gasLimit, blobInputs, gasPrice, nonce }; const txData = this.makeTxData(baseState, { isCancelTx: false }); - const now = new Date(await this.getL1Timestamp()); - if (gasConfig.txTimeoutAt && now > gasConfig.txTimeoutAt) { - throw new TimeoutError( - `Transaction timed out before sending (now ${now.toISOString()} > timeoutAt ${gasConfig.txTimeoutAt.toISOString()})`, - ); - } - // Send the new tx const signedRequest = await this.prepareSignedTransaction(txData); const txHash = await this.client.sendRawTransaction({ serializedTransaction: signedRequest });