@aztec-labs/ethereum 6.0.0-rc.3 → 6.0.0

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 (114) hide show
  1. package/dest/blob_tx.d.ts +24 -0
  2. package/dest/blob_tx.d.ts.map +1 -0
  3. package/dest/blob_tx.js +94 -0
  4. package/dest/bn254/hash_to_point.d.ts +32 -0
  5. package/dest/bn254/hash_to_point.d.ts.map +1 -0
  6. package/dest/bn254/hash_to_point.js +80 -0
  7. package/dest/bn254/index.d.ts +3 -0
  8. package/dest/bn254/index.d.ts.map +1 -0
  9. package/dest/bn254/index.js +2 -0
  10. package/dest/bn254/proof_of_possession_gas.d.ts +29 -0
  11. package/dest/bn254/proof_of_possession_gas.d.ts.map +1 -0
  12. package/dest/bn254/proof_of_possession_gas.js +24 -0
  13. package/dest/client.d.ts +1 -1
  14. package/dest/client.d.ts.map +1 -1
  15. package/dest/client.js +5 -3
  16. package/dest/contracts/errors.d.ts +10 -3
  17. package/dest/contracts/errors.d.ts.map +1 -1
  18. package/dest/contracts/errors.js +10 -4
  19. package/dest/contracts/inbox.d.ts +1 -1
  20. package/dest/contracts/inbox.d.ts.map +1 -1
  21. package/dest/contracts/inbox.js +2 -0
  22. package/dest/contracts/log.d.ts +7 -1
  23. package/dest/contracts/log.d.ts.map +1 -1
  24. package/dest/contracts/log.js +6 -0
  25. package/dest/contracts/rollup.d.ts +32 -4
  26. package/dest/contracts/rollup.d.ts.map +1 -1
  27. package/dest/contracts/rollup.js +63 -7
  28. package/dest/deploy_aztec_l1_contracts.d.ts +16 -1
  29. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  30. package/dest/deploy_aztec_l1_contracts.js +201 -65
  31. package/dest/foundry_binary.d.ts +1 -1
  32. package/dest/foundry_binary.d.ts.map +1 -1
  33. package/dest/foundry_binary.js +2 -1
  34. package/dest/l1_tx_utils/amsterdam.d.ts +1 -1
  35. package/dest/l1_tx_utils/amsterdam.d.ts.map +1 -1
  36. package/dest/l1_tx_utils/amsterdam.js +4 -1
  37. package/dest/l1_tx_utils/constants.d.ts +2 -1
  38. package/dest/l1_tx_utils/constants.d.ts.map +1 -1
  39. package/dest/l1_tx_utils/constants.js +3 -0
  40. package/dest/l1_tx_utils/fee-strategies/p75_competitive.d.ts +1 -1
  41. package/dest/l1_tx_utils/fee-strategies/p75_competitive.d.ts.map +1 -1
  42. package/dest/l1_tx_utils/fee-strategies/p75_competitive.js +6 -1
  43. package/dest/l1_tx_utils/index.d.ts +2 -1
  44. package/dest/l1_tx_utils/index.d.ts.map +1 -1
  45. package/dest/l1_tx_utils/index.js +1 -0
  46. package/dest/l1_tx_utils/l1_tx_utils.d.ts +21 -1
  47. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  48. package/dest/l1_tx_utils/l1_tx_utils.js +71 -5
  49. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +8 -1
  50. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
  51. package/dest/l1_tx_utils/readonly_l1_tx_utils.js +1 -1
  52. package/dest/l1_tx_utils/send_cost.d.ts +15 -0
  53. package/dest/l1_tx_utils/send_cost.d.ts.map +1 -0
  54. package/dest/l1_tx_utils/send_cost.js +10 -0
  55. package/dest/l1_tx_utils/signer.d.ts +2 -2
  56. package/dest/l1_tx_utils/signer.d.ts.map +1 -1
  57. package/dest/l1_tx_utils/signer.js +2 -2
  58. package/dest/l1_tx_utils/tx_delayer.d.ts +1 -1
  59. package/dest/l1_tx_utils/tx_delayer.d.ts.map +1 -1
  60. package/dest/l1_tx_utils/tx_delayer.js +8 -26
  61. package/dest/publisher_manager.d.ts +127 -13
  62. package/dest/publisher_manager.d.ts.map +1 -1
  63. package/dest/publisher_manager.js +320 -79
  64. package/dest/test/anvil_hardfork.d.ts +42 -0
  65. package/dest/test/anvil_hardfork.d.ts.map +1 -0
  66. package/dest/test/anvil_hardfork.js +107 -0
  67. package/dest/test/eth_cheat_codes.d.ts +30 -5
  68. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  69. package/dest/test/eth_cheat_codes.js +283 -101
  70. package/dest/test/index.d.ts +3 -1
  71. package/dest/test/index.d.ts.map +1 -1
  72. package/dest/test/index.js +2 -0
  73. package/dest/test/l1_hardfork.d.ts +43 -0
  74. package/dest/test/l1_hardfork.d.ts.map +1 -0
  75. package/dest/test/l1_hardfork.js +51 -0
  76. package/dest/test/recording_rpc_server.d.ts +1 -1
  77. package/dest/test/recording_rpc_server.d.ts.map +1 -1
  78. package/dest/test/recording_rpc_server.js +1 -0
  79. package/dest/test/rollup_cheat_codes.d.ts +1 -1
  80. package/dest/test/rollup_cheat_codes.d.ts.map +1 -1
  81. package/dest/test/rollup_cheat_codes.js +7 -7
  82. package/dest/test/start_anvil.d.ts +14 -2
  83. package/dest/test/start_anvil.d.ts.map +1 -1
  84. package/dest/test/start_anvil.js +28 -11
  85. package/package.json +13 -11
  86. package/src/blob_tx.ts +118 -0
  87. package/src/bn254/hash_to_point.ts +95 -0
  88. package/src/bn254/index.ts +2 -0
  89. package/src/bn254/proof_of_possession_gas.ts +54 -0
  90. package/src/client.ts +6 -3
  91. package/src/contracts/errors.ts +12 -4
  92. package/src/contracts/inbox.ts +4 -0
  93. package/src/contracts/log.ts +12 -0
  94. package/src/contracts/rollup.ts +63 -6
  95. package/src/deploy_aztec_l1_contracts.ts +238 -63
  96. package/src/foundry_binary.ts +2 -1
  97. package/src/l1_tx_utils/amsterdam.ts +4 -1
  98. package/src/l1_tx_utils/constants.ts +4 -0
  99. package/src/l1_tx_utils/fee-strategies/p75_competitive.ts +11 -1
  100. package/src/l1_tx_utils/index.ts +1 -0
  101. package/src/l1_tx_utils/l1_tx_utils.ts +72 -5
  102. package/src/l1_tx_utils/readonly_l1_tx_utils.ts +1 -1
  103. package/src/l1_tx_utils/send_cost.ts +21 -0
  104. package/src/l1_tx_utils/signer.ts +3 -2
  105. package/src/l1_tx_utils/tx_delayer.ts +8 -37
  106. package/src/publisher_manager.ts +458 -83
  107. package/src/test/anvil_hardfork.ts +95 -0
  108. package/src/test/eth_cheat_codes.ts +306 -81
  109. package/src/test/fixtures/sepolia_blob_tx.json +10 -0
  110. package/src/test/index.ts +2 -0
  111. package/src/test/l1_hardfork.ts +71 -0
  112. package/src/test/recording_rpc_server.ts +1 -0
  113. package/src/test/rollup_cheat_codes.ts +9 -9
  114. package/src/test/start_anvil.ts +41 -13
@@ -1,17 +1,30 @@
1
1
  import { SlotNumber } from '@aztec-labs/foundation/branded-types';
2
2
  import { SecretValue, getActiveNetworkName } from '@aztec-labs/foundation/config';
3
+ import { getEnv } from '@aztec-labs/foundation/config/env';
3
4
  import { EthAddress } from '@aztec-labs/foundation/eth-address';
4
5
  import { jsonStringify } from '@aztec-labs/foundation/json-rpc';
5
6
  import { createLogger } from '@aztec-labs/foundation/log';
6
7
  import { promiseWithResolvers } from '@aztec-labs/foundation/promise';
7
8
  import type { Fr } from '@aztec-labs/foundation/schemas';
9
+ import { parse as parseToml } from '@iarna/toml';
8
10
  import { bn254 } from '@noble/curves/bn254';
9
11
  import type { Abi, Narrow } from 'abitype';
10
- import { spawn } from 'child_process';
11
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
12
+ import { execFileSync, spawn } from 'child_process';
13
+ import {
14
+ cpSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ readFileSync,
19
+ realpathSync,
20
+ rmSync,
21
+ statSync,
22
+ utimesSync,
23
+ writeFileSync,
24
+ } from 'fs';
12
25
  import { createRequire } from 'node:module';
13
26
  import { tmpdir } from 'os';
14
- import { basename, dirname, join, resolve } from 'path';
27
+ import { basename, dirname, isAbsolute, join, resolve } from 'path';
15
28
  import readline from 'readline';
16
29
  import type { Hex } from 'viem';
17
30
  import { mainnet, sepolia } from 'viem/chains';
@@ -32,27 +45,96 @@ const require = createRequire(import.meta.url);
32
45
 
33
46
  const JSON_DEPLOY_RESULT_PREFIX = 'JSON DEPLOY RESULT:';
34
47
 
48
+ /**
49
+ * Extends a broadcast deadline for a `--slow` broadcast, which waits for each transaction's receipt before sending the
50
+ * next, so on a chain that mines on an interval it needs at least one block per transaction.
51
+ */
52
+ export function getSlowBroadcastTimeout(timeout: number | undefined, txCount: number): number | undefined {
53
+ return timeout === undefined ? undefined : timeout + txCount * SLOW_BROADCAST_ALLOWANCE_PER_TX_MS;
54
+ }
55
+
56
+ /** One mainnet slot per transaction, which also covers the slowest anvil block intervals the tests use. */
57
+ const SLOW_BROADCAST_ALLOWANCE_PER_TX_MS = 12_000;
58
+
59
+ /** Returns the broadcast deadline, with an optional environment override in milliseconds. */
60
+ export function getForgeBroadcastTimeout(
61
+ chainId: number,
62
+ value = getEnv().FORGE_BROADCAST_TIMEOUT_MS,
63
+ ): number | undefined {
64
+ if (value === undefined || value === '') {
65
+ return isAnvilTestChain(chainId) ? 120_000 : undefined;
66
+ }
67
+ const timeout = Number(value);
68
+ if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 2_147_483_647) {
69
+ throw new Error('FORGE_BROADCAST_TIMEOUT_MS must be an integer between 0 and 2147483647');
70
+ }
71
+ return timeout === 0 ? undefined : timeout;
72
+ }
73
+
74
+ /** Returns Forge broadcast flags for the target chain and artifact EVM version. */
75
+ export function getForgeBroadcastArgs(l1ContractsPath: string, chainId: number): string[] {
76
+ const broadcastArgs = ['--broadcast', '--disable-external-identification'];
77
+ if (chainId !== sepolia.id) {
78
+ return broadcastArgs;
79
+ }
80
+
81
+ const foundryToml = readFileSync(join(l1ContractsPath, 'foundry.toml'), 'utf-8');
82
+ const profile = parseToml(foundryToml).profile;
83
+ const defaultProfile = typeof profile === 'object' && 'default' in profile ? profile.default : undefined;
84
+ const evmVersion =
85
+ defaultProfile && typeof defaultProfile === 'object' && 'evm_version' in defaultProfile
86
+ ? defaultProfile.evm_version
87
+ : undefined;
88
+ if (typeof evmVersion !== 'string' || !evmVersion) {
89
+ throw new Error('L1 deployment artifacts do not specify an EVM version');
90
+ }
91
+
92
+ // Plan with the artifact's EVM target; --skip-simulation makes Forge estimate each send through RPC.
93
+ return [...broadcastArgs, '--hardfork', evmVersion, '--skip-simulation'];
94
+ }
95
+
35
96
  /**
36
97
  * Runs a process and parses JSON deploy results from stdout.
37
98
  * Lines starting with JSON_DEPLOY_RESULT_PREFIX are parsed and returned.
38
99
  * All other stdout goes to logger.info, stderr goes to logger.warn.
39
100
  */
40
- function runProcess<T>(
101
+ export function runProcess<T>(
41
102
  command: string,
42
103
  args: string[],
43
104
  env: Record<string, string | undefined>,
44
105
  cwd: string,
106
+ timeoutMs?: number,
45
107
  ): Promise<T | undefined> {
46
108
  const { promise, resolve, reject } = promiseWithResolvers<T | undefined>();
47
109
  const proc = spawn(command, args, {
48
110
  cwd,
49
- env: { ...process.env, ...env },
111
+ env: { ...getEnv(), ...env },
50
112
  stdio: ['ignore', 'pipe', 'pipe'],
51
113
  });
52
114
 
53
115
  let result: T | undefined;
54
116
  let parseError: Error | undefined;
55
117
  let settled = false;
118
+ let timedOut = false;
119
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
120
+ const timeoutTimer = timeoutMs
121
+ ? setTimeout(() => {
122
+ timedOut = true;
123
+ proc.kill('SIGTERM');
124
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), 1000);
125
+ }, timeoutMs)
126
+ : undefined;
127
+
128
+ const clearTimers = () => {
129
+ clearTimeout(timeoutTimer);
130
+ clearTimeout(killTimer);
131
+ };
132
+ const rejectProcess = (error: Error) => {
133
+ if (preparedDeployDirs.delete(cwd)) {
134
+ error.message += `; deployment artifacts preserved at ${cwd}. Transactions may already have been broadcast.`;
135
+ }
136
+ reject(error);
137
+ };
56
138
 
57
139
  readline.createInterface({ input: proc.stdout }).on('line', line => {
58
140
  const trimmedLine = line.trim();
@@ -74,18 +156,24 @@ function runProcess<T>(
74
156
  return;
75
157
  }
76
158
  settled = true;
77
- reject(new Error(`Failed to spawn ${command}: ${error.message}`));
159
+ clearTimers();
160
+ rejectProcess(new Error(`Failed to spawn ${command}: ${error.message}`));
78
161
  });
79
162
 
80
- proc.on('close', code => {
163
+ proc.on('close', (code, signal) => {
81
164
  if (settled) {
82
165
  return;
83
166
  }
84
167
  settled = true;
85
- if (code !== 0) {
86
- reject(new Error(`${command} exited with code ${code}`));
168
+ clearTimers();
169
+ if (timedOut) {
170
+ rejectProcess(new Error(`${command} timed out after ${timeoutMs}ms (signal ${signal ?? 'none'})`));
171
+ } else if (signal) {
172
+ rejectProcess(new Error(`${command} terminated by signal ${signal}`));
173
+ } else if (code !== 0) {
174
+ rejectProcess(new Error(`${command} exited with code ${code}`));
87
175
  } else if (parseError) {
88
- reject(parseError);
176
+ rejectProcess(parseError);
89
177
  } else {
90
178
  resolve(result);
91
179
  }
@@ -129,52 +217,77 @@ async function runForgeScriptBroadcast<T extends { rollupAddress: Hex }>(
129
217
  const { forgeBin, l1ContractsPath, script, privateKey, rpcUrl, l1Client, env } = args;
130
218
  const baseArgs = ['script', script, '--sig', 'run()', '--private-key', privateKey, '--rpc-url', rpcUrl];
131
219
  const verifyArgs = args.verify ? ['--verify'] : [];
220
+ const chainId = await l1Client.getChainId();
221
+ const broadcastArgs = getForgeBroadcastArgs(l1ContractsPath, chainId);
222
+ const timeout = getForgeBroadcastTimeout(chainId);
132
223
 
133
224
  const latestBlock = await l1Client.request({ method: 'eth_getBlockByNumber', params: ['latest', false] });
225
+ let result: T | undefined;
134
226
  if (!latestBlock || !isAmsterdamBlock(latestBlock)) {
135
- const scriptPath = join(getL1ContractsPath(), 'scripts', 'forge_broadcast.js');
136
- return runProcess<T>(
137
- process.execPath,
138
- [scriptPath, ...baseArgs.slice(1), ...verifyArgs],
139
- // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
140
- { ...env, FORGE_BIN: forgeBin },
227
+ result = await runProcess<T>(
228
+ forgeBin,
229
+ [...baseArgs, ...broadcastArgs, ...verifyArgs],
230
+ env,
231
+ l1ContractsPath,
232
+ timeout,
233
+ );
234
+ } else {
235
+ logger.info(`Dry-running ${script} to plan Glamsterdam gas limits`);
236
+ result = await runProcess<T>(
237
+ forgeBin,
238
+ [
239
+ ...baseArgs,
240
+ ...broadcastArgs.filter(arg => arg !== '--broadcast' && arg !== '--skip-simulation'),
241
+ '--gas-estimate-multiplier',
242
+ String(GLAMSTERDAM_GAS_ESTIMATE_MULTIPLIER),
243
+ ],
244
+ env,
141
245
  l1ContractsPath,
142
246
  );
143
- }
144
247
 
145
- logger.info(`Dry-running ${script} to plan Glamsterdam gas limits`);
146
- const result = await runProcess<T>(
147
- forgeBin,
148
- [...baseArgs, '--gas-estimate-multiplier', String(GLAMSTERDAM_GAS_ESTIMATE_MULTIPLIER)],
149
- env,
150
- l1ContractsPath,
151
- );
248
+ // Leave headroom for the block gas limit drifting down before the last tx lands.
249
+ const maxTxGas = (BigInt(latestBlock.gasLimit) * 99n) / 100n;
250
+ const sequencePath = join(
251
+ l1ContractsPath,
252
+ 'broadcast',
253
+ basename(script),
254
+ String(chainId),
255
+ 'dry-run',
256
+ 'run-latest.json',
257
+ );
258
+ const sequence = JSON.parse(readFileSync(sequencePath, 'utf-8'));
259
+ for (const tx of sequence.transactions) {
260
+ const gas = BigInt(tx.transaction.gas);
261
+ tx.transaction.gas = `0x${(gas < maxTxGas ? gas : maxTxGas).toString(16)}`;
262
+ }
263
+ // Forge 1.8 resumes from a recovery snapshot under cache/ (fingerprinted, so it cannot be edited) instead of from the
264
+ // broadcast file, and would send the unclamped limits, some larger than a block. Dropping the snapshot and the
265
+ // generation the exported files point at makes it resume from the edited broadcast file, as older forge versions did.
266
+ delete sequence.recovery_generation;
267
+ writeFileSync(sequencePath, JSON.stringify(sequence, null, 2));
268
+ const cacheDir = join(l1ContractsPath, 'cache', basename(script), String(chainId), 'dry-run');
269
+ const sensitivePath = join(cacheDir, 'run-latest.json');
270
+ if (existsSync(sensitivePath)) {
271
+ const sensitive = JSON.parse(readFileSync(sensitivePath, 'utf-8'));
272
+ delete sensitive.recovery_generation;
273
+ writeFileSync(sensitivePath, JSON.stringify(sensitive, null, 2));
274
+ }
275
+ rmSync(join(cacheDir, 'run-latest.json.recovery.json'), { force: true });
152
276
 
153
- // Leave headroom for the block gas limit drifting down before the last tx lands.
154
- const maxTxGas = (BigInt(latestBlock.gasLimit) * 99n) / 100n;
155
- const chainId = await l1Client.getChainId();
156
- const sequencePath = join(
157
- l1ContractsPath,
158
- 'broadcast',
159
- basename(script),
160
- String(chainId),
161
- 'dry-run',
162
- 'run-latest.json',
163
- );
164
- const sequence = JSON.parse(readFileSync(sequencePath, 'utf-8'));
165
- for (const tx of sequence.transactions) {
166
- const gas = BigInt(tx.transaction.gas);
167
- tx.transaction.gas = `0x${(gas < maxTxGas ? gas : maxTxGas).toString(16)}`;
277
+ logger.info(`Broadcasting ${sequence.transactions.length} transactions from ${script}`, { maxTxGas });
278
+ // --slow waits for each receipt before sending the next tx, so the sender only needs balance for one gas limit at a
279
+ // time and a block-sized tx is not queued behind others from the same sender.
280
+ await runProcess(
281
+ forgeBin,
282
+ [...baseArgs, ...broadcastArgs, '--resume', '--slow', ...verifyArgs],
283
+ env,
284
+ l1ContractsPath,
285
+ getSlowBroadcastTimeout(timeout, sequence.transactions.length),
286
+ );
168
287
  }
169
- writeFileSync(sequencePath, JSON.stringify(sequence, null, 2));
170
-
171
- logger.info(`Broadcasting ${sequence.transactions.length} transactions from ${script}`, { maxTxGas });
172
- // --slow waits for each receipt before sending the next tx, so the sender only needs balance for one gas limit at a
173
- // time and a block-sized tx is not queued behind others from the same sender.
174
- await runProcess(forgeBin, [...baseArgs, '--resume', '--slow', ...verifyArgs], env, l1ContractsPath);
175
288
 
176
289
  // l1-contracts releases that ship this script no longer activate initial validators inside the deploy, and rely on
177
- // forge_broadcast.js flushing the entry queue afterwards; we bypassed it, so flush here. Older releases flush inline.
290
+ // the broadcast wrapper flushing the entry queue afterwards; we bypassed it, so flush here. Older releases flush inline.
178
291
  if (
179
292
  result &&
180
293
  hasInitialValidators(env.INITIAL_VALIDATORS) &&
@@ -195,13 +308,12 @@ async function runForgeScriptBroadcast<T extends { rollupAddress: Hex }>(
195
308
  privateKey,
196
309
  '--rpc-url',
197
310
  rpcUrl,
311
+ ...broadcastArgs.filter(arg => arg !== '--skip-simulation'),
198
312
  '--skip-simulation',
199
- '--batch-size',
200
- '1',
201
- '--broadcast',
202
313
  ],
203
314
  env,
204
315
  l1ContractsPath,
316
+ timeout,
205
317
  );
206
318
  }
207
319
  return result;
@@ -258,37 +370,32 @@ export function getL1ContractsPath(): string {
258
370
  return resolve(dirname(require.resolve('@aztec-foundation/l1-artifacts')), '..', 'l1-contracts');
259
371
  }
260
372
 
261
- // Cached deployment directory
262
- let preparedDeployDir: string | undefined;
373
+ const preparedDeployDirs = new Set<string>();
263
374
 
264
375
  function cleanupDeployDir() {
265
- if (preparedDeployDir) {
376
+ for (const directory of preparedDeployDirs) {
266
377
  try {
267
- rmSync(preparedDeployDir, { recursive: true, force: true });
378
+ rmSync(directory, { recursive: true, force: true });
268
379
  } catch {
269
380
  // ignore cleanup errors
270
381
  }
271
- preparedDeployDir = undefined;
272
382
  }
383
+ preparedDeployDirs.clear();
273
384
  }
274
385
 
386
+ process.once('exit', cleanupDeployDir);
387
+
275
388
  /**
276
389
  * Prepares a temp directory for forge deployment.
277
390
  * Copies all artifacts with preserved timestamps (required for forge cache validity).
278
391
  * A fresh broadcast/ directory is created for deployment outputs.
279
392
  */
280
393
  export function prepareL1ContractsForDeployment(): string {
281
- if (preparedDeployDir && existsSync(preparedDeployDir)) {
282
- logger.verbose(`Using cached deployment directory: ${preparedDeployDir}`);
283
- return preparedDeployDir;
284
- }
285
-
286
394
  const basePath = getL1ContractsPath();
287
395
  logger.verbose(`Preparing L1 contracts from: ${basePath}`);
288
396
  const tempDir = mkdtempSync(join(tmpdir(), '.foundry-deploy-'));
289
397
  logger.verbose(`Created temp directory for deployment: ${tempDir}`);
290
- preparedDeployDir = tempDir;
291
- process.on('exit', cleanupDeployDir);
398
+ preparedDeployDirs.add(tempDir);
292
399
 
293
400
  // Copy all dirs with preserved timestamps (required for forge cache validity)
294
401
  const copyOpts = { recursive: true, preserveTimestamps: true };
@@ -315,12 +422,80 @@ export function prepareL1ContractsForDeployment(): string {
315
422
  foundryToml = foundryToml.replace(/solc\s*=\s*"\.\/solc-[^"]+"/, `solc = "${absoluteSolcPath}"`);
316
423
  logger.verbose(`Updated solc path in foundry.toml to: ${absoluteSolcPath}`);
317
424
  }
425
+
426
+ const cachePath = join(tempDir, FORGE_CACHE_FILE);
427
+ const cache: ForgeCache = JSON.parse(readFileSync(cachePath, 'utf-8'));
428
+ if (!cache.preprocessed) {
429
+ // A cache written without test preprocessing is only accepted by forge 1.8 if dynamic test linking is off. Test
430
+ // preprocessing does not affect the contracts and scripts we deploy.
431
+ foundryToml = foundryToml.replace(/^\[profile\.default\]$/m, '$&\ndynamic_test_linking = false');
432
+ }
318
433
  writeFileSync(join(tempDir, 'foundry.toml'), foundryToml);
319
434
 
435
+ try {
436
+ patchForgeCache(tempDir, cache);
437
+ } catch (err) {
438
+ logger.warn(`Failed to patch the forge cache, forge will recompile the L1 contracts`, { err });
439
+ }
440
+
320
441
  mkdirSync(join(tempDir, 'broadcast'));
321
442
  return tempDir;
322
443
  }
323
444
 
445
+ const FORGE_CACHE_FILE = join('cache', 'solidity-files-cache.json');
446
+
447
+ /** The fields of forge's `solidity-files-cache.json` that depend on the forge version or the project location. */
448
+ type ForgeCache = {
449
+ profiles?: Record<string, { solc?: Record<string, unknown> }>;
450
+ remappings?: string[];
451
+ preprocessed?: boolean;
452
+ };
453
+
454
+ /**
455
+ * Makes the forge build cache shipped in l1-artifacts valid in a deploy directory, so forge uses the shipped artifacts
456
+ * instead of recompiling all L1 contracts, which takes ~10s per process and yields bytecode with a different metadata
457
+ * hash than the published one.
458
+ *
459
+ * Since 1.8, forge stores the absolute remappings of the project in its cache, and discards a cache whose remappings do
460
+ * not match the current project root. So a cache written in the directory where l1-artifacts was built never matches a
461
+ * copy of it. We regenerate the remappings for the deploy directory in the format forge writes them, which lists every
462
+ * context remapping twice: once with a relative context and once with an absolute one.
463
+ *
464
+ * Caches written by forge before 1.8 also lack the `viaSSACFG` and `experimental` solc settings, and need dynamic test
465
+ * linking disabled (see `prepareL1ContractsForDeployment`).
466
+ *
467
+ * TODO: Once l1-artifacts is built with forge 1.8.5 or later, remove the `viaSSACFG`/`experimental` backfill and the
468
+ * `dynamic_test_linking = false` override, as such caches already carry both settings and record `preprocessed: true`.
469
+ * Keep the remappings rewrite: a forge 1.8 cache stays tied to the directory where it was written.
470
+ */
471
+ function patchForgeCache(deployDir: string, cache: ForgeCache) {
472
+ const root = realpathSync(deployDir);
473
+ const remappings = execFileSync(resolveFoundryBinary('forge'), ['remappings'], { cwd: root, encoding: 'utf-8' })
474
+ .split('\n')
475
+ .filter(line => line.trim())
476
+ .flatMap(line => {
477
+ const separator = line.indexOf('=');
478
+ const context = line.slice(0, separator).trim();
479
+ const target = line.slice(separator + 1).trim();
480
+ const absoluteTarget = isAbsolute(target) ? target : `${root}/${target}`;
481
+ const remapping = `${context}=${absoluteTarget}`;
482
+ return context.includes(':') ? [remapping, `${root}/${remapping}`] : [remapping];
483
+ });
484
+
485
+ for (const profile of Object.values(cache.profiles ?? {})) {
486
+ if (profile.solc) {
487
+ profile.solc.viaSSACFG ??= false;
488
+ profile.solc.experimental ??= false;
489
+ }
490
+ }
491
+ cache.remappings = remappings;
492
+
493
+ const cachePath = join(deployDir, FORGE_CACHE_FILE);
494
+ const { atime, mtime } = statSync(cachePath);
495
+ writeFileSync(cachePath, JSON.stringify(cache));
496
+ utimesSync(cachePath, atime, mtime);
497
+ }
498
+
324
499
  /**
325
500
  * Computes the validator data for passing to Solidity.
326
501
  * Only computes the G2 public key (which requires scalar multiplication on G2, not available in EVM).
@@ -450,9 +625,9 @@ export async function deployAztecL1Contracts(
450
625
 
451
626
  // Verify contracts on Etherscan when on mainnet/sepolia and ETHERSCAN_API_KEY is available.
452
627
  const isVerifiableChain = chainId === mainnet.id || chainId === sepolia.id;
453
- const shouldVerify = isVerifiableChain && !!process.env.ETHERSCAN_API_KEY;
628
+ const shouldVerify = isVerifiableChain && !!getEnv().ETHERSCAN_API_KEY;
454
629
 
455
- if (isVerifiableChain && !process.env.ETHERSCAN_API_KEY) {
630
+ if (isVerifiableChain && !getEnv().ETHERSCAN_API_KEY) {
456
631
  logger.warn(
457
632
  `Deploying to chain ${chainId} (${chainId === mainnet.id ? 'mainnet' : 'sepolia'}) without ETHERSCAN_API_KEY. ` +
458
633
  `Contracts will NOT be verified on Etherscan. Set ETHERSCAN_API_KEY environment variable to enable verification.`,
@@ -1,3 +1,4 @@
1
+ import { getEnv } from '@aztec-labs/foundation/config/env';
1
2
  import { spawnSync } from 'child_process';
2
3
  import { accessSync, constants } from 'fs';
3
4
  import { homedir } from 'os';
@@ -25,7 +26,7 @@ function isExecutable(path: string): boolean {
25
26
  */
26
27
  export function resolveFoundryBinary(name: string): string {
27
28
  const envVar = `${name.toUpperCase()}_BIN`;
28
- const envBin = process.env[envVar];
29
+ const envBin = getEnv()[envVar];
29
30
  if (envBin) {
30
31
  if (!isExecutable(envBin)) {
31
32
  throw new Error(`$${envVar} is set to ${envBin}, which does not exist or is not executable.`);
@@ -37,8 +37,11 @@ export class AmsterdamForkDetector {
37
37
 
38
38
  /** Records a freshly fetched latest L1 block. */
39
39
  public observe(block: object | null | undefined): void {
40
- if (isAmsterdamBlock(block)) {
40
+ if (!this.active && isAmsterdamBlock(block)) {
41
41
  this.active = true;
42
+ (this.opts.log ?? createLogger('ethereum:amsterdam-fork-detector')).debug('Amsterdam fork detected on L1', {
43
+ blockNumber: block && 'number' in block ? block.number : undefined,
44
+ });
42
45
  }
43
46
  this.lastObservedAtMs = this.now();
44
47
  }
@@ -18,6 +18,10 @@ export const MAX_L1_TX_LIMIT = 16_777_216n;
18
18
  export const AMSTERDAM_MAX_L1_TX_LIMIT = 50_000_000n;
19
19
  export const AMSTERDAM_MAX_SLASHED_VALIDATORS_PER_ROUND = 35;
20
20
 
21
+ // How long a publisher whose send was rejected for insufficient funds is skipped by publisher selection if its balance
22
+ // does not increase in the meantime.
23
+ export const INSUFFICIENT_FUNDS_BACKOFF_MS = 5 * 60 * 1000;
24
+
21
25
  // setting a minimum bump percentage to 10% due to geth's implementation
22
26
  // https://github.com/ethereum/go-ethereum/blob/e3d61e6db028c412f74bc4d4c7e117a9e29d0de0/core/txpool/legacypool/list.go#L298
23
27
  export const MIN_REPLACEMENT_BUMP_PERCENTAGE = 10;
@@ -28,7 +28,7 @@ export const P75AllTxsPriorityFeeStrategy: PriorityFeeStrategy = {
28
28
  await Promise.allSettled([
29
29
  client.getBlock({ blockTag: 'latest' }),
30
30
  isBlobTx ? client.getBlobBaseFee() : Promise.resolve(undefined),
31
- client.estimateMaxPriorityFeePerGas().catch(() => 0n),
31
+ client.estimateMaxPriorityFeePerGas(),
32
32
  client.getBlock({ blockTag: 'pending', includeTransactions: true }).catch(() => null),
33
33
  client
34
34
  .getFeeHistory({
@@ -140,6 +140,16 @@ export const P75AllTxsPriorityFeeStrategy: PriorityFeeStrategy = {
140
140
  });
141
141
  competitiveFee = maxReasonableFee;
142
142
  debugInfo.cappedToGwei = formatGwei(maxReasonableFee);
143
+ } else if (networkEstimateResult.status === 'fulfilled' && networkEstimate === 0n && competitiveFee > 0n) {
144
+ logger?.warn(
145
+ 'L1 RPC returned a zero priority fee estimate, disabling the competitive fee sanity cap. ' +
146
+ 'You may be overpaying L1 transaction fees. Review your L1 RPC gas price oracle settings; ' +
147
+ 'if using Reth, consider setting --gpo.ignoreprice=2 to exclude zero- and one-wei tips.',
148
+ {
149
+ networkEstimateGwei: formatGwei(networkEstimate),
150
+ competitiveFeeGwei: formatGwei(competitiveFee),
151
+ },
152
+ );
143
153
  }
144
154
 
145
155
  // Log final decision
@@ -7,6 +7,7 @@ export * from './interfaces.js';
7
7
  export * from './l1_fee_analyzer.js';
8
8
  export * from './l1_tx_utils.js';
9
9
  export * from './readonly_l1_tx_utils.js';
10
+ export * from './send_cost.js';
10
11
  export * from './signer.js';
11
12
  export * from './types.js';
12
13
  export * from './tx_delayer.js';
@@ -10,25 +10,27 @@ import { Semaphore } from '@aztec-labs/foundation/queue';
10
10
  import { makeBackoff, retry, retryUntil } from '@aztec-labs/foundation/retry';
11
11
  import { sleep } from '@aztec-labs/foundation/sleep';
12
12
  import { DateProvider } from '@aztec-labs/foundation/timer';
13
+ import { getErrorCause } from '@aztec-labs/foundation/types';
13
14
  import pickBy from 'lodash.pickby';
14
15
  import {
15
16
  type Abi,
16
17
  type BlockOverrides,
17
18
  type Hex,
19
+ InsufficientFundsError,
18
20
  type PrepareTransactionRequestRequest,
19
21
  type StateOverride,
20
22
  type TransactionReceipt,
21
23
  type TransactionSerializable,
22
24
  formatGwei,
23
- serializeTransaction,
24
25
  } from 'viem';
25
26
 
27
+ import { serializeSignedTransaction } from '../blob_tx.js';
26
28
  import type { ViemClient } from '../types.js';
27
29
  import { formatViemError } from '../utils.js';
28
30
  import { type L1TxUtilsConfig, l1TxUtilsConfigMappings } from './config.js';
29
- import { MAX_L1_TX_LIMIT } from './constants.js';
31
+ import { INSUFFICIENT_FUNDS_BACKOFF_MS, MAX_L1_TX_LIMIT } from './constants.js';
30
32
  import type { IL1TxMetrics, IL1TxStore } from './interfaces.js';
31
- import { type L1SimulationResult, ReadOnlyL1TxUtils } from './readonly_l1_tx_utils.js';
33
+ import { type L1SimulationResult, ReadOnlyL1TxUtils, isInsufficientFundsRpcError } from './readonly_l1_tx_utils.js';
32
34
  import { Delayer, createDelayer, wrapClientWithDelayer } from './tx_delayer.js';
33
35
  import {
34
36
  DroppedTransactionError,
@@ -61,6 +63,11 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
61
63
  public delayer?: Delayer;
62
64
  /** KZG instance for blob operations. */
63
65
  protected kzg?: BlobKzgInstance;
66
+ /**
67
+ * Sender balance and time when a send was last rejected for insufficient funds. Cleared once the balance rises above
68
+ * the recorded one or the backoff expires.
69
+ */
70
+ private insufficientFunds: { balance: bigint; atMs: number } | undefined;
64
71
 
65
72
  constructor(
66
73
  public override client: ViemClient,
@@ -156,6 +163,47 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
156
163
  });
157
164
  }
158
165
 
166
+ /** Returns the sender balance recorded when a send was last rejected for insufficient funds, if any. */
167
+ public getInsufficientFundsAtBalance(): bigint | undefined {
168
+ return this.insufficientFunds?.balance;
169
+ }
170
+
171
+ /**
172
+ * Returns whether this sender should be skipped because a previous send was rejected for insufficient funds and its
173
+ * balance has not increased since. Clears the mark once the given current balance is above the recorded one, or once
174
+ * INSUFFICIENT_FUNDS_BACKOFF_MS have elapsed since the rejection: a rejection can come from a send whose gas limit was
175
+ * far above what later sends need, so a balance that did not move is not proof the publisher cannot afford them.
176
+ */
177
+ public isBackedOffForInsufficientFunds(currentBalance: bigint): boolean {
178
+ if (this.insufficientFunds === undefined) {
179
+ return false;
180
+ }
181
+ if (this.hasActiveInsufficientFundsBackoff(currentBalance)) {
182
+ return true;
183
+ }
184
+ const { balance } = this.insufficientFunds;
185
+ this.logger.info(`Clearing insufficient funds backoff for publisher`, {
186
+ account: this.getSenderAddress().toString(),
187
+ balance: currentBalance,
188
+ insufficientFundsAtBalance: balance,
189
+ reason: currentBalance > balance ? 'balance-increased' : 'expired',
190
+ });
191
+ this.insufficientFunds = undefined;
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Same check as {@link isBackedOffForInsufficientFunds}, but without clearing a lifted backoff or logging, so it can be
197
+ * used for reporting without affecting publisher selection.
198
+ */
199
+ public hasActiveInsufficientFundsBackoff(currentBalance: bigint): boolean {
200
+ if (this.insufficientFunds === undefined) {
201
+ return false;
202
+ }
203
+ const { balance, atMs } = this.insufficientFunds;
204
+ return currentBalance <= balance && this.dateProvider.now() - atMs < INSUFFICIENT_FUNDS_BACKOFF_MS;
205
+ }
206
+
159
207
  /**
160
208
  * Rehydrates transaction states from the store and resumes monitoring for pending transactions.
161
209
  * This should be called on startup to restore state and resume monitoring of any in-flight transactions.
@@ -220,11 +268,12 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
220
268
 
221
269
  private async signTransaction(txRequest: TransactionSerializable): Promise<`0x${string}`> {
222
270
  const signature = await this.signer(txRequest, this.getSenderAddress());
223
- return serializeTransaction(txRequest, signature);
271
+ return serializeSignedTransaction(txRequest, signature);
224
272
  }
225
273
 
226
274
  protected async prepareSignedTransaction(txData: PrepareTransactionRequestRequest) {
227
- const txRequest = await this.client.prepareTransactionRequest(txData);
275
+ // Passing the chain id keeps viem from routing a fully specified request through eth_fillTransaction.
276
+ const txRequest = await this.client.prepareTransactionRequest({ chainId: this.client.chain.id, ...txData });
228
277
  return await this.signTransaction(txRequest as TransactionSerializable);
229
278
  }
230
279
 
@@ -340,6 +389,9 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
340
389
 
341
390
  return { txHash, state: l1TxState };
342
391
  } catch (err: any) {
392
+ if (isInsufficientFundsRpcError(err) || getErrorCause(err, InsufficientFundsError)) {
393
+ await this.recordInsufficientFunds();
394
+ }
343
395
  const viemError = formatViemError(err, request.abi);
344
396
  this.logger.error(`Failed to send L1 transaction: ${viemError.message}`, viemError, {
345
397
  request: pick(request, 'to', 'value'),
@@ -348,6 +400,21 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
348
400
  }
349
401
  }
350
402
 
403
+ /** Records the current sender balance as one that cannot afford a send, so selection skips it for a while. */
404
+ private async recordInsufficientFunds(): Promise<void> {
405
+ try {
406
+ const balance = await this.getSenderBalance();
407
+ this.insufficientFunds = { balance, atMs: this.dateProvider.now() };
408
+ this.logger.warn(`L1 send rejected for insufficient funds, backing off publisher until its balance increases`, {
409
+ account: this.getSenderAddress().toString(),
410
+ balance,
411
+ backoffMs: INSUFFICIENT_FUNDS_BACKOFF_MS,
412
+ });
413
+ } catch (err) {
414
+ this.logger.warn(`Failed to read balance after an insufficient funds rejection`, { err });
415
+ }
416
+ }
417
+
351
418
  private async tryGetTxReceipt(
352
419
  txHashes: Hex[],
353
420
  nonce: number,
@@ -51,7 +51,7 @@ const INSUFFICIENT_FUNDS_RPC_ERROR_CODE = -38014;
51
51
  * error code, since RPC gateways differ in how they wrap node errors. Apply this only to transport errors: an
52
52
  * execution revert string can mention insufficient funds without being this kind of rejection.
53
53
  */
54
- function isInsufficientFundsRpcError(err: unknown): boolean {
54
+ export function isInsufficientFundsRpcError(err: unknown): boolean {
55
55
  if (getL1RpcErrorCode(err) === INSUFFICIENT_FUNDS_RPC_ERROR_CODE) {
56
56
  return true;
57
57
  }