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 });