@aztec/ethereum 3.0.0-nightly.20251003 → 3.0.0-nightly.20251005

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dest/config.js +1 -1
  2. package/dest/contracts/multicall.d.ts +2 -2
  3. package/dest/contracts/multicall.d.ts.map +1 -1
  4. package/dest/contracts/rollup.d.ts +1 -1
  5. package/dest/contracts/rollup.d.ts.map +1 -1
  6. package/dest/contracts/rollup.js +1 -1
  7. package/dest/deploy_l1_contracts.d.ts +2 -2
  8. package/dest/deploy_l1_contracts.d.ts.map +1 -1
  9. package/dest/l1_tx_utils/config.d.ts +10 -7
  10. package/dest/l1_tx_utils/config.d.ts.map +1 -1
  11. package/dest/l1_tx_utils/config.js +12 -6
  12. package/dest/l1_tx_utils/l1_tx_utils.d.ts +16 -4
  13. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  14. package/dest/l1_tx_utils/l1_tx_utils.js +259 -129
  15. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +2 -2
  16. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
  17. package/dest/l1_tx_utils/readonly_l1_tx_utils.js +10 -20
  18. package/dest/l1_tx_utils/types.d.ts +11 -2
  19. package/dest/l1_tx_utils/types.d.ts.map +1 -1
  20. package/dest/l1_tx_utils/types.js +17 -0
  21. package/dest/publisher_manager.d.ts.map +1 -1
  22. package/dest/publisher_manager.js +16 -6
  23. package/dest/test/eth_cheat_codes.d.ts +18 -1
  24. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  25. package/dest/test/eth_cheat_codes.js +101 -22
  26. package/package.json +5 -5
  27. package/src/config.ts +1 -1
  28. package/src/contracts/multicall.ts +3 -6
  29. package/src/contracts/rollup.ts +2 -2
  30. package/src/deploy_l1_contracts.ts +2 -2
  31. package/src/l1_tx_utils/README.md +177 -0
  32. package/src/l1_tx_utils/config.ts +24 -13
  33. package/src/l1_tx_utils/l1_tx_utils.ts +282 -158
  34. package/src/l1_tx_utils/readonly_l1_tx_utils.ts +23 -19
  35. package/src/l1_tx_utils/types.ts +20 -2
  36. package/src/publisher_manager.ts +24 -5
  37. package/src/test/eth_cheat_codes.ts +120 -20
@@ -1,9 +1,9 @@
1
1
  import { maxBigint } from '@aztec/foundation/bigint';
2
- import { times } from '@aztec/foundation/collection';
3
- import { TimeoutError } from '@aztec/foundation/error';
2
+ import { merge, pick } from '@aztec/foundation/collection';
3
+ import { InterruptError, TimeoutError } from '@aztec/foundation/error';
4
4
  import { EthAddress } from '@aztec/foundation/eth-address';
5
5
  import { type Logger, createLogger } from '@aztec/foundation/log';
6
- import { makeBackoff, retry } from '@aztec/foundation/retry';
6
+ import { retryUntil } from '@aztec/foundation/retry';
7
7
  import { sleep } from '@aztec/foundation/sleep';
8
8
  import { DateProvider } from '@aztec/foundation/timer';
9
9
  import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi';
@@ -12,7 +12,6 @@ import pickBy from 'lodash.pickby';
12
12
  import {
13
13
  type Abi,
14
14
  type BlockOverrides,
15
- type GetTransactionReturnType,
16
15
  type Hex,
17
16
  type NonceManager,
18
17
  type PrepareTransactionRequestRequest,
@@ -31,12 +30,15 @@ import { type L1TxUtilsConfig, l1TxUtilsConfigMappings } from './config.js';
31
30
  import { LARGE_GAS_LIMIT } from './constants.js';
32
31
  import { ReadOnlyL1TxUtils } from './readonly_l1_tx_utils.js';
33
32
  import {
33
+ DroppedTransactionError,
34
34
  type L1BlobInputs,
35
- type L1GasConfig,
35
+ type L1TxConfig,
36
36
  type L1TxRequest,
37
37
  type L1TxState,
38
38
  type SigningCallback,
39
+ TerminalTxUtilsState,
39
40
  TxUtilsState,
41
+ UnknownMinedTxError,
40
42
  } from './types.js';
41
43
 
42
44
  const MAX_L1_TX_STATES = 32;
@@ -72,12 +74,12 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
72
74
  l1TxState.status = newState;
73
75
  const sender = this.getSenderAddress().toString();
74
76
  this.logger.debug(
75
- `State changed from ${TxUtilsState[oldState]} to ${TxUtilsState[newState]} for nonce ${l1TxState.nonce} account ${sender}`,
77
+ `Tx state changed from ${TxUtilsState[oldState]} to ${TxUtilsState[newState]} for nonce ${l1TxState.nonce} account ${sender}`,
76
78
  );
77
79
  }
78
80
 
79
81
  public updateConfig(newConfig: Partial<L1TxUtilsConfig>) {
80
- this.config = { ...this.config, ...newConfig };
82
+ this.config = merge(this.config, newConfig);
81
83
  this.logger.info(
82
84
  'Updated L1TxUtils config',
83
85
  pickBy(newConfig, (_, key) => key in l1TxUtilsConfigMappings),
@@ -112,12 +114,16 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
112
114
  */
113
115
  public async sendTransaction(
114
116
  request: L1TxRequest,
115
- gasConfigOverrides?: L1GasConfig,
117
+ gasConfigOverrides?: L1TxConfig,
116
118
  blobInputs?: L1BlobInputs,
117
119
  stateChange: TxUtilsState = TxUtilsState.SENT,
118
120
  ): Promise<{ txHash: Hex; state: L1TxState }> {
121
+ if (this.interrupted) {
122
+ throw new InterruptError(`Transaction sending is interrupted`);
123
+ }
124
+
119
125
  try {
120
- const gasConfig = { ...this.config, ...gasConfigOverrides };
126
+ const gasConfig = merge(this.config, gasConfigOverrides);
121
127
  const account = this.getSenderAddress().toString();
122
128
 
123
129
  let gasLimit: bigint;
@@ -128,34 +134,16 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
128
134
  } else {
129
135
  gasLimit = await this.estimateGas(account, request, gasConfig);
130
136
  }
131
- this.logger?.debug(`Gas limit for request is ${gasLimit}`, { gasLimit, ...request });
137
+ this.logger.trace(`Computed gas limit ${gasLimit}`, { gasLimit, ...request });
132
138
 
133
139
  const gasPrice = await this.getGasPrice(gasConfig, !!blobInputs);
134
140
 
135
- if (gasConfig.txTimeoutAt && this.dateProvider.now() > gasConfig.txTimeoutAt.getTime()) {
136
- throw new Error('Transaction timed out before sending');
137
- }
138
-
139
141
  const nonce = await this.nonceManager.consume({
140
142
  client: this.client,
141
143
  address: account,
142
144
  chainId: this.client.chain.id,
143
145
  });
144
146
 
145
- const l1TxState: L1TxState = {
146
- txHashes: [],
147
- cancelTxHashes: [],
148
- gasPrice,
149
- request,
150
- status: TxUtilsState.IDLE,
151
- nonce,
152
- gasLimit,
153
- txConfig: gasConfig,
154
- blobInputs,
155
- };
156
-
157
- this.updateState(l1TxState, stateChange);
158
-
159
147
  const baseTxData = {
160
148
  ...request,
161
149
  gas: gasLimit,
@@ -168,29 +156,60 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
168
156
  ? { ...baseTxData, ...blobInputs, maxFeePerBlobGas: gasPrice.maxFeePerBlobGas! }
169
157
  : baseTxData;
170
158
 
159
+ const now = new Date(await this.getL1Timestamp());
160
+ if (gasConfig.txTimeoutAt && now > gasConfig.txTimeoutAt) {
161
+ throw new TimeoutError(
162
+ `Transaction timed out before sending (now ${now.toISOString()} > timeoutAt ${gasConfig.txTimeoutAt.toISOString()})`,
163
+ );
164
+ }
165
+
166
+ if (this.interrupted) {
167
+ throw new InterruptError(`Transaction sending is interrupted`);
168
+ }
169
+
171
170
  const signedRequest = await this.prepareSignedTransaction(txData);
172
171
  const txHash = await this.client.sendRawTransaction({ serializedTransaction: signedRequest });
173
172
 
174
- l1TxState.txHashes.push(txHash);
173
+ const l1TxState: L1TxState = {
174
+ txHashes: [txHash],
175
+ cancelTxHashes: [],
176
+ gasPrice,
177
+ request,
178
+ status: TxUtilsState.IDLE,
179
+ nonce,
180
+ gasLimit,
181
+ txConfigOverrides: gasConfigOverrides ?? {},
182
+ blobInputs,
183
+ sentAtL1Ts: now,
184
+ lastSentAtL1Ts: now,
185
+ };
186
+
187
+ this.updateState(l1TxState, stateChange);
188
+
175
189
  this.txs.push(l1TxState);
176
190
  if (this.txs.length > MAX_L1_TX_STATES) {
177
191
  this.txs.shift();
178
192
  }
179
193
 
180
- const cleanGasConfig = pickBy(gasConfig, (_, key) => key in l1TxUtilsConfigMappings);
181
- this.logger?.info(`Sent L1 transaction ${txHash}`, {
194
+ this.logger.info(`Sent L1 transaction ${txHash}`, {
195
+ to: request.to,
196
+ value: request.value,
197
+ nonce,
198
+ account,
199
+ sentAt: now,
182
200
  gasLimit,
183
201
  maxFeePerGas: formatGwei(gasPrice.maxFeePerGas),
184
202
  maxPriorityFeePerGas: formatGwei(gasPrice.maxPriorityFeePerGas),
185
- gasConfig: cleanGasConfig,
186
203
  ...(gasPrice.maxFeePerBlobGas && { maxFeePerBlobGas: formatGwei(gasPrice.maxFeePerBlobGas) }),
204
+ isBlobTx: !!blobInputs,
205
+ txConfig: gasConfigOverrides,
187
206
  });
188
207
 
189
208
  return { txHash, state: l1TxState };
190
209
  } catch (err: any) {
191
210
  const viemError = formatViemError(err, request.abi);
192
- this.logger?.error(`Failed to send L1 transaction`, viemError.message, {
193
- metaMessages: viemError.metaMessages,
211
+ this.logger.error(`Failed to send L1 transaction`, viemError, {
212
+ request: pick(request, 'to', 'value'),
194
213
  });
195
214
  throw viemError;
196
215
  }
@@ -205,11 +224,12 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
205
224
  try {
206
225
  const receipt = await this.client.getTransactionReceipt({ hash });
207
226
  if (receipt) {
227
+ const account = this.getSenderAddress().toString();
208
228
  const what = isCancelTx ? 'Cancellation L1 transaction' : 'L1 transaction';
209
229
  if (receipt.status === 'reverted') {
210
- this.logger?.warn(`${what} ${hash} with nonce ${nonce} reverted`, receipt);
230
+ this.logger.warn(`${what} ${hash} with nonce ${nonce} reverted`, { receipt, nonce, account });
211
231
  } else {
212
- this.logger?.verbose(`${what} ${hash} with nonce ${nonce} mined`, receipt);
232
+ this.logger.info(`${what} ${hash} with nonce ${nonce} mined`, { receipt, nonce, account });
213
233
  }
214
234
  return receipt;
215
235
  }
@@ -224,50 +244,89 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
224
244
  }
225
245
  }
226
246
 
247
+ /**
248
+ * Returns whether a given tx should be considered as timed out and no longer monitored.
249
+ * Relies on the txTimeout setting for user txs, and on the txCancellationTimeout for cancel txs.
250
+ * @remarks We check against the latestBlockTimestamp as opposed to the current time to avoid a race condition where
251
+ * the tx is mined in a block with the same timestamp as txTimeoutAt, but our execution node has not yet processed it,
252
+ * or the loop here has not yet checked the tx before that timeout.
253
+ */
254
+ private isTxTimedOut(state: L1TxState, l1Timestamp: number) {
255
+ const account = this.getSenderAddress().toString();
256
+ const { nonce } = state;
257
+
258
+ const txConfig = merge(this.config, state.txConfigOverrides);
259
+ const { txTimeoutAt, txTimeoutMs, maxSpeedUpAttempts } = txConfig;
260
+ const isCancelTx = state.cancelTxHashes.length > 0;
261
+
262
+ this.logger.trace(`Tx timeout check for ${account} with nonce ${nonce}`, {
263
+ nonce,
264
+ account,
265
+ l1Timestamp,
266
+ lastAttemptSent: state.lastSentAtL1Ts.getTime(),
267
+ initialTxTime: state.sentAtL1Ts.getTime(),
268
+ now: this.dateProvider.now(),
269
+ txTimeoutAt,
270
+ txTimeoutMs,
271
+ interrupted: this.interrupted,
272
+ });
273
+
274
+ if (this.interrupted) {
275
+ this.logger.warn(`Tx monitoring interrupted during nonce ${nonce} for ${account} check`, { nonce, account });
276
+ return true;
277
+ }
278
+
279
+ if (isCancelTx) {
280
+ // Note that we check against the lastSentAt time for cancellations, since we time them out
281
+ // after the last attempt to submit them, not the initial time.
282
+ const attempts = state.cancelTxHashes.length;
283
+ return (
284
+ attempts > maxSpeedUpAttempts &&
285
+ state.lastSentAtL1Ts.getTime() + txConfig.txCancellationFinalTimeoutMs! <= l1Timestamp
286
+ );
287
+ }
288
+
289
+ return (
290
+ (txTimeoutAt !== undefined && l1Timestamp >= txTimeoutAt.getTime()) ||
291
+ (txTimeoutMs !== undefined && txTimeoutMs > 0 && l1Timestamp - state.sentAtL1Ts.getTime() >= txTimeoutMs)
292
+ );
293
+ }
294
+
227
295
  /**
228
296
  * Monitors a transaction until completion, handling speed-ups if needed
229
297
  */
230
298
  protected async monitorTransaction(state: L1TxState): Promise<TransactionReceipt> {
231
- const { request, nonce, txHashes, cancelTxHashes, gasLimit, blobInputs, txConfig: gasConfig } = state;
232
- const isCancelTx = cancelTxHashes.length > 0;
299
+ const { request, nonce, gasLimit, blobInputs, txConfigOverrides: gasConfigOverrides } = state;
300
+ const gasConfig = merge(this.config, gasConfigOverrides);
301
+ const { maxSpeedUpAttempts, stallTimeMs } = gasConfig;
302
+ const isCancelTx = state.cancelTxHashes.length > 0;
303
+ const txHashes = isCancelTx ? state.cancelTxHashes : state.txHashes;
233
304
  const isBlobTx = !!blobInputs;
234
305
  const account = this.getSenderAddress().toString();
235
306
 
236
- const makeGetTransactionBackoff = () =>
237
- makeBackoff(times(gasConfig.txPropagationMaxQueryAttempts ?? 3, i => i + 1));
238
-
239
- let currentTxHash = isCancelTx ? cancelTxHashes[0] : txHashes[0];
240
- let attempts = 0;
241
- let lastAttemptSent = this.dateProvider.now();
242
-
243
- const initialTxTime = lastAttemptSent;
244
- let txTimedOut = false;
245
- let latestBlockTimestamp: bigint | undefined;
246
-
247
- // We check against the latestBlockTimestamp as opposed to the current time to avoid a race condition where
248
- // the tx is mined in a block with the same timestamp as txTimeoutAt, but our execution node has not yet processed it,
249
- // or the loop here has not yet checked the tx before that timeout.
250
- const isTimedOut = () =>
251
- (gasConfig.txTimeoutAt &&
252
- latestBlockTimestamp !== undefined &&
253
- Number(latestBlockTimestamp) * 1000 >= gasConfig.txTimeoutAt.getTime()) ||
254
- (gasConfig.txTimeoutMs !== undefined && this.dateProvider.now() - initialTxTime > gasConfig.txTimeoutMs) ||
255
- this.interrupted ||
256
- false;
257
-
258
- while (!txTimedOut) {
307
+ const initialTxHash = txHashes[0];
308
+ let currentTxHash = initialTxHash;
309
+ let l1Timestamp: number;
310
+
311
+ while (true) {
312
+ l1Timestamp = await this.getL1Timestamp();
313
+
259
314
  try {
260
- ({ timestamp: latestBlockTimestamp } = await this.client.getBlock({
261
- blockTag: 'latest',
262
- includeTransactions: false,
263
- }));
315
+ const timePassed = l1Timestamp - state.lastSentAtL1Ts.getTime();
316
+ const [currentNonce, pendingNonce] = await Promise.all([
317
+ this.client.getTransactionCount({ address: account, blockTag: 'latest' }),
318
+ this.client.getTransactionCount({ address: account, blockTag: 'pending' }),
319
+ ]);
264
320
 
265
- const currentNonce = await this.client.getTransactionCount({ address: account });
266
321
  // If the current nonce on our account is greater than our transaction's nonce then a tx with the same nonce has been mined.
267
322
  if (currentNonce > nonce) {
323
+ // We try getting the receipt twice, since sometimes anvil fails to return it if the tx has just been mined
268
324
  const receipt =
269
- (await this.tryGetTxReceipt(cancelTxHashes, nonce, true)) ??
270
- (await this.tryGetTxReceipt(txHashes, nonce, false));
325
+ (await this.tryGetTxReceipt(state.cancelTxHashes, nonce, true)) ??
326
+ (await this.tryGetTxReceipt(state.txHashes, nonce, false)) ??
327
+ (await sleep(500)) ??
328
+ (await this.tryGetTxReceipt(state.cancelTxHashes, nonce, true)) ??
329
+ (await this.tryGetTxReceipt(state.txHashes, nonce, false));
271
330
 
272
331
  if (receipt) {
273
332
  this.updateState(state, TxUtilsState.MINED);
@@ -278,62 +337,39 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
278
337
  // If we get here then we have checked all of our tx versions and not found anything.
279
338
  // We should consider the nonce as MINED
280
339
  this.updateState(state, TxUtilsState.MINED);
281
- throw new Error(`Nonce ${nonce} is MINED but not by one of our expected transactions`);
340
+ throw new UnknownMinedTxError(nonce, account);
282
341
  }
283
342
 
284
- this.logger?.trace(`Tx timeout check for ${currentTxHash}: ${isTimedOut()}`, {
285
- latestBlockTimestamp: Number(latestBlockTimestamp) * 1000,
286
- lastAttemptSent,
287
- initialTxTime,
288
- now: this.dateProvider.now(),
289
- txTimeoutAt: gasConfig.txTimeoutAt?.getTime(),
290
- txTimeoutMs: gasConfig.txTimeoutMs,
291
- txStallTime: gasConfig.stallTimeMs,
292
- });
293
-
294
- // Retry a few times, in case the tx is not yet propagated.
295
- const tx = await retry<GetTransactionReturnType>(
296
- () => this.client.getTransaction({ hash: currentTxHash }),
297
- `Getting L1 transaction ${currentTxHash}`,
298
- makeGetTransactionBackoff(),
299
- this.logger,
300
- true,
301
- );
302
- const timePassed = this.dateProvider.now() - lastAttemptSent;
303
-
304
- if (tx && timePassed < gasConfig.stallTimeMs!) {
305
- this.logger?.debug(`L1 transaction ${currentTxHash} pending. Time passed: ${timePassed}ms.`);
306
-
307
- // Check timeout before continuing
308
- txTimedOut = isTimedOut();
309
- if (txTimedOut) {
310
- break;
311
- }
343
+ // If this is a cancel tx and its nonce is no longer on the mempool, we consider it dropped and stop monitoring
344
+ // If it is a regular tx, we let the loop speed it up after the stall time
345
+ if (isCancelTx && pendingNonce <= nonce && timePassed >= gasConfig.txUnseenConsideredDroppedMs) {
346
+ this.logger.warn(
347
+ `Cancellation tx with nonce ${nonce} for account ${account} has been dropped from the visible mempool`,
348
+ { nonce, account, pendingNonce, timePassed },
349
+ );
350
+ this.updateState(state, TxUtilsState.NOT_MINED);
351
+ this.nonceManager.reset({ address: account, chainId: this.client.chain.id });
352
+ throw new DroppedTransactionError(nonce, account);
353
+ }
312
354
 
313
- await sleep(gasConfig.checkIntervalMs!);
314
- continue;
355
+ // Break if the tx has timed out (ie expired)
356
+ if (this.isTxTimedOut(state, l1Timestamp)) {
357
+ break;
315
358
  }
316
359
 
317
- if (timePassed > gasConfig.stallTimeMs! && attempts < gasConfig.maxAttempts!) {
318
- attempts++;
319
- const newGasPrice = await this.getGasPrice(
320
- gasConfig,
321
- isBlobTx,
322
- attempts,
323
- tx.maxFeePerGas && tx.maxPriorityFeePerGas
324
- ? {
325
- maxFeePerGas: tx.maxFeePerGas,
326
- maxPriorityFeePerGas: tx.maxPriorityFeePerGas,
327
- maxFeePerBlobGas: tx.maxFeePerBlobGas,
328
- }
329
- : undefined,
330
- );
360
+ // Speed up the transaction if it appears to be stuck (exceeded stall time and still have retry attempts)
361
+ const attempts = txHashes.length;
362
+ if (timePassed >= stallTimeMs && attempts <= maxSpeedUpAttempts) {
363
+ const newGasPrice = await this.getGasPrice(gasConfig, isBlobTx, attempts, state.gasPrice);
331
364
  state.gasPrice = newGasPrice;
332
365
 
333
- this.logger?.debug(
334
- `L1 transaction ${currentTxHash} appears stuck. Attempting speed-up ${attempts}/${gasConfig.maxAttempts} ` +
335
- `with new priority fee ${formatGwei(newGasPrice.maxPriorityFeePerGas)} gwei`,
366
+ this.logger.debug(
367
+ `Tx ${currentTxHash} with nonce ${nonce} from ${account} appears stuck. ` +
368
+ `Attempting speed-up ${attempts}/${maxSpeedUpAttempts} ` +
369
+ `with new priority fee ${formatGwei(newGasPrice.maxPriorityFeePerGas)} gwei.`,
336
370
  {
371
+ account,
372
+ nonce,
337
373
  maxFeePerGas: formatGwei(newGasPrice.maxFeePerGas),
338
374
  maxPriorityFeePerGas: formatGwei(newGasPrice.maxPriorityFeePerGas),
339
375
  ...(newGasPrice.maxFeePerBlobGas && { maxFeePerBlobGas: formatGwei(newGasPrice.maxFeePerBlobGas) }),
@@ -359,58 +395,109 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
359
395
  this.updateState(state, TxUtilsState.SPEED_UP);
360
396
  }
361
397
 
362
- const cleanGasConfig = pickBy(gasConfig, (_, key) => key in l1TxUtilsConfigMappings);
363
- this.logger?.verbose(`Sent L1 speed-up tx ${newHash}, replacing ${currentTxHash}`, {
364
- gasLimit,
365
- maxFeePerGas: formatGwei(newGasPrice.maxFeePerGas),
366
- maxPriorityFeePerGas: formatGwei(newGasPrice.maxPriorityFeePerGas),
367
- gasConfig: cleanGasConfig,
368
- ...(newGasPrice.maxFeePerBlobGas && { maxFeePerBlobGas: formatGwei(newGasPrice.maxFeePerBlobGas) }),
369
- });
398
+ this.logger.verbose(
399
+ `Sent L1 speed-up tx ${newHash} replacing ${currentTxHash} for nonce ${nonce} from ${account}`,
400
+ {
401
+ nonce,
402
+ account,
403
+ gasLimit,
404
+ maxFeePerGas: formatGwei(newGasPrice.maxFeePerGas),
405
+ maxPriorityFeePerGas: formatGwei(newGasPrice.maxPriorityFeePerGas),
406
+ txConfig: state.txConfigOverrides,
407
+ ...(newGasPrice.maxFeePerBlobGas && { maxFeePerBlobGas: formatGwei(newGasPrice.maxFeePerBlobGas) }),
408
+ },
409
+ );
370
410
 
371
411
  currentTxHash = newHash;
372
-
373
- (isCancelTx ? cancelTxHashes : txHashes).push(currentTxHash);
374
- lastAttemptSent = this.dateProvider.now();
412
+ txHashes.push(currentTxHash);
413
+ state.lastSentAtL1Ts = new Date(l1Timestamp);
414
+ await sleep(gasConfig.checkIntervalMs!);
415
+ continue;
375
416
  }
417
+
418
+ this.logger.debug(
419
+ `Tx ${currentTxHash} from ${account} with nonce ${nonce} still pending after ${timePassed}ms`,
420
+ {
421
+ account,
422
+ nonce,
423
+ pendingNonce,
424
+ attempts,
425
+ timePassed,
426
+ isBlobTx,
427
+ isCancelTx,
428
+ ...pick(state.gasPrice, 'maxFeePerGas', 'maxPriorityFeePerGas', 'maxFeePerBlobGas'),
429
+ ...pick(
430
+ gasConfig,
431
+ 'txUnseenConsideredDroppedMs',
432
+ 'txCancellationFinalTimeoutMs',
433
+ 'maxSpeedUpAttempts',
434
+ 'stallTimeMs',
435
+ 'txTimeoutAt',
436
+ 'txTimeoutMs',
437
+ ),
438
+ },
439
+ );
440
+
376
441
  await sleep(gasConfig.checkIntervalMs!);
377
442
  } catch (err: any) {
378
- const viemError = formatViemError(err);
379
- this.logger?.warn(`Error monitoring L1 transaction ${currentTxHash}:`, viemError.message);
380
- if (viemError.message?.includes('reverted')) {
381
- throw viemError;
443
+ if (err instanceof DroppedTransactionError || err instanceof UnknownMinedTxError) {
444
+ throw err;
382
445
  }
446
+
447
+ const viemError = formatViemError(err);
448
+ this.logger.error(`Error while monitoring L1 tx ${currentTxHash}`, viemError, { nonce, account });
383
449
  await sleep(gasConfig.checkIntervalMs!);
384
450
  }
385
- // Check if tx has timed out.
386
- txTimedOut = isTimedOut();
387
451
  }
388
452
 
389
- // The transaction has timed out. If it's a cancellation then we are giving up on it.
390
- // Otherwise we may attempt to cancel it if configured to do so.
391
- if (isCancelTx) {
453
+ // Oh no, the transaction has timed out!
454
+ if (isCancelTx || !gasConfig.cancelTxOnTimeout) {
455
+ // If this was already a cancellation tx, or we are configured to not cancel txs, we just mark it as NOT_MINED
456
+ // and reset the nonce manager, so the next tx that comes along can reuse the nonce if/when this tx gets dropped.
457
+ // This is the nastiest scenario for us, since the new tx could acquire the next nonce, but then this tx is dropped,
458
+ // and the new tx would never get mined. Eventually, the new tx would also drop.
392
459
  this.updateState(state, TxUtilsState.NOT_MINED);
393
- } else if (gasConfig.cancelTxOnTimeout) {
394
- // Fire cancellation without awaiting to avoid blocking the main thread
395
- this.attemptTxCancellation(state, attempts).catch(err => {
396
- const viemError = formatViemError(err);
397
- this.logger?.error(`Failed to send cancellation for timed out tx ${currentTxHash}:`, viemError.message, {
398
- metaMessages: viemError.metaMessages,
460
+ this.nonceManager.reset({ address: account, chainId: this.client.chain.id });
461
+ } else {
462
+ // Otherwise we fire the cancellation without awaiting to avoid blocking the caller,
463
+ // and monitor it in the background so we can speed it up as needed.
464
+ void this.attemptTxCancellation(state).catch(err => {
465
+ this.updateState(state, TxUtilsState.NOT_MINED);
466
+ this.logger.error(`Failed to send cancellation for timed out tx ${initialTxHash} with nonce ${nonce}`, err, {
467
+ account,
468
+ nonce,
469
+ initialTxHash,
399
470
  });
400
471
  });
401
472
  }
402
473
 
403
- this.logger?.warn(`L1 transaction ${currentTxHash} timed out`, {
404
- txHash: currentTxHash,
405
- txTimeoutAt: gasConfig.txTimeoutAt,
474
+ const what = isCancelTx ? 'Cancellation L1' : 'L1';
475
+ this.logger.warn(`${what} transaction ${initialTxHash} with nonce ${nonce} from ${account} timed out`, {
476
+ initialTxHash,
477
+ currentTxHash,
478
+ nonce,
479
+ account,
480
+ txTimeoutAt: gasConfig.txTimeoutAt?.getTime(),
406
481
  txTimeoutMs: gasConfig.txTimeoutMs,
407
- txInitialTime: initialTxTime,
482
+ txInitialTime: state.sentAtL1Ts.getTime(),
483
+ l1Timestamp,
408
484
  now: this.dateProvider.now(),
409
- attempts,
485
+ attempts: txHashes.length - 1,
410
486
  isInterrupted: this.interrupted,
411
487
  });
412
488
 
413
- throw new TimeoutError(`L1 transaction ${currentTxHash} timed out`);
489
+ throw new TimeoutError(`L1 transaction ${initialTxHash} timed out`);
490
+ }
491
+
492
+ /** Returns when all monitor loops have stopped. */
493
+ public async waitMonitoringStopped(timeoutSeconds = 10) {
494
+ const account = this.getSenderAddress().toString();
495
+ await retryUntil(
496
+ () => this.txs.every(tx => TerminalTxUtilsState.includes(tx.status)),
497
+ `monitoring stopped for ${account}`,
498
+ timeoutSeconds,
499
+ 0.1,
500
+ ).catch(() => this.logger.warn(`Timeout waiting for monitoring loops to stop for ${account}`));
414
501
  }
415
502
 
416
503
  /**
@@ -421,7 +508,7 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
421
508
  */
422
509
  public async sendAndMonitorTransaction(
423
510
  request: L1TxRequest,
424
- gasConfig?: L1GasConfig,
511
+ gasConfig?: L1TxConfig,
425
512
  blobInputs?: L1BlobInputs,
426
513
  ): Promise<{ receipt: TransactionReceipt; state: L1TxState }> {
427
514
  const { state } = await this.sendTransaction(request, gasConfig, blobInputs);
@@ -437,7 +524,7 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
437
524
  _gasConfig?: L1TxUtilsConfig & { fallbackGasEstimate?: bigint; ignoreBlockGasLimit?: boolean },
438
525
  ): Promise<{ gasUsed: bigint; result: `0x${string}` }> {
439
526
  const blockOverrides = { ..._blockOverrides };
440
- const gasConfig = { ...this.config, ..._gasConfig };
527
+ const gasConfig = merge(this.config, _gasConfig);
441
528
  const gasPrice = await this.getGasPrice(gasConfig, false);
442
529
 
443
530
  const call: any = {
@@ -459,11 +546,36 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
459
546
 
460
547
  /**
461
548
  * Attempts to cancel a transaction by sending a 0-value tx to self with same nonce but higher gas prices
549
+ * Only sends the cancellation if the original tx is still pending, not if it was dropped
462
550
  * @returns The hash of the cancellation transaction
463
551
  */
464
- protected async attemptTxCancellation(state: L1TxState, attempts: number) {
552
+ protected async attemptTxCancellation(state: L1TxState) {
465
553
  const isBlobTx = state.blobInputs !== undefined;
466
554
  const { nonce, gasPrice: previousGasPrice } = state;
555
+ const account = this.getSenderAddress().toString();
556
+
557
+ // Do not send cancellation if interrupted
558
+ if (this.interrupted) {
559
+ this.logger.warn(
560
+ `Not sending cancellation for L1 tx from account ${account} with nonce ${nonce} as interrupted`,
561
+ { nonce, account },
562
+ );
563
+ this.updateState(state, TxUtilsState.NOT_MINED);
564
+ this.nonceManager.reset({ address: account, chainId: this.client.chain.id });
565
+ return;
566
+ }
567
+
568
+ // Check if the original tx is still pending
569
+ const currentNonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' });
570
+ if (currentNonce < nonce) {
571
+ this.logger.verbose(
572
+ `Not sending cancellation for L1 tx from account ${account} with nonce ${nonce} as it is dropped`,
573
+ { nonce, account, currentNonce },
574
+ );
575
+ this.updateState(state, TxUtilsState.NOT_MINED);
576
+ this.nonceManager.reset({ address: account, chainId: this.client.chain.id });
577
+ return;
578
+ }
467
579
 
468
580
  // Get gas price with higher priority fee for cancellation
469
581
  const cancelGasPrice = await this.getGasPrice(
@@ -473,13 +585,13 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
473
585
  priorityFeeRetryBumpPercentage: 150, // 150% bump should be enough to replace any tx
474
586
  },
475
587
  isBlobTx,
476
- attempts + 1,
588
+ state.txHashes.length,
477
589
  previousGasPrice,
478
590
  );
479
591
 
480
592
  const { maxFeePerGas, maxPriorityFeePerGas, maxFeePerBlobGas } = cancelGasPrice;
481
- this.logger?.info(
482
- `Attempting to cancel L1 ${isBlobTx ? 'blob' : 'vanilla'} transaction ${state.txHashes[0]} with nonce ${nonce}`,
593
+ this.logger.verbose(
594
+ `Attempting to cancel L1 ${isBlobTx ? 'blob' : 'vanilla'} transaction from account ${account} with nonce ${nonce} after time out`,
483
595
  {
484
596
  maxFeePerGas: formatGwei(maxFeePerGas),
485
597
  maxPriorityFeePerGas: formatGwei(maxPriorityFeePerGas),
@@ -508,18 +620,30 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
508
620
  state.gasPrice = cancelGasPrice;
509
621
  state.gasLimit = 21_000n;
510
622
  state.cancelTxHashes.push(cancelTxHash);
623
+ state.lastSentAtL1Ts = new Date(await this.getL1Timestamp());
511
624
 
512
625
  this.updateState(state, TxUtilsState.CANCELLED);
513
626
 
514
- this.logger?.info(`Sent cancellation tx ${cancelTxHash} for timed out tx with nonce ${nonce}`, {
627
+ this.logger.warn(`Sent cancellation tx ${cancelTxHash} for timed out tx from ${account} with nonce ${nonce}`, {
515
628
  nonce,
516
- txData,
629
+ txData: baseTxData,
517
630
  isBlobTx,
518
631
  txHashes: state.txHashes,
519
632
  });
520
633
 
521
- const { transactionHash } = await this.monitorTransaction(state);
522
- return transactionHash;
634
+ // Do not await the cancel tx to be mined
635
+ void this.monitorTransaction(state).catch(err => {
636
+ this.logger.error(`Failed to mine cancellation tx ${cancelTxHash} for nonce ${nonce} account ${account}`, err, {
637
+ nonce,
638
+ account,
639
+ cancelTxHash,
640
+ });
641
+ });
642
+ }
643
+
644
+ private async getL1Timestamp() {
645
+ const { timestamp } = await this.client.getBlock({ blockTag: 'latest', includeTransactions: false });
646
+ return Number(timestamp) * 1000;
523
647
  }
524
648
 
525
649
  /** Makes empty blob inputs for the cancellation tx. To be overridden in L1TxUtilsWithBlobs. */