@aztec/ethereum 0.0.1-commit.2f68f620 → 0.0.1-commit.321f6a9

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 (77) hide show
  1. package/dest/config.d.ts +22 -3
  2. package/dest/config.d.ts.map +1 -1
  3. package/dest/config.js +54 -3
  4. package/dest/contracts/gse.d.ts +4 -2
  5. package/dest/contracts/gse.d.ts.map +1 -1
  6. package/dest/contracts/gse.js +2 -2
  7. package/dest/contracts/multicall.d.ts +5 -3
  8. package/dest/contracts/multicall.d.ts.map +1 -1
  9. package/dest/contracts/multicall.js +7 -5
  10. package/dest/contracts/rollup.d.ts +168 -9
  11. package/dest/contracts/rollup.d.ts.map +1 -1
  12. package/dest/contracts/rollup.js +15 -5
  13. package/dest/deploy_aztec_l1_contracts.d.ts +12 -2
  14. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  15. package/dest/deploy_aztec_l1_contracts.js +19 -5
  16. package/dest/foundry_binary.d.ts +13 -0
  17. package/dest/foundry_binary.d.ts.map +1 -0
  18. package/dest/foundry_binary.js +52 -0
  19. package/dest/l1_artifacts.d.ts +1219 -77
  20. package/dest/l1_artifacts.d.ts.map +1 -1
  21. package/dest/l1_tx_utils/config.js +3 -3
  22. package/dest/l1_tx_utils/l1_fee_analyzer.d.ts +23 -4
  23. package/dest/l1_tx_utils/l1_fee_analyzer.d.ts.map +1 -1
  24. package/dest/l1_tx_utils/l1_fee_analyzer.js +65 -3
  25. package/dest/l1_tx_utils/l1_tx_utils.d.ts +1 -1
  26. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  27. package/dest/l1_tx_utils/l1_tx_utils.js +84 -22
  28. package/dest/l1_tx_utils/types.d.ts +27 -1
  29. package/dest/l1_tx_utils/types.d.ts.map +1 -1
  30. package/dest/l1_tx_utils/types.js +10 -0
  31. package/dest/publisher_manager.d.ts +18 -6
  32. package/dest/publisher_manager.d.ts.map +1 -1
  33. package/dest/publisher_manager.js +44 -7
  34. package/dest/queries.d.ts +1 -1
  35. package/dest/queries.d.ts.map +1 -1
  36. package/dest/queries.js +7 -1
  37. package/dest/test/blob_kzg_warmup.d.ts +19 -0
  38. package/dest/test/blob_kzg_warmup.d.ts.map +1 -0
  39. package/dest/test/blob_kzg_warmup.js +64 -0
  40. package/dest/test/chain_monitor.d.ts +33 -2
  41. package/dest/test/chain_monitor.d.ts.map +1 -1
  42. package/dest/test/chain_monitor.js +71 -6
  43. package/dest/test/eth_cheat_codes.d.ts +26 -3
  44. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  45. package/dest/test/eth_cheat_codes.js +72 -37
  46. package/dest/test/index.d.ts +2 -1
  47. package/dest/test/index.d.ts.map +1 -1
  48. package/dest/test/index.js +1 -0
  49. package/dest/test/rollup_cheat_codes.d.ts +30 -1
  50. package/dest/test/rollup_cheat_codes.d.ts.map +1 -1
  51. package/dest/test/rollup_cheat_codes.js +27 -0
  52. package/dest/test/start_anvil.d.ts +1 -1
  53. package/dest/test/start_anvil.d.ts.map +1 -1
  54. package/dest/test/start_anvil.js +41 -5
  55. package/package.json +5 -6
  56. package/src/config.ts +73 -3
  57. package/src/contracts/gse.ts +7 -2
  58. package/src/contracts/multicall.ts +8 -5
  59. package/src/contracts/rollup.ts +15 -6
  60. package/src/deploy_aztec_l1_contracts.ts +23 -5
  61. package/src/foundry_binary.ts +57 -0
  62. package/src/l1_tx_utils/config.ts +3 -3
  63. package/src/l1_tx_utils/l1_fee_analyzer.ts +75 -3
  64. package/src/l1_tx_utils/l1_tx_utils.ts +71 -20
  65. package/src/l1_tx_utils/types.ts +32 -0
  66. package/src/publisher_manager.ts +54 -18
  67. package/src/queries.ts +6 -0
  68. package/src/test/blob_kzg_warmup.ts +65 -0
  69. package/src/test/chain_monitor.ts +88 -5
  70. package/src/test/eth_cheat_codes.ts +66 -41
  71. package/src/test/index.ts +1 -0
  72. package/src/test/rollup_cheat_codes.ts +51 -0
  73. package/src/test/start_anvil.ts +38 -6
  74. package/dest/generated/l1-contracts-defaults.d.ts +0 -30
  75. package/dest/generated/l1-contracts-defaults.d.ts.map +0 -1
  76. package/dest/generated/l1-contracts-defaults.js +0 -30
  77. package/src/generated/l1-contracts-defaults.ts +0 -32
package/src/config.ts CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  optionalNumberConfigHelper,
10
10
  } from '@aztec/foundation/config';
11
11
  import { EthAddress } from '@aztec/foundation/eth-address';
12
+ import l1ContractsDefaultEnv from '@aztec/l1-artifacts/network-defaults.json' with { type: 'json' };
12
13
 
13
- import { l1ContractsDefaultEnv } from './generated/l1-contracts-defaults.js';
14
14
  import { type L1TxUtilsConfig, l1TxUtilsConfigMappings } from './l1_tx_utils/config.js';
15
15
 
16
16
  export type GenesisStateConfig = {
@@ -81,11 +81,21 @@ export type L1ContractsConfig = {
81
81
  initialEthPerFeeAsset: bigint;
82
82
  /** The number of seconds to wait for an exit */
83
83
  exitDelaySeconds: number;
84
+ /** Validator set size at or below which the entry queue uses the bootstrap flush size. */
85
+ entryQueueBootstrapValidatorSetSize: number;
86
+ /** Number of validators admitted from the entry queue per flush during the bootstrap phase. */
87
+ entryQueueBootstrapFlushSize: number;
88
+ /** Minimum number of validators admitted from the entry queue per flush. */
89
+ entryQueueFlushSizeMin: number;
90
+ /** Divisor applied to the queue size to derive the normal per-flush admission count. */
91
+ entryQueueFlushSizeQuotient: number;
92
+ /** Maximum number of validators admitted from the entry queue per flush. */
93
+ entryQueueMaxFlushSize: number;
84
94
  } & L1TxUtilsConfig;
85
95
 
86
96
  /**
87
97
  * Config mappings for L1ContractsConfig.
88
- * Default values come from generated l1-contracts-defaults.json (source: defaults.yml).
98
+ * Default values come from l1-contracts/scripts/network-defaults.json (published via @aztec/l1-artifacts).
89
99
  * Real deployments use forge scripts which require explicit env vars (vm.envUint).
90
100
  */
91
101
  export const l1ContractsConfigMappings: ConfigMappingsType<L1ContractsConfig> = {
@@ -237,15 +247,75 @@ export const l1ContractsConfigMappings: ConfigMappingsType<L1ContractsConfig> =
237
247
  description: 'The delay before a validator can exit the set',
238
248
  ...numberConfigHelper(l1ContractsDefaultEnv.AZTEC_EXIT_DELAY_SECONDS),
239
249
  },
250
+ entryQueueBootstrapValidatorSetSize: {
251
+ env: 'AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE',
252
+ description: 'Validator set size at or below which the entry queue uses the bootstrap flush size.',
253
+ ...numberConfigHelper(l1ContractsDefaultEnv.AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE),
254
+ },
255
+ entryQueueBootstrapFlushSize: {
256
+ env: 'AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE',
257
+ description: 'Number of validators admitted from the entry queue per flush during the bootstrap phase.',
258
+ ...numberConfigHelper(l1ContractsDefaultEnv.AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE),
259
+ },
260
+ entryQueueFlushSizeMin: {
261
+ env: 'AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN',
262
+ description: 'Minimum number of validators admitted from the entry queue per flush.',
263
+ ...numberConfigHelper(l1ContractsDefaultEnv.AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN),
264
+ },
265
+ entryQueueFlushSizeQuotient: {
266
+ env: 'AZTEC_ENTRY_QUEUE_FLUSH_SIZE_QUOTIENT',
267
+ description: 'Divisor applied to the queue size to derive the normal per-flush admission count.',
268
+ ...numberConfigHelper(l1ContractsDefaultEnv.AZTEC_ENTRY_QUEUE_FLUSH_SIZE_QUOTIENT),
269
+ },
270
+ entryQueueMaxFlushSize: {
271
+ env: 'AZTEC_ENTRY_QUEUE_MAX_FLUSH_SIZE',
272
+ description: 'Maximum number of validators admitted from the entry queue per flush.',
273
+ ...numberConfigHelper(l1ContractsDefaultEnv.AZTEC_ENTRY_QUEUE_MAX_FLUSH_SIZE),
274
+ },
240
275
  ...omitConfigMappings(l1TxUtilsConfigMappings, ['ethereumSlotDuration']),
241
276
  };
242
277
 
243
278
  /**
244
279
  * Default L1 contracts configuration derived from l1ContractsConfigMappings.
245
- * Source of truth: spartan/environments/defaults.yml -> defaults.l1-contracts
280
+ * Source of truth: l1-contracts/scripts/network-defaults.json (published via @aztec/l1-artifacts).
246
281
  */
247
282
  export const DefaultL1ContractsConfig = getDefaultConfig(l1ContractsConfigMappings);
248
283
 
284
+ /**
285
+ * Validates that `ethereumSlotDuration` and `aztecSlotDuration` are positive and that the L2 slot is an exact
286
+ * multiple of the L1 slot. Every L2 slot boundary must land on an L1 slot boundary; a non-multiple pairing
287
+ * desyncs the epoch/checkpoint timing math throughout the sequencer, validator client, and timetables. Returns
288
+ * a list of error messages (empty when valid).
289
+ */
290
+ export function validateSlotDurations(
291
+ config: Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'>,
292
+ ): string[] {
293
+ const errors: string[] = [];
294
+ if (config.ethereumSlotDuration <= 0) {
295
+ errors.push(`ethereumSlotDuration must be positive (got ${config.ethereumSlotDuration})`);
296
+ }
297
+ if (config.aztecSlotDuration <= 0) {
298
+ errors.push(`aztecSlotDuration must be positive (got ${config.aztecSlotDuration})`);
299
+ }
300
+ if (config.ethereumSlotDuration > 0 && config.aztecSlotDuration % config.ethereumSlotDuration !== 0) {
301
+ errors.push(
302
+ `aztecSlotDuration (${config.aztecSlotDuration}s) must be a multiple of ethereumSlotDuration ` +
303
+ `(${config.ethereumSlotDuration}s)`,
304
+ );
305
+ }
306
+ return errors;
307
+ }
308
+
309
+ /** Throws if {@link validateSlotDurations} reports any errors. */
310
+ export function assertValidSlotDurations(
311
+ config: Pick<L1ContractsConfig, 'ethereumSlotDuration' | 'aztecSlotDuration'>,
312
+ ): void {
313
+ const errors = validateSlotDurations(config);
314
+ if (errors.length > 0) {
315
+ throw new Error(`Invalid slot duration configuration:\n${errors.map(e => ` - ${e}`).join('\n')}`);
316
+ }
317
+ }
318
+
249
319
  export const genesisStateConfigMappings: ConfigMappingsType<GenesisStateConfig> = {
250
320
  testAccounts: {
251
321
  env: 'TEST_ACCOUNTS',
@@ -47,11 +47,16 @@ export class GSEContract {
47
47
  return EthAddress.fromString(await this.gse.read.getGovernance());
48
48
  }
49
49
 
50
- getAttestersFromIndicesAtTime(instance: Hex | EthAddress, ts: bigint, indices: bigint[]) {
50
+ getAttestersFromIndicesAtTime(
51
+ instance: Hex | EthAddress,
52
+ ts: bigint,
53
+ indices: bigint[],
54
+ options?: { blockNumber?: bigint },
55
+ ) {
51
56
  if (instance instanceof EthAddress) {
52
57
  instance = instance.toString();
53
58
  }
54
- return this.gse.read.getAttestersFromIndicesAtTime([instance, ts, indices]);
59
+ return this.gse.read.getAttestersFromIndicesAtTime([instance, ts, indices], options);
55
60
  }
56
61
 
57
62
  public async getRegistrationDigest(publicKey: ProjPointType<bigint>): Promise<ProjPointType<bigint>> {
@@ -14,7 +14,7 @@ import {
14
14
  multicall3Abi,
15
15
  } from 'viem';
16
16
 
17
- import type { L1BlobInputs, L1TxConfig, L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js';
17
+ import type { L1BlobInputs, L1TxConfig, L1TxRequest, L1TxState, L1TxUtils } from '../l1_tx_utils/index.js';
18
18
  import type { ExtendedViemWalletClient } from '../types.js';
19
19
  import { tryDecodeRevertReason } from '../utils.js';
20
20
 
@@ -26,7 +26,10 @@ export const MULTI_CALL_3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'
26
26
  * treat it as a fatal on-chain failure rather than retrying on a different publisher.
27
27
  */
28
28
  export class MulticallForwarderRevertedError extends Error {
29
- constructor(public readonly receipt: TransactionReceipt) {
29
+ constructor(
30
+ public readonly receipt: TransactionReceipt,
31
+ public readonly txState?: L1TxState,
32
+ ) {
30
33
  super(`Multicall3 forwarder tx reverted: ${receipt.transactionHash}`);
31
34
  this.name = 'MulticallForwarderRevertedError';
32
35
  }
@@ -209,7 +212,7 @@ export class Multicall3 {
209
212
  args: [args],
210
213
  });
211
214
 
212
- const { receipt } = await l1TxUtils.sendAndMonitorTransaction(
215
+ const { receipt, state } = await l1TxUtils.sendAndMonitorTransaction(
213
216
  {
214
217
  to: MULTI_CALL_3_ADDRESS,
215
218
  data: encodedForwarderData,
@@ -223,11 +226,11 @@ export class Multicall3 {
223
226
  // allowFailure to true for all calls, so a reverted status here would indicate a problem with
224
227
  // the Multicall3 contract itself or the forwarder transaction (such as an out-of-gas).
225
228
  if (receipt.status !== 'success') {
226
- throw new MulticallForwarderRevertedError(receipt);
229
+ throw new MulticallForwarderRevertedError(receipt, state);
227
230
  }
228
231
 
229
232
  const stats = await l1TxUtils.getTransactionStats(receipt.transactionHash);
230
- return { receipt, stats, multicallData: encodedForwarderData };
233
+ return { receipt, stats, multicallData: encodedForwarderData, state };
231
234
  }
232
235
 
233
236
  /** Batch multiple value transfers into a single aggregate3Value call on Multicall3. */
@@ -83,6 +83,7 @@ export type ViemHeader = {
83
83
  feeRecipient: `0x${string}`;
84
84
  gasFees: ViemGasFees;
85
85
  totalManaUsed: bigint;
86
+ accumulatedFees: bigint;
86
87
  };
87
88
 
88
89
  export type ViemGasFees = {
@@ -554,8 +555,9 @@ export class RollupContract {
554
555
  return EthAddress.fromString(await this.rollup.read.owner());
555
556
  }
556
557
 
557
- async getActiveAttesterCount(): Promise<number> {
558
- return Number(await this.rollup.read.getActiveAttesterCount());
558
+ async getActiveAttesterCount(options?: { blockNumber?: bigint }): Promise<number> {
559
+ await checkBlockTag(options?.blockNumber, this.client);
560
+ return Number(await this.rollup.read.getActiveAttesterCount(options));
559
561
  }
560
562
 
561
563
  public async getSlashingProposerAddress() {
@@ -812,7 +814,7 @@ export class RollupContract {
812
814
  }
813
815
 
814
816
  async getEpochProofPublicInputs(
815
- args: readonly [bigint, bigint, EpochProofPublicInputArgs, readonly `0x${string}`[], `0x${string}`],
817
+ args: readonly [bigint, bigint, EpochProofPublicInputArgs, readonly ViemHeader[], `0x${string}`],
816
818
  ): Promise<Fr[]> {
817
819
  const result = await this.rollup.read.getEpochProofPublicInputs(args);
818
820
  return result.map(Fr.fromString);
@@ -1190,13 +1192,20 @@ export class RollupContract {
1190
1192
  }
1191
1193
 
1192
1194
  async getAttesters(timestamp?: bigint): Promise<EthAddress[]> {
1193
- const attesterSize = await this.getActiveAttesterCount();
1195
+ // Pin every read to a single L1 block so the attester count and the chunked index reads
1196
+ // observe a consistent set. Without this, the count and each chunk default to `latest` and
1197
+ // can straddle a block boundary (or reorg), yielding an inconsistent or truncated set.
1198
+ const block = await this.client.getBlock();
1199
+ const blockNumber = block.number ?? undefined;
1200
+ const ts = timestamp ?? block.timestamp;
1201
+ const attesterSize = await this.getActiveAttesterCount({ blockNumber });
1194
1202
  const gse = new GSEContract(this.client, await this.getGSE());
1195
- const ts = timestamp ?? (await this.client.getBlock()).timestamp;
1196
1203
  const indices = Array.from({ length: attesterSize }, (_, i) => BigInt(i));
1197
1204
  const chunks = chunk(indices, 1000);
1198
1205
 
1199
- const results = await Promise.all(chunks.map(chunk => gse.getAttestersFromIndicesAtTime(this.address, ts, chunk)));
1206
+ const results = await Promise.all(
1207
+ chunks.map(chunk => gse.getAttestersFromIndicesAtTime(this.address, ts, chunk, { blockNumber })),
1208
+ );
1200
1209
  return results.flat().map(addr => EthAddress.fromString(addr));
1201
1210
  }
1202
1211
 
@@ -19,9 +19,10 @@ import { mainnet, sepolia } from 'viem/chains';
19
19
 
20
20
  import { createEthereumChain, isAnvilTestChain } from './chain.js';
21
21
  import { createExtendedL1Client } from './client.js';
22
- import type { L1ContractsConfig } from './config.js';
22
+ import { type L1ContractsConfig, assertValidSlotDurations } from './config.js';
23
23
  import { deployMulticall3 } from './contracts/multicall.js';
24
24
  import { RollupContract } from './contracts/rollup.js';
25
+ import { resolveFoundryBinary } from './foundry_binary.js';
25
26
  import type { L1ContractAddresses } from './l1_contract_addresses.js';
26
27
  import type { ExtendedViemWalletClient } from './types.js';
27
28
 
@@ -95,11 +96,16 @@ function runProcess<T>(
95
96
 
96
97
  // Covers an edge where where we may have a cached BlobLib that is not meant for production.
97
98
  // Despite the profile apparently sometimes cached code remains (so says Lasse after his ignition-monorepo arc).
98
- async function maybeForgeForceProductionBuild(l1ContractsPath: string, script: string, chainId: number) {
99
+ async function maybeForgeForceProductionBuild(
100
+ forgeBin: string,
101
+ l1ContractsPath: string,
102
+ script: string,
103
+ chainId: number,
104
+ ) {
99
105
  if (chainId === mainnet.id) {
100
106
  logger.info(`Recompiling ${script} with production profile for mainnet deployment`);
101
107
  logger.info('This may take a minute but ensures production BlobLib is used.');
102
- await runProcess('forge', ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
108
+ await runProcess(forgeBin, ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
103
109
  }
104
110
  }
105
111
 
@@ -282,6 +288,7 @@ export async function deployAztecL1Contracts(
282
288
  args: DeployAztecL1ContractsArgs,
283
289
  ): Promise<DeployAztecL1ContractsReturnType> {
284
290
  logger.info(`Deploying L1 contracts with config: ${jsonStringify(args)}`);
291
+ assertValidSlotDurations(args);
285
292
  if (args.initialValidators && args.initialValidators.length > 0 && args.existingTokenAddress) {
286
293
  throw new Error(
287
294
  'Cannot deploy with both initialValidators and existingTokenAddress. ' +
@@ -319,8 +326,9 @@ export async function deployAztecL1Contracts(
319
326
  // Use foundry-artifacts from l1-artifacts package
320
327
  const l1ContractsPath = prepareL1ContractsForDeployment();
321
328
 
329
+ const forgeBin = resolveFoundryBinary('forge');
322
330
  const FORGE_SCRIPT = 'script/deploy/DeployAztecL1Contracts.s.sol';
323
- await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
331
+ await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
324
332
 
325
333
  // Verify contracts on Etherscan when on mainnet/sepolia and ETHERSCAN_API_KEY is available.
326
334
  const isVerifiableChain = chainId === mainnet.id || chainId === sepolia.id;
@@ -345,6 +353,8 @@ export async function deployAztecL1Contracts(
345
353
  ...(shouldVerify ? ['--verify'] : []),
346
354
  ];
347
355
  const forgeEnv = {
356
+ // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
357
+ FORGE_BIN: forgeBin,
348
358
  // Env vars required by l1-contracts/script/deploy/DeploymentConfiguration.sol.
349
359
  NETWORK: getActiveNetworkName(),
350
360
  FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
@@ -589,6 +599,11 @@ export function getDeployRollupForUpgradeEnvVars(
589
599
  AZTEC_SLASH_AMOUNT_SMALL: args.slashAmountSmall.toString(),
590
600
  AZTEC_SLASH_AMOUNT_MEDIUM: args.slashAmountMedium.toString(),
591
601
  AZTEC_SLASH_AMOUNT_LARGE: args.slashAmountLarge.toString(),
602
+ AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE: args.entryQueueBootstrapValidatorSetSize.toString(),
603
+ AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE: args.entryQueueBootstrapFlushSize.toString(),
604
+ AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN: args.entryQueueFlushSizeMin.toString(),
605
+ AZTEC_ENTRY_QUEUE_FLUSH_SIZE_QUOTIENT: args.entryQueueFlushSizeQuotient.toString(),
606
+ AZTEC_ENTRY_QUEUE_MAX_FLUSH_SIZE: args.entryQueueMaxFlushSize.toString(),
592
607
  } as const;
593
608
  }
594
609
 
@@ -612,12 +627,15 @@ export const deployRollupForUpgrade = async (
612
627
  // Use foundry-artifacts from l1-artifacts package
613
628
  const l1ContractsPath = prepareL1ContractsForDeployment();
614
629
 
630
+ const forgeBin = resolveFoundryBinary('forge');
615
631
  const FORGE_SCRIPT = 'script/deploy/DeployRollupForUpgrade.s.sol';
616
- await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
632
+ await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
617
633
 
618
634
  const scriptPath = join(getL1ContractsPath(), 'scripts', 'forge_broadcast.js');
619
635
  const forgeArgs = [FORGE_SCRIPT, '--sig', 'run()', '--private-key', privateKey, '--rpc-url', rpcUrl];
620
636
  const forgeEnv = {
637
+ // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
638
+ FORGE_BIN: forgeBin,
621
639
  FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
622
640
  // Env vars required by l1-contracts/script/deploy/RollupConfiguration.sol.
623
641
  REGISTRY_ADDRESS: registryAddress.toString(),
@@ -0,0 +1,57 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { accessSync, constants } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ function isExecutable(path: string): boolean {
7
+ try {
8
+ accessSync(path, constants.X_OK);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Locate a Foundry binary (`anvil`, `forge`, ...) without relying on the caller's PATH. Order:
17
+ * 1. `$<NAME>_BIN` (e.g. `$ANVIL_BIN`, `$FORGE_BIN`) — explicit override, e.g. for CI with a pinned
18
+ * version. Throws if set but not pointing at an executable, instead of silently falling back.
19
+ * 2. `~/.aztec/current/internal-bin/<name>` — where aztec-up installs it.
20
+ * 3. `~/.aztec/current/bin/aztec-<name>` — the publicly-exposed symlink.
21
+ * 4. `~/.foundry/bin/<name>` — standalone foundryup install.
22
+ * 5. `command -v <name>` — anything else on PATH.
23
+ *
24
+ * Throws with a directive message if none work.
25
+ */
26
+ export function resolveFoundryBinary(name: string): string {
27
+ const envVar = `${name.toUpperCase()}_BIN`;
28
+ const envBin = process.env[envVar];
29
+ if (envBin) {
30
+ if (!isExecutable(envBin)) {
31
+ throw new Error(`$${envVar} is set to ${envBin}, which does not exist or is not executable.`);
32
+ }
33
+ return envBin;
34
+ }
35
+
36
+ const candidates = [
37
+ join(homedir(), '.aztec', 'current', 'internal-bin', name),
38
+ join(homedir(), '.aztec', 'current', 'bin', `aztec-${name}`),
39
+ join(homedir(), '.foundry', 'bin', name),
40
+ ];
41
+ for (const path of candidates) {
42
+ if (isExecutable(path)) {
43
+ return path;
44
+ }
45
+ }
46
+
47
+ const which = spawnSync('sh', ['-c', `command -v ${name}`], { encoding: 'utf8' });
48
+ if (which.status === 0 && which.stdout.trim()) {
49
+ return which.stdout.trim();
50
+ }
51
+
52
+ throw new Error(
53
+ `${name} binary not found. Tried $${envVar}, ~/.aztec/current/internal-bin/${name}, ` +
54
+ `~/.aztec/current/bin/aztec-${name}, ~/.foundry/bin/${name}, and $PATH. ` +
55
+ `Install via \`aztec-up\` or set ${envVar} to a working binary.`,
56
+ );
57
+ }
@@ -73,7 +73,7 @@ export const l1TxUtilsConfigMappings: ConfigMappingsType<L1TxUtilsConfig> = {
73
73
  gasLimitBufferPercentage: {
74
74
  description: 'How much to increase calculated gas limit by (percentage)',
75
75
  env: 'L1_GAS_LIMIT_BUFFER_PERCENTAGE',
76
- ...numberConfigHelper(20),
76
+ ...floatConfigHelper(20),
77
77
  },
78
78
  maxGwei: {
79
79
  description: 'Maximum gas price in gwei to be used for transactions.',
@@ -90,12 +90,12 @@ export const l1TxUtilsConfigMappings: ConfigMappingsType<L1TxUtilsConfig> = {
90
90
  priorityFeeBumpPercentage: {
91
91
  description: 'How much to increase priority fee by each attempt (percentage)',
92
92
  env: 'L1_PRIORITY_FEE_BUMP_PERCENTAGE',
93
- ...numberConfigHelper(20),
93
+ ...floatConfigHelper(20),
94
94
  },
95
95
  priorityFeeRetryBumpPercentage: {
96
96
  description: 'How much to increase priority fee by each retry attempt (percentage)',
97
97
  env: 'L1_PRIORITY_FEE_RETRY_BUMP_PERCENTAGE',
98
- ...numberConfigHelper(50),
98
+ ...floatConfigHelper(50),
99
99
  },
100
100
  minimumPriorityFeePerGas: {
101
101
  description:
@@ -791,9 +791,6 @@ export class L1FeeAnalyzer {
791
791
  });
792
792
  }
793
793
 
794
- /**
795
- * Gets the minimum value from an array of bigints
796
- */
797
794
  private minBigInt(values: bigint[]): bigint {
798
795
  if (values.length === 0) {
799
796
  return 0n;
@@ -801,3 +798,78 @@ export class L1FeeAnalyzer {
801
798
  return values.reduce((min, val) => (val < min ? val : min), values[0]);
802
799
  }
803
800
  }
801
+
802
+ /** Per-block fee data for one mined L1 block, used to diagnose whether a tx was underpriced for it. */
803
+ export interface WindowBlockFees {
804
+ blockNumber: bigint;
805
+ timestamp: bigint;
806
+ baseFeePerGas: bigint;
807
+ /** 75th percentile priority fee among the block's txs (gas-weighted, from eth_feeHistory). */
808
+ p75PriorityFee: bigint;
809
+ /** Minimum priority fee among all included txs — the inclusion bar for that block. */
810
+ minIncludedPriorityFee: bigint;
811
+ blockBlobsFull: boolean;
812
+ includedBlobCount: number;
813
+ }
814
+
815
+ /** Safety bound on how far back the window scan walks if the chain head is far ahead of the window. */
816
+ const MAX_WINDOW_SCAN_BLOCKS = 64;
817
+
818
+ /**
819
+ * Reads the already-mined L1 blocks whose timestamps fall in [windowStartS, windowEndS) — the L1
820
+ * inclusion window of an L2 slot — and extracts per-block fee data. Walks block headers to map the
821
+ * window to block numbers, then reads the priority-fee stats from a single eth_feeHistory call
822
+ * (percentile 0 is the cheapest included tx, i.e. the inclusion bar), so no transaction bodies are
823
+ * ever downloaded. Historical reads only, so it never waits on a future block. Returns the blocks in
824
+ * chronological order, [] on error, or the subset mined so far if the window is still in progress.
825
+ * Never throws.
826
+ */
827
+ export async function captureWindowBlockFees(
828
+ client: ViemClient,
829
+ windowStartS: bigint,
830
+ windowEndS: bigint,
831
+ ): Promise<WindowBlockFees[]> {
832
+ try {
833
+ let current = await client.getBlock({ blockTag: 'latest' });
834
+ const inWindow: (typeof current)[] = [];
835
+ let scanned = 0;
836
+ // Walk back from the head, collecting blocks inside the window and skipping any past its end,
837
+ // until we cross below the window start (or hit the safety cap / genesis).
838
+ while (current.timestamp >= windowStartS && scanned < MAX_WINDOW_SCAN_BLOCKS) {
839
+ if (current.timestamp < windowEndS) {
840
+ inWindow.push(current);
841
+ }
842
+ if (current.number === 0n) {
843
+ break;
844
+ }
845
+ current = await client.getBlock({ blockNumber: current.number - 1n });
846
+ scanned++;
847
+ }
848
+ if (inWindow.length === 0) {
849
+ return [];
850
+ }
851
+ inWindow.reverse();
852
+ const newest = inWindow[inWindow.length - 1];
853
+ const feeHistory = await client.getFeeHistory({
854
+ blockCount: Number(newest.number - inWindow[0].number) + 1,
855
+ blockNumber: newest.number,
856
+ rewardPercentiles: [0, 75],
857
+ });
858
+ return inWindow.map(block => {
859
+ const idx = Number(block.number - feeHistory.oldestBlock);
860
+ const [minIncludedPriorityFee, p75PriorityFee] = feeHistory.reward?.[idx] ?? [0n, 0n];
861
+ const blobsInBlock = block.blobGasUsed ? Number(block.blobGasUsed / GAS_PER_BLOB) : 0;
862
+ return {
863
+ blockNumber: block.number,
864
+ timestamp: block.timestamp,
865
+ baseFeePerGas: block.baseFeePerGas ?? 0n,
866
+ p75PriorityFee,
867
+ minIncludedPriorityFee,
868
+ blockBlobsFull: blobsInBlock >= getMaxBlobCapacity(block.timestamp),
869
+ includedBlobCount: blobsInBlock,
870
+ };
871
+ });
872
+ } catch {
873
+ return [];
874
+ }
875
+ }
@@ -5,7 +5,7 @@ import { InterruptError, TimeoutError } from '@aztec/foundation/error';
5
5
  import { EthAddress } from '@aztec/foundation/eth-address';
6
6
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
7
7
  import { Semaphore } from '@aztec/foundation/queue';
8
- import { retryUntil } from '@aztec/foundation/retry';
8
+ import { makeBackoff, retry, retryUntil } from '@aztec/foundation/retry';
9
9
  import { sleep } from '@aztec/foundation/sleep';
10
10
  import { DateProvider } from '@aztec/foundation/timer';
11
11
  import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi';
@@ -36,6 +36,7 @@ import {
36
36
  type L1TxConfig,
37
37
  type L1TxRequest,
38
38
  type L1TxState,
39
+ L1TxTimeoutError,
39
40
  type SigningCallback,
40
41
  TerminalTxUtilsState,
41
42
  TxUtilsState,
@@ -44,6 +45,11 @@ import {
44
45
 
45
46
  const MAX_L1_TX_STATES = 32;
46
47
 
48
+ // Backoff (in seconds) for retrying the read-only RPC calls that prepare a tx cancellation. A
49
+ // cancellation is fired in the background after a tx times out and is important (it frees the stuck
50
+ // nonce), so a transient RPC failure while reading the pending nonce or gas price must not abandon it.
51
+ const CANCELLATION_PREP_RETRY_BACKOFF_S = [1, 2, 4, 8];
52
+
47
53
  export class L1TxUtils extends ReadOnlyL1TxUtils {
48
54
  protected txs: L1TxState[] = [];
49
55
  /** Last nonce successfully sent to the chain. Used as a lower bound when a fallback RPC node returns a stale count. */
@@ -301,6 +307,7 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
301
307
  txConfigOverrides: gasConfigOverrides ?? {},
302
308
  sentAtL1Ts: now,
303
309
  lastSentAtL1Ts: now,
310
+ gasPriceHistory: [baseState.gasPrice],
304
311
  };
305
312
 
306
313
  // And persist it
@@ -431,10 +438,23 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
431
438
 
432
439
  const initialTxHash = txHashes[0];
433
440
  let currentTxHash = txHashes.at(-1)!;
434
- let l1Timestamp: number;
441
+ let l1Timestamp = 0;
435
442
 
436
443
  while (true) {
437
- l1Timestamp = await this.getL1Timestamp();
444
+ try {
445
+ l1Timestamp = await this.getL1Timestamp();
446
+ } catch (err) {
447
+ // A transient RPC failure here must not abort monitoring with a rejection: callers that
448
+ // fire this loop in the background would surface it as an unhandled rejection (e.g. when a
449
+ // test tears down its L1 node while a monitor iteration is in flight). Exit quietly if we
450
+ // are shutting down, otherwise retry on the next interval.
451
+ if (this.interrupted) {
452
+ break;
453
+ }
454
+ this.logger.error(`Error fetching L1 timestamp while monitoring tx ${currentTxHash}`, err, { nonce, account });
455
+ await sleep(gasConfig.checkIntervalMs!);
456
+ continue;
457
+ }
438
458
 
439
459
  try {
440
460
  const timePassed = l1Timestamp - state.lastSentAtL1Ts.getTime();
@@ -486,6 +506,7 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
486
506
  if (timePassed >= stallTimeMs && attempts <= maxSpeedUpAttempts) {
487
507
  const newGasPrice = await this.getGasPrice(gasConfig, isBlobTx, attempts, state.gasPrice);
488
508
  state.gasPrice = newGasPrice;
509
+ state.gasPriceHistory?.push(newGasPrice);
489
510
 
490
511
  this.logger.debug(
491
512
  `Tx ${currentTxHash} with nonce ${nonce} from ${account} appears stuck. ` +
@@ -654,8 +675,22 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
654
675
  blobInputs?: L1BlobInputs,
655
676
  ): Promise<{ receipt: TransactionReceipt; state: L1TxState }> {
656
677
  const { state } = await this.sendTransaction(request, gasConfig, blobInputs);
657
- const receipt = await this.monitorTransaction(state);
658
- return { receipt, state };
678
+ try {
679
+ const receipt = await this.monitorTransaction(state);
680
+ return { receipt, state };
681
+ } catch (err) {
682
+ if (err instanceof TimeoutError) {
683
+ // Snapshot the ladder now: the fire-and-forget cancellation mutates state.gasPrice moments later.
684
+ throw new L1TxTimeoutError(err.message, {
685
+ gasPriceHistory: state.gasPriceHistory ? [...state.gasPriceHistory] : undefined,
686
+ finalGasPrice: state.gasPrice,
687
+ attempts: state.txHashes.length,
688
+ nonce: state.nonce,
689
+ gasLimit: state.gasLimit,
690
+ });
691
+ }
692
+ throw err;
693
+ }
659
694
  }
660
695
 
661
696
  public override async simulate(
@@ -706,9 +741,37 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
706
741
  return;
707
742
  }
708
743
 
709
- // Check if the original tx is still pending
710
- const currentNonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' });
711
- if (currentNonce < nonce) {
744
+ // Resolve the pending nonce and cancellation gas price up front. These are read-only RPC calls, so
745
+ // they are safe to retry; a transient RPC failure here must not permanently abandon the cancellation
746
+ // (which would leave the nonce stuck and the tx state stranded short of CANCELLED). We retry with a
747
+ // real backoff before giving up, unlike getGasPrice's tight internal retry which can be exhausted by
748
+ // a brief node hiccup. The retried block performs no state-changing send, so retrying cannot
749
+ // double-send the cancellation tx.
750
+ const { currentNonce, cancelGasPrice } = await retry(
751
+ async () => {
752
+ const currentNonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' });
753
+ if (currentNonce >= nonce) {
754
+ // Get gas price with higher priority fee for cancellation
755
+ const cancelGasPrice = await this.getGasPrice(
756
+ {
757
+ ...this.config,
758
+ // Use high bump for cancellation to ensure it replaces the original tx
759
+ priorityFeeRetryBumpPercentage: 150, // 150% bump should be enough to replace any tx
760
+ },
761
+ isBlobTx,
762
+ state.txHashes.length,
763
+ previousGasPrice,
764
+ );
765
+ return { currentNonce, cancelGasPrice };
766
+ }
767
+ return { currentNonce, cancelGasPrice: undefined };
768
+ },
769
+ `Preparing cancellation for L1 tx from account ${account} with nonce ${nonce}`,
770
+ makeBackoff(CANCELLATION_PREP_RETRY_BACKOFF_S),
771
+ this.logger,
772
+ );
773
+
774
+ if (cancelGasPrice === undefined) {
712
775
  this.logger.verbose(
713
776
  `Not sending cancellation for L1 tx from account ${account} with nonce ${nonce} as it is dropped`,
714
777
  { nonce, account, currentNonce },
@@ -717,18 +780,6 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
717
780
  return;
718
781
  }
719
782
 
720
- // Get gas price with higher priority fee for cancellation
721
- const cancelGasPrice = await this.getGasPrice(
722
- {
723
- ...this.config,
724
- // Use high bump for cancellation to ensure it replaces the original tx
725
- priorityFeeRetryBumpPercentage: 150, // 150% bump should be enough to replace any tx
726
- },
727
- isBlobTx,
728
- state.txHashes.length,
729
- previousGasPrice,
730
- );
731
-
732
783
  const { maxFeePerGas, maxPriorityFeePerGas, maxFeePerBlobGas } = cancelGasPrice;
733
784
  this.logger.verbose(
734
785
  `Attempting to cancel L1 ${isBlobTx ? 'blob' : 'vanilla'} transaction from account ${account} with nonce ${nonce} after time out`,