@aztec/ethereum 0.0.1-commit.3100065 → 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 (47) hide show
  1. package/dest/config.d.ts +13 -3
  2. package/dest/config.d.ts.map +1 -1
  3. package/dest/config.js +30 -3
  4. package/dest/contracts/multicall.d.ts +5 -3
  5. package/dest/contracts/multicall.d.ts.map +1 -1
  6. package/dest/contracts/multicall.js +7 -5
  7. package/dest/deploy_aztec_l1_contracts.d.ts +15 -4
  8. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  9. package/dest/deploy_aztec_l1_contracts.js +24 -13
  10. package/dest/foundry_binary.d.ts +13 -0
  11. package/dest/foundry_binary.d.ts.map +1 -0
  12. package/dest/foundry_binary.js +52 -0
  13. package/dest/l1_tx_utils/l1_fee_analyzer.d.ts +23 -4
  14. package/dest/l1_tx_utils/l1_fee_analyzer.d.ts.map +1 -1
  15. package/dest/l1_tx_utils/l1_fee_analyzer.js +65 -3
  16. package/dest/l1_tx_utils/l1_tx_utils.d.ts +1 -1
  17. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  18. package/dest/l1_tx_utils/l1_tx_utils.js +27 -7
  19. package/dest/l1_tx_utils/types.d.ts +27 -1
  20. package/dest/l1_tx_utils/types.d.ts.map +1 -1
  21. package/dest/l1_tx_utils/types.js +10 -0
  22. package/dest/queries.d.ts +1 -1
  23. package/dest/queries.d.ts.map +1 -1
  24. package/dest/queries.js +7 -1
  25. package/dest/test/eth_cheat_codes.d.ts +7 -1
  26. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  27. package/dest/test/eth_cheat_codes.js +29 -4
  28. package/dest/test/start_anvil.d.ts +1 -1
  29. package/dest/test/start_anvil.d.ts.map +1 -1
  30. package/dest/test/start_anvil.js +41 -5
  31. package/dest/utils.js +8 -11
  32. package/package.json +5 -6
  33. package/src/config.ts +38 -3
  34. package/src/contracts/multicall.ts +8 -5
  35. package/src/deploy_aztec_l1_contracts.ts +29 -12
  36. package/src/foundry_binary.ts +57 -0
  37. package/src/l1_tx_utils/l1_fee_analyzer.ts +75 -3
  38. package/src/l1_tx_utils/l1_tx_utils.ts +19 -2
  39. package/src/l1_tx_utils/types.ts +32 -0
  40. package/src/queries.ts +6 -0
  41. package/src/test/eth_cheat_codes.ts +23 -2
  42. package/src/test/start_anvil.ts +38 -6
  43. package/src/utils.ts +11 -11
  44. package/dest/generated/l1-contracts-defaults.d.ts +0 -30
  45. package/dest/generated/l1-contracts-defaults.d.ts.map +0 -1
  46. package/dest/generated/l1-contracts-defaults.js +0 -30
  47. 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,12 +247,37 @@ 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
 
@@ -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. */
@@ -5,12 +5,12 @@ import { jsonStringify } from '@aztec/foundation/json-rpc';
5
5
  import { createLogger } from '@aztec/foundation/log';
6
6
  import { promiseWithResolvers } from '@aztec/foundation/promise';
7
7
  import type { Fr } from '@aztec/foundation/schemas';
8
- import { fileURLToPath } from '@aztec/foundation/url';
9
8
 
10
9
  import { bn254 } from '@noble/curves/bn254';
11
10
  import type { Abi, Narrow } from 'abitype';
12
11
  import { spawn } from 'child_process';
13
12
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
13
+ import { createRequire } from 'node:module';
14
14
  import { tmpdir } from 'os';
15
15
  import { dirname, join, resolve } from 'path';
16
16
  import readline from 'readline';
@@ -22,11 +22,14 @@ import { createExtendedL1Client } from './client.js';
22
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
 
28
29
  const logger = createLogger('ethereum:deploy_aztec_l1_contracts');
29
30
 
31
+ const require = createRequire(import.meta.url);
32
+
30
33
  const JSON_DEPLOY_RESULT_PREFIX = 'JSON DEPLOY RESULT:';
31
34
 
32
35
  /**
@@ -93,11 +96,16 @@ function runProcess<T>(
93
96
 
94
97
  // Covers an edge where where we may have a cached BlobLib that is not meant for production.
95
98
  // Despite the profile apparently sometimes cached code remains (so says Lasse after his ignition-monorepo arc).
96
- 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
+ ) {
97
105
  if (chainId === mainnet.id) {
98
106
  logger.info(`Recompiling ${script} with production profile for mainnet deployment`);
99
107
  logger.info('This may take a minute but ensures production BlobLib is used.');
100
- await runProcess('forge', ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
108
+ await runProcess(forgeBin, ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
101
109
  }
102
110
  }
103
111
 
@@ -123,15 +131,13 @@ export interface ValidatorJson {
123
131
  }
124
132
 
125
133
  /**
126
- * Gets the path to the l1-contracts foundry artifacts directory.
127
- * These are copied from l1-contracts to yarn-project/l1-artifacts/l1-contracts
128
- * during build to make yarn-project self-contained.
134
+ * Gets the path to the l1-contracts foundry artifacts directory bundled inside @aztec/l1-artifacts.
135
+ * Resolved through the package (its "." export -> dest/index.js) so it works whether the package is
136
+ * linked via portal (monorepo) or installed under node_modules (published npm) — resolution follows
137
+ * the symlink in the portal case. The bundled foundry subtree sits alongside dest/, at <pkg>/l1-contracts.
129
138
  */
130
139
  export function getL1ContractsPath(): string {
131
- const currentDir = dirname(fileURLToPath(import.meta.url));
132
- // Go up from yarn-project/ethereum/dest to yarn-project, then to l1-artifacts/l1-contracts
133
- const l1ContractsPath = resolve(currentDir, '..', '..', 'l1-artifacts', 'l1-contracts');
134
- return l1ContractsPath;
140
+ return resolve(dirname(require.resolve('@aztec/l1-artifacts')), '..', 'l1-contracts');
135
141
  }
136
142
 
137
143
  // Cached deployment directory
@@ -320,8 +326,9 @@ export async function deployAztecL1Contracts(
320
326
  // Use foundry-artifacts from l1-artifacts package
321
327
  const l1ContractsPath = prepareL1ContractsForDeployment();
322
328
 
329
+ const forgeBin = resolveFoundryBinary('forge');
323
330
  const FORGE_SCRIPT = 'script/deploy/DeployAztecL1Contracts.s.sol';
324
- await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
331
+ await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
325
332
 
326
333
  // Verify contracts on Etherscan when on mainnet/sepolia and ETHERSCAN_API_KEY is available.
327
334
  const isVerifiableChain = chainId === mainnet.id || chainId === sepolia.id;
@@ -346,6 +353,8 @@ export async function deployAztecL1Contracts(
346
353
  ...(shouldVerify ? ['--verify'] : []),
347
354
  ];
348
355
  const forgeEnv = {
356
+ // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
357
+ FORGE_BIN: forgeBin,
349
358
  // Env vars required by l1-contracts/script/deploy/DeploymentConfiguration.sol.
350
359
  NETWORK: getActiveNetworkName(),
351
360
  FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
@@ -590,6 +599,11 @@ export function getDeployRollupForUpgradeEnvVars(
590
599
  AZTEC_SLASH_AMOUNT_SMALL: args.slashAmountSmall.toString(),
591
600
  AZTEC_SLASH_AMOUNT_MEDIUM: args.slashAmountMedium.toString(),
592
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(),
593
607
  } as const;
594
608
  }
595
609
 
@@ -613,12 +627,15 @@ export const deployRollupForUpgrade = async (
613
627
  // Use foundry-artifacts from l1-artifacts package
614
628
  const l1ContractsPath = prepareL1ContractsForDeployment();
615
629
 
630
+ const forgeBin = resolveFoundryBinary('forge');
616
631
  const FORGE_SCRIPT = 'script/deploy/DeployRollupForUpgrade.s.sol';
617
- await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
632
+ await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
618
633
 
619
634
  const scriptPath = join(getL1ContractsPath(), 'scripts', 'forge_broadcast.js');
620
635
  const forgeArgs = [FORGE_SCRIPT, '--sig', 'run()', '--private-key', privateKey, '--rpc-url', rpcUrl];
621
636
  const forgeEnv = {
637
+ // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
638
+ FORGE_BIN: forgeBin,
622
639
  FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
623
640
  // Env vars required by l1-contracts/script/deploy/RollupConfiguration.sol.
624
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
+ }
@@ -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
+ }
@@ -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,
@@ -306,6 +307,7 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
306
307
  txConfigOverrides: gasConfigOverrides ?? {},
307
308
  sentAtL1Ts: now,
308
309
  lastSentAtL1Ts: now,
310
+ gasPriceHistory: [baseState.gasPrice],
309
311
  };
310
312
 
311
313
  // And persist it
@@ -504,6 +506,7 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
504
506
  if (timePassed >= stallTimeMs && attempts <= maxSpeedUpAttempts) {
505
507
  const newGasPrice = await this.getGasPrice(gasConfig, isBlobTx, attempts, state.gasPrice);
506
508
  state.gasPrice = newGasPrice;
509
+ state.gasPriceHistory?.push(newGasPrice);
507
510
 
508
511
  this.logger.debug(
509
512
  `Tx ${currentTxHash} with nonce ${nonce} from ${account} appears stuck. ` +
@@ -672,8 +675,22 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
672
675
  blobInputs?: L1BlobInputs,
673
676
  ): Promise<{ receipt: TransactionReceipt; state: L1TxState }> {
674
677
  const { state } = await this.sendTransaction(request, gasConfig, blobInputs);
675
- const receipt = await this.monitorTransaction(state);
676
- 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
+ }
677
694
  }
678
695
 
679
696
  public override async simulate(
@@ -1,4 +1,5 @@
1
1
  import type { BlobKzgInstance } from '@aztec/blob-lib/types';
2
+ import { TimeoutError } from '@aztec/foundation/error';
2
3
  import { EthAddress } from '@aztec/foundation/eth-address';
3
4
  import type { ViemTransactionSignature } from '@aztec/foundation/eth-signature';
4
5
 
@@ -55,6 +56,12 @@ export type L1TxState = {
55
56
  cancelTxHashes: Hex[];
56
57
  gasLimit: bigint;
57
58
  gasPrice: GasPrice;
59
+ /**
60
+ * Prices used for each attempt (initial send followed by each speed-up), in order. Always set on
61
+ * newly sent txs; optional because states restored from the state store predate the field.
62
+ * In-memory only — not persisted by the state store.
63
+ */
64
+ gasPriceHistory?: GasPrice[];
58
65
  txConfigOverrides: L1TxConfig;
59
66
  request: L1TxRequest;
60
67
  status: TxUtilsState;
@@ -83,3 +90,28 @@ export class DroppedTransactionError extends Error {
83
90
  this.name = 'DroppedTransactionError';
84
91
  }
85
92
  }
93
+
94
+ /** Snapshot of what a timed-out L1 tx tried to pay, taken when the timeout is raised. */
95
+ export type TimedOutTxState = {
96
+ /** Prices used across the initial send and each speed-up, in order (undefined only for restored states). */
97
+ gasPriceHistory?: GasPrice[];
98
+ /** The last price the tx was sent at before timing out. */
99
+ finalGasPrice: GasPrice;
100
+ /** Number of send attempts (initial + speed-ups). */
101
+ attempts: number;
102
+ nonce: number;
103
+ gasLimit: bigint;
104
+ };
105
+
106
+ /**
107
+ * Thrown by sendAndMonitorTransaction when a tx times out. Subclasses TimeoutError so existing
108
+ * `instanceof TimeoutError` checks keep working, while carrying the gas-price ladder for diagnostics.
109
+ */
110
+ export class L1TxTimeoutError extends TimeoutError {
111
+ constructor(
112
+ message: string,
113
+ public readonly txState: TimedOutTxState,
114
+ ) {
115
+ super(message);
116
+ }
117
+ }
package/src/queries.ts CHANGED
@@ -186,5 +186,11 @@ export async function getL1ContractsConfig(
186
186
  slashAmountMedium: slashingAmounts[1],
187
187
  slashAmountLarge: slashingAmounts[2],
188
188
  initialEthPerFeeAsset: DefaultL1ContractsConfig.initialEthPerFeeAsset,
189
+ // Not exposed by the rollup contract; fall back to defaults like the other non-on-chain fields above.
190
+ entryQueueBootstrapValidatorSetSize: DefaultL1ContractsConfig.entryQueueBootstrapValidatorSetSize,
191
+ entryQueueBootstrapFlushSize: DefaultL1ContractsConfig.entryQueueBootstrapFlushSize,
192
+ entryQueueFlushSizeMin: DefaultL1ContractsConfig.entryQueueFlushSizeMin,
193
+ entryQueueFlushSizeQuotient: DefaultL1ContractsConfig.entryQueueFlushSizeQuotient,
194
+ entryQueueMaxFlushSize: DefaultL1ContractsConfig.entryQueueMaxFlushSize,
189
195
  };
190
196
  }
@@ -505,19 +505,40 @@ export class EthCheatCodes {
505
505
  * reorg is needed because anvil applies a new gas limit only to future blocks: without it the just-mined
506
506
  * blocks would keep the tiny gas limit, and an `eth_call` against `latest` (whose gas is capped by the
507
507
  * block gas limit) would revert with "intrinsic gas too high".
508
+ *
509
+ * The replacement blocks advance L1 time. `anvil_reorg` stamps each replacement as `parent + N*interval`,
510
+ * so on its own it would freeze L1 time at the parent timestamp; callers that measure elapsed L1 time
511
+ * (L1TxUtils stall/timeout detection, `syncDateProvider`-driven slot advancement) rely on it moving
512
+ * forward. We reproduce the wall-clock advance the mine step just made (at least 1s per block) via a
513
+ * temporary block-timestamp interval, then restore any pre-existing interval.
508
514
  */
509
515
  public async mineEmptyBlock(blockCount: number = 1): Promise<void> {
510
516
  await this.execWithPausedAnvil(async () => {
511
517
  const originalGasLimit = await this.getBlockGasLimit();
518
+ const parentTimestamp = await this.lastBlockTimestamp();
519
+ // anvil has no getter for the block-timestamp interval, but the pending block is stamped
520
+ // `latest + interval`, so this delta is the standing interval (0 when none is set).
521
+ const priorInterval = (await this.nextBlockTimestamp()) - parentTimestamp;
512
522
  try {
513
523
  await this.setBlockGasLimit(1n);
514
524
  await this.doMine(blockCount);
515
525
  } finally {
516
526
  await this.setBlockGasLimit(originalGasLimit);
517
527
  }
528
+ const advance = (await this.lastBlockTimestamp()) - parentTimestamp;
529
+ const perBlockInterval = Math.max(1, Math.floor(advance / blockCount));
518
530
  // Replace the tiny-gas-limit blocks with empty blocks at the restored gas limit, keeping the same
519
- // height and timestamps. The reorged-out blocks held no transactions, so nothing returns to the pool.
520
- await this.doRpcCall('anvil_reorg', [blockCount, []]);
531
+ // height. The reorged-out blocks held no transactions, so nothing returns to the pool.
532
+ await this.doRpcCall('anvil_setBlockTimestampInterval', [perBlockInterval]);
533
+ try {
534
+ await this.doRpcCall('anvil_reorg', [blockCount, []]);
535
+ } finally {
536
+ if (priorInterval > 0) {
537
+ await this.doRpcCall('anvil_setBlockTimestampInterval', [priorInterval]);
538
+ } else {
539
+ await this.doRpcCall('anvil_removeBlockTimestampInterval', []);
540
+ }
541
+ }
521
542
  });
522
543
 
523
544
  this.logger.warn(`Mined ${blockCount} empty L1 ${pluralize('block', blockCount)}`);
@@ -1,10 +1,10 @@
1
1
  import { createLogger } from '@aztec/foundation/log';
2
2
  import { makeBackoff, retry } from '@aztec/foundation/retry';
3
3
  import type { TestDateProvider } from '@aztec/foundation/timer';
4
- import { fileURLToPath } from '@aztec/foundation/url';
5
4
 
6
5
  import { type ChildProcess, spawn } from 'child_process';
7
- import { dirname, resolve } from 'path';
6
+
7
+ import { resolveFoundryBinary } from '../foundry_binary.js';
8
8
 
9
9
  /** Minimal interface matching the @viem/anvil Anvil shape used by callers. */
10
10
  export interface Anvil {
@@ -14,6 +14,30 @@ export interface Anvil {
14
14
  stop(): Promise<void>;
15
15
  }
16
16
 
17
+ // Watchdog wrapper: instead of spawning anvil directly, we spawn a small bash supervisor that runs
18
+ // anvil as a background child and polls its own parent (this node process). If the parent dies for
19
+ // ANY reason — including SIGKILL / crash / OOM, where node's own exit handlers never run — the poll
20
+ // loop ends and the EXIT trap reaps anvil. The script is inlined (rather than shipped as a `.sh`) so
21
+ // it works from the published npm tarball too, and the resolved anvil binary is passed via
22
+ // `$ANVIL_BIN` so it works without `anvil` on PATH.
23
+ //
24
+ // `$@` is the anvil argv; `bash -c <script> bash <...args>` puts the args in `$@` and `$0` = 'bash'.
25
+ //
26
+ // The EXIT trap reaps anvil; INT/TERM just `exit` (which fires the EXIT trap) so a signal terminates
27
+ // the supervisor promptly instead of being swallowed — a trapped TERM does NOT terminate the shell,
28
+ // so trapping the kill directly on TERM would leave the poll loop running and the caller's teardown
29
+ // hanging until its SIGKILL escalation. `sleep & wait` makes the poll interruptible, so INT/TERM are
30
+ // handled immediately rather than after the current `sleep` returns.
31
+ const ANVIL_WATCHDOG = `
32
+ set -u
33
+ parent=$PPID
34
+ "$ANVIL_BIN" "$@" &
35
+ anvil_pid=$!
36
+ trap 'kill "$anvil_pid" 2>/dev/null' EXIT
37
+ trap 'exit 0' INT TERM
38
+ while kill -0 "$parent" 2>/dev/null; do sleep 1 & wait $!; done
39
+ `;
40
+
17
41
  /**
18
42
  * Ensures there's a running Anvil instance and returns the RPC URL.
19
43
  */
@@ -42,7 +66,7 @@ export async function startAnvil(
42
66
  dateProvider?: TestDateProvider;
43
67
  } = {},
44
68
  ): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise<void> }> {
45
- const anvilBinary = resolve(dirname(fileURLToPath(import.meta.url)), '../../', 'scripts/anvil_kill_wrapper.sh');
69
+ const anvilBinary = resolveFoundryBinary('anvil');
46
70
  const logger = opts.log ? createLogger('ethereum:anvil') : undefined;
47
71
  const methodCalls = opts.captureMethodCalls ? ([] as string[]) : undefined;
48
72
 
@@ -50,6 +74,9 @@ export async function startAnvil(
50
74
 
51
75
  const anvil = await retry(
52
76
  async () => {
77
+ // `--port 0` lets anvil bind an OS-assigned ephemeral port; the actual port is read back from
78
+ // its "Listening on host:port" stdout below, so independent suites can spawn their own anvil
79
+ // in parallel without fighting over a fixed port.
53
80
  const port = opts.port ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : 8545);
54
81
  const args: string[] = [
55
82
  '--host',
@@ -71,9 +98,11 @@ export async function startAnvil(
71
98
  }
72
99
  args.push('--slots-in-an-epoch', String(opts.slotsInAnEpoch ?? 1));
73
100
 
74
- const child = spawn(anvilBinary, args, {
101
+ // Spawn the watchdog (see ANVIL_WATCHDOG). It launches anvil with these args and reaps it if we
102
+ // die; `$0` is 'bash' and `$@` is the anvil argv.
103
+ const child = spawn('bash', ['-c', ANVIL_WATCHDOG, 'bash', ...args], {
75
104
  stdio: ['ignore', 'pipe', 'pipe'],
76
- env: { ...process.env, RAYON_NUM_THREADS: '1' },
105
+ env: { ...process.env, ANVIL_BIN: anvilBinary, RAYON_NUM_THREADS: '1' },
77
106
  });
78
107
 
79
108
  // Wait for "Listening on" or an early exit.
@@ -183,7 +212,10 @@ function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDatePro
183
212
  }
184
213
  }
185
214
 
186
- /** Send SIGTERM, wait up to 5 s, then SIGKILL. All timers are always cleared. */
215
+ /**
216
+ * Send SIGTERM to the watchdog, wait up to 5 s, then SIGKILL. The watchdog's trap forwards the
217
+ * signal to anvil, so terminating it tears down anvil too. All timers are always cleared.
218
+ */
187
219
  function killChild(child: ChildProcess): Promise<void> {
188
220
  return new Promise<void>(resolve => {
189
221
  if (child.exitCode !== null || child.killed) {
package/src/utils.ts CHANGED
@@ -235,18 +235,18 @@ export function formatViemError(error: any, abi: Abi = ErrorsAbi): FormattedViem
235
235
  // If decoding fails, we fall back to the original formatting
236
236
  }
237
237
 
238
- // Strip ABI from the error object before formatting. We clone first to avoid mutating the
239
- // caller's error, but structuredClone throws DataCloneError on values it cannot clone (e.g.
240
- // viem RPC errors carrying function-valued request context). If cloning fails, fall back to
241
- // formatting the original error untouched rather than letting the clone failure mask it.
238
+ // Strip ABI from the error object before formatting
242
239
  if (error && typeof error === 'object') {
243
- try {
244
- const errorClone = structuredClone(error);
245
- stripAbis(errorClone);
246
- error = errorClone;
247
- } catch {
248
- // Leave `error` as the original; we skip stripAbis to avoid mutating the caller's object.
249
- }
240
+ // Create a clone to avoid modifying the original
241
+ const errorClone = structuredClone(error);
242
+
243
+ // Helper function to recursively remove ABI properties
244
+
245
+ // Strip ABIs from the clone
246
+ stripAbis(errorClone);
247
+
248
+ // Use the cleaned clone for further processing
249
+ error = errorClone;
250
250
  }
251
251
 
252
252
  // If it's a regular Error instance, return it with its message