From 1f11ffbd6f116b9d950366efb1ba4c09a95a8ced Mon Sep 17 00:00:00 2001 From: Gregorio Juliana Date: Thu, 9 Jul 2026 12:19:44 +0200 Subject: [PATCH 1/4] wip --- .../aztec/src/local-network/local-network.ts | 7 +- yarn-project/aztec/src/testing/index.ts | 1 + .../aztec/src/testing/local-network.test.ts | 61 ++++++++ .../aztec/src/testing/local-network.ts | 93 +++++++++++ .../ethereum/scripts/anvil_kill_wrapper.sh | 51 ------ yarn-project/ethereum/src/test/start_anvil.ts | 145 +++++++++++++++++- 6 files changed, 299 insertions(+), 59 deletions(-) create mode 100644 yarn-project/aztec/src/testing/local-network.test.ts create mode 100644 yarn-project/aztec/src/testing/local-network.ts delete mode 100755 yarn-project/ethereum/scripts/anvil_kill_wrapper.sh diff --git a/yarn-project/aztec/src/local-network/local-network.ts b/yarn-project/aztec/src/local-network/local-network.ts index b8419006aca5..a4a26202d55e 100644 --- a/yarn-project/aztec/src/local-network/local-network.ts +++ b/yarn-project/aztec/src/local-network/local-network.ts @@ -88,6 +88,8 @@ export type LocalNetworkConfig = AztecNodeConfig & { l1Mnemonic: string; /** Whether to deploy test accounts on local network start.*/ testAccounts: boolean; + /** Override the default per-address fee juice granted at genesis to funded addresses. */ + initialAccountFeeJuice: Fr; }; /** @@ -172,7 +174,10 @@ export async function createLocalNetwork(config: Partial = { ...(initialAccounts.length ? [bananaFPC, sponsoredFPC] : []), ...prefundAddresses, ]; - const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues(fundedAddresses); + const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues( + fundedAddresses, + config.initialAccountFeeJuice, + ); const dateProvider = new TestDateProvider(); diff --git a/yarn-project/aztec/src/testing/index.ts b/yarn-project/aztec/src/testing/index.ts index 0155a48144fd..6459e351146b 100644 --- a/yarn-project/aztec/src/testing/index.ts +++ b/yarn-project/aztec/src/testing/index.ts @@ -1,4 +1,5 @@ export { EthCheatCodes, RollupCheatCodes } from '@aztec/ethereum/test'; export { CheatCodes } from './cheat_codes.js'; export { EpochTestSettler } from './epoch_test_settler.js'; +export { setupLocalNetwork, TEST_FEE_PADDING, type LocalNetwork, type LocalNetworkOptions } from './local-network.js'; export { getTokenAllowedSetupFunctions } from './token_allowed_setup.js'; diff --git a/yarn-project/aztec/src/testing/local-network.test.ts b/yarn-project/aztec/src/testing/local-network.test.ts new file mode 100644 index 000000000000..77964177efc6 --- /dev/null +++ b/yarn-project/aztec/src/testing/local-network.test.ts @@ -0,0 +1,61 @@ +import { getInitialTestAccountsData } from '@aztec/accounts/testing'; +import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { EmbeddedWallet } from '@aztec/wallets/embedded'; + +import { TEST_FEE_PADDING, setupLocalNetwork } from './local-network.js'; + +// Heavy integration fixture: each case spawns anvil, deploys the L1 contracts via forge, and runs a +// full in-process node. Requires an aztec-up Foundry toolchain on PATH. Not part of the fast unit +// gate (the package's CI test_cmds only covers src/cli); run explicitly with +// `yarn workspace @aztec/aztec test src/testing/local-network.test.ts`. +describe('setupLocalNetwork', () => { + it('serves a live node on a random L1 port and tears down cleanly', async () => { + const net = await setupLocalNetwork(); + try { + const info = await net.node.getNodeInfo(); + expect(info.l1ContractAddresses.rollupAddress).toBeDefined(); + expect(await net.node.getBlockNumber()).toBeGreaterThanOrEqual(0); + expect(net.l1ChainId).toBe(31337); + // OS-assigned ephemeral port, never the fixed default 8545. + expect(net.l1RpcUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(net.l1RpcUrl).not.toContain(':8545'); + } finally { + await net.stop(); + } + }, 300_000); + + it('runs two networks in parallel on distinct ports', async () => { + const [a, b] = await Promise.all([setupLocalNetwork(), setupLocalNetwork()]); + try { + expect(a.l1RpcUrl).not.toEqual(b.l1RpcUrl); + expect(await a.node.getBlockNumber()).toBeGreaterThanOrEqual(0); + expect(await b.node.getBlockNumber()).toBeGreaterThanOrEqual(0); + } finally { + await Promise.all([a.stop(), b.stop()]); + } + }, 300_000); + + it('pre-funds addresses at genesis so they can pay for their own txs', async () => { + const [alice] = await getInitialTestAccountsData(); + const net = await setupLocalNetwork({ fundedAddresses: [alice.address] }); + try { + const wallet = await EmbeddedWallet.create(net.node, { + ephemeral: true, + pxeConfig: { proverEnabled: false }, + }); + await wallet.createSchnorrInitializerlessAccount(alice.secret, alice.salt, alice.signingKey); + wallet.setMinFeePadding(TEST_FEE_PADDING); + + // Deploying a contract paying from alice's own genesis fee juice proves the funded path + // end-to-end: without genesis funding this tx would be rejected for lack of fee juice. + const { contract } = await TokenContract.deploy(wallet, alice.address, 'TokenName', 'TKN', 18).send({ + from: alice.address, + }); + expect(contract.address).toBeDefined(); + + await wallet.stop(); + } finally { + await net.stop(); + } + }, 300_000); +}); diff --git a/yarn-project/aztec/src/testing/local-network.ts b/yarn-project/aztec/src/testing/local-network.ts new file mode 100644 index 000000000000..9bceae67a66c --- /dev/null +++ b/yarn-project/aztec/src/testing/local-network.ts @@ -0,0 +1,93 @@ +import type { AztecNodeService } from '@aztec/aztec-node'; +import type { AztecNodeConfig } from '@aztec/aztec-node/config'; +import type { Fr } from '@aztec/aztec.js/fields'; +import { ensureAztecBinsInPath, startAnvil } from '@aztec/ethereum/test'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { foundry } from 'viem/chains'; + +import { createLocalNetwork } from '../local-network/local-network.js'; + +/** A running in-process local network: an inline Aztec node backed by its own anvil L1. */ +export interface LocalNetwork { + /** Fully-synced Aztec node, ready to serve client requests. */ + node: AztecNodeService; + /** RPC URL of the spawned anvil instance. */ + l1RpcUrl: string; + /** Chain id used on L1 (foundry's default 31337). */ + l1ChainId: number; + /** Stops every process started by the fixture: node and anvil. */ + stop: () => Promise; +} + +/** Options for {@link setupLocalNetwork}. */ +export interface LocalNetworkOptions { + /** + * Addresses that should hold fee juice at genesis. Saves each of these the round-trip of bridging + * + claiming fee juice before they can pay for gas. + */ + fundedAddresses?: AztecAddress[]; + /** Override the default per-address genesis fee juice granted to {@link fundedAddresses}. */ + initialAccountFeeJuice?: Fr; + /** Node config overrides, e.g. `realProofs`, `aztecEpochDuration`, `p2pEnabled`. */ + config?: Partial; + /** anvil block time in seconds. Omit for automine (the default). */ + l1BlockTime?: number; +} + +/** + * Spin up an in-process local network with the given addresses pre-funded. + * + * Each call spawns its own anvil on an OS-assigned random port and runs the Aztec node inline via + * the same {@link createLocalNetwork} codepath that backs `aztec start --local-network` (with the + * sandbox account/FPC/token setup skipped). Distinct ports let independent suites run in parallel. + * The caller must `await result.stop()` in its teardown. + * + * Requires an `aztec-up`-installed Foundry toolchain (`anvil`/`forge`/`solc`) reachable on `PATH`; + * {@link ensureAztecBinsInPath} splices the standard aztec-up bin directories in automatically. + */ +export async function setupLocalNetwork(opts: LocalNetworkOptions = {}): Promise { + // `deployAztecL1Contracts` shells out to bare `forge`/`solc`; make the aztec-up bins reachable + // before anything spawns them. Idempotent, and also called inside `startAnvil`. + ensureAztecBinsInPath(); + + // `--port 0` → anvil binds an ephemeral port that `startAnvil` reads back, so parallel suites + // never collide on a fixed port. + const { rpcUrl, stop: stopAnvil } = await startAnvil({ port: 0, l1BlockTime: opts.l1BlockTime }); + + try { + const { node, stop: stopNode } = await createLocalNetwork( + { + ...opts.config, + l1RpcUrls: [rpcUrl], + testAccounts: false, + prefundAddresses: (opts.fundedAddresses ?? []).map(a => a.toString()), + initialAccountFeeJuice: opts.initialAccountFeeJuice, + }, + () => {}, + ); + + return { + node, + l1RpcUrl: rpcUrl, + l1ChainId: foundry.id, + stop: async () => { + await stopNode(); + await stopAnvil(); + }, + }; + } catch (err) { + await stopAnvil(); + throw err; + } +} + +/** + * Min-fee padding multiplier for test wallets sending txs against {@link setupLocalNetwork}. The + * automine sequencer builds one block per tx and advances L1 time in big jumps, so the network's + * congestion base fee can swing sharply between the wallet's fee estimate and the block the tx + * actually lands in. The default wallet padding isn't enough and trips + * `maxFeesPerGas.feePerL2Gas must be >= gasFees.feePerL2Gas`. Apply via + * `wallet.setMinFeePadding(TEST_FEE_PADDING)` on every test wallet that sends txs. + */ +export const TEST_FEE_PADDING = 30; diff --git a/yarn-project/ethereum/scripts/anvil_kill_wrapper.sh b/yarn-project/ethereum/scripts/anvil_kill_wrapper.sh deleted file mode 100755 index 629750efda21..000000000000 --- a/yarn-project/ethereum/scripts/anvil_kill_wrapper.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash - -# Function to get the PPID in macOS -get_ppid_macos() { - ps -j $$ | awk 'NR==2 {print $3}' -} - -# Function to get the PPID in Linux -get_ppid_linux() { - awk '{print $4}' /proc/$$/stat -} - -# Function to check if a process is alive in macOS -is_process_alive_macos() { - ps -p $1 > /dev/null 2>&1 -} - -# Function to check if a process is alive in Linux -is_process_alive_linux() { - [ -d /proc/$1 ] -} - - -# Determine the operating system and call the appropriate function -if [[ "$OSTYPE" == "darwin"* ]]; then - PARENT_PID=$(get_ppid_macos) - check_process_alive() { is_process_alive_macos $1; } -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - PARENT_PID=$(get_ppid_linux) - check_process_alive() { is_process_alive_linux $1; } -else - echo "Unsupported OS" - exit 1 -fi - -# echo "Parent PID: $PARENT_PID" - -# Start anvil in the background. -RAYON_NUM_THREADS=1 anvil $@ & -CHILD_PID=$! - -cleanup() { - kill $CHILD_PID -} - -trap cleanup EXIT - -# Continuously check if the parent process is still alive. -while check_process_alive $PARENT_PID; do - sleep 1 -done diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index f5ba8609e15f..4a4af18a30bb 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -1,10 +1,11 @@ import { createLogger } from '@aztec/foundation/log'; import { makeBackoff, retry } from '@aztec/foundation/retry'; import type { TestDateProvider } from '@aztec/foundation/timer'; -import { fileURLToPath } from '@aztec/foundation/url'; -import { type ChildProcess, spawn } from 'child_process'; -import { dirname, resolve } from 'path'; +import { type ChildProcess, spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; /** Minimal interface matching the @viem/anvil Anvil shape used by callers. */ export interface Anvil { @@ -14,6 +15,126 @@ export interface Anvil { stop(): Promise; } +// Every anvil we spawn is its own process-group leader (`detached: true`), so killing the group +// (`process.kill(-pid, …)`) tears down anvil and anything it forked. We track the live children and +// install best-effort signal/exit handlers so an uncleanly-killed test runner (Ctrl+C, crash) does +// not leave orphan anvils behind — this replaces the parent-polling shell wrapper we used to spawn. +const tracked = new Set(); +let handlersInstalled = false; + +function installHandlers(): void { + if (handlersInstalled) { + return; + } + handlersInstalled = true; + + // Async work isn't allowed in `exit` handlers, so SIGKILL the groups synchronously. + process.on('exit', () => { + for (const child of tracked) { + killGroupSync(child, 'SIGKILL'); + } + }); + + // Nuke children synchronously, then re-raise the signal so the test runner exits conventionally + // (and its own teardown still runs, unlike a bare `process.exit`). + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { + process.on(signal, () => { + for (const child of tracked) { + killGroupSync(child, 'SIGKILL'); + } + process.removeAllListeners(signal); + process.kill(process.pid, signal); + }); + } +} + +function killGroupSync(child: ChildProcess, signal: NodeJS.Signals): void { + if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { + return; + } + try { + if (process.platform === 'win32') { + child.kill(signal); + } else { + // Negative PID → kill the whole process group. Requires `detached: true` at spawn time. + process.kill(-child.pid, signal); + } + } catch { + try { + child.kill(signal); + } catch { + /* already dead */ + } + } +} + +/** + * Splice the directories where `aztec-up` installs foundry/nargo binaries into `process.env.PATH`. + * Idempotent. + * + * Necessary because `deployAztecL1Contracts` shells out to bare `forge`/`solc`, and those inherit + * PATH from us. Since the aztec-up change that stopped polluting the user's interactive PATH, + * `forge`/`cast`/`anvil`/`nargo` are only reachable via `~/.aztec/current/internal-bin/`. + */ +export function ensureAztecBinsInPath(): void { + const dirs = [join(homedir(), '.aztec', 'current', 'internal-bin'), join(homedir(), '.foundry', 'bin')].filter(d => + existsSync(d), + ); + + if (dirs.length === 0) { + return; + } + + const sep = process.platform === 'win32' ? ';' : ':'; + const current = process.env.PATH ?? ''; + const parts = current.split(sep); + const missing = dirs.filter(d => !parts.includes(d)); + if (missing.length === 0) { + return; + } + + process.env.PATH = [...missing, ...parts].filter(Boolean).join(sep); +} + +/** + * Locate the `anvil` binary. Order: + * 1. `$ANVIL_BIN` (explicit override, e.g. for CI with a pinned version). + * 2. `~/.aztec/current/internal-bin/anvil` — where aztec-up installs it. + * 3. `~/.aztec/current/bin/aztec-anvil` — the publicly-exposed symlink. + * 4. `~/.foundry/bin/anvil` — standalone foundryup install. + * 5. `which anvil` — anything else on PATH. + * + * Throws with a directive message if none work. + */ +export function resolveAnvilBinary(): string { + const envBin = process.env.ANVIL_BIN; + if (envBin && existsSync(envBin)) { + return envBin; + } + + const candidates = [ + join(homedir(), '.aztec', 'current', 'internal-bin', 'anvil'), + join(homedir(), '.aztec', 'current', 'bin', 'aztec-anvil'), + join(homedir(), '.foundry', 'bin', 'anvil'), + ]; + for (const path of candidates) { + if (existsSync(path)) { + return path; + } + } + + const which = spawnSync('sh', ['-c', 'command -v anvil'], { encoding: 'utf8' }); + if (which.status === 0 && which.stdout.trim()) { + return which.stdout.trim(); + } + + throw new Error( + 'anvil binary not found. Tried $ANVIL_BIN, ~/.aztec/current/internal-bin/anvil, ' + + '~/.aztec/current/bin/aztec-anvil, ~/.foundry/bin/anvil, and $PATH. ' + + 'Install via `aztec-up` or set ANVIL_BIN to a working binary.', + ); +} + /** * Ensures there's a running Anvil instance and returns the RPC URL. */ @@ -42,7 +163,8 @@ export async function startAnvil( dateProvider?: TestDateProvider; } = {}, ): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise }> { - const anvilBinary = resolve(dirname(fileURLToPath(import.meta.url)), '../../', 'scripts/anvil_kill_wrapper.sh'); + ensureAztecBinsInPath(); + const anvilBinary = resolveAnvilBinary(); const logger = opts.log ? createLogger('ethereum:anvil') : undefined; const methodCalls = opts.captureMethodCalls ? ([] as string[]) : undefined; @@ -50,6 +172,9 @@ export async function startAnvil( const anvil = await retry( async () => { + // Pass `--port 0` to let anvil bind an OS-assigned ephemeral port; the actual port is read + // back from its "Listening on host:port" stdout below. This lets independent suites spawn + // their own anvil in parallel without fighting over a fixed port. const port = opts.port ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : 8545); const args: string[] = [ '--host', @@ -71,10 +196,14 @@ export async function startAnvil( } args.push('--slots-in-an-epoch', String(opts.slotsInAnEpoch ?? 1)); + installHandlers(); const child = spawn(anvilBinary, args, { stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', env: { ...process.env, RAYON_NUM_THREADS: '1' }, }); + tracked.add(child); + child.once('exit', () => tracked.delete(child)); // Wait for "Listening on" or an early exit. await new Promise((resolve, reject) => { @@ -183,9 +312,11 @@ function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDatePro } } -/** Send SIGTERM, wait up to 5 s, then SIGKILL. All timers are always cleared. */ +/** Send SIGTERM to the process group, wait up to 5 s, then SIGKILL. All timers are always cleared. */ function killChild(child: ChildProcess): Promise { return new Promise(resolve => { + tracked.delete(child); + if (child.exitCode !== null || child.killed) { child.stdout?.destroy(); child.stderr?.destroy(); @@ -206,11 +337,11 @@ function killChild(child: ChildProcess): Promise { }; child.once('close', onClose); - child.kill('SIGTERM'); + killGroupSync(child, 'SIGTERM'); killTimer = setTimeout(() => { killTimer = undefined; - child.kill('SIGKILL'); + killGroupSync(child, 'SIGKILL'); }, 5000); // Ensure the timer does not prevent Node from exiting. From d42dd00b7491a8d2c820c71430d551727d3bf7ec Mon Sep 17 00:00:00 2001 From: Gregorio Juliana Date: Thu, 9 Jul 2026 12:32:24 +0200 Subject: [PATCH 2/4] fix(ethereum): restore anvil parent-death watchdog for orphan cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure-Node signal/exit handlers in startAnvil only reap anvil when node runs its own handlers (explicit stop, Ctrl+C, normal exit, uncaught exception). On a parent hard-kill (SIGKILL/crash/OOM) they never run, and the detached anvil is orphaned — verified empirically. That is the exact failure the removed anvil_kill_wrapper.sh guarded against and a known source of CI flakiness. Spawn anvil under a small inlined bash watchdog that polls its parent (this node process) and reaps anvil via an EXIT/INT/TERM trap when the parent dies for any reason. Inlined (not a shipped .sh) so it works from the published npm tarball, with the resolved anvil binary passed via $ANVIL_BIN so it works without anvil on PATH. --- yarn-project/ethereum/src/test/start_anvil.ts | 98 ++++++------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index 4a4af18a30bb..408f469d12cb 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -15,58 +15,25 @@ export interface Anvil { stop(): Promise; } -// Every anvil we spawn is its own process-group leader (`detached: true`), so killing the group -// (`process.kill(-pid, …)`) tears down anvil and anything it forked. We track the live children and -// install best-effort signal/exit handlers so an uncleanly-killed test runner (Ctrl+C, crash) does -// not leave orphan anvils behind — this replaces the parent-polling shell wrapper we used to spawn. -const tracked = new Set(); -let handlersInstalled = false; - -function installHandlers(): void { - if (handlersInstalled) { - return; - } - handlersInstalled = true; - - // Async work isn't allowed in `exit` handlers, so SIGKILL the groups synchronously. - process.on('exit', () => { - for (const child of tracked) { - killGroupSync(child, 'SIGKILL'); - } - }); - - // Nuke children synchronously, then re-raise the signal so the test runner exits conventionally - // (and its own teardown still runs, unlike a bare `process.exit`). - for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { - process.on(signal, () => { - for (const child of tracked) { - killGroupSync(child, 'SIGKILL'); - } - process.removeAllListeners(signal); - process.kill(process.pid, signal); - }); - } -} - -function killGroupSync(child: ChildProcess, signal: NodeJS.Signals): void { - if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { - return; - } - try { - if (process.platform === 'win32') { - child.kill(signal); - } else { - // Negative PID → kill the whole process group. Requires `detached: true` at spawn time. - process.kill(-child.pid, signal); - } - } catch { - try { - child.kill(signal); - } catch { - /* already dead */ - } - } -} +// Watchdog wrapper: instead of spawning anvil directly, we spawn a small bash supervisor that runs +// anvil as a background child and polls its own parent (this node process). If the parent dies for +// ANY reason — including SIGKILL / crash / OOM, where node's own exit handlers never run — the poll +// loop ends and the EXIT trap reaps anvil. This is the guarantee the old `anvil_kill_wrapper.sh` +// gave us; orphan anvils holding ports were a long-standing source of CI flakiness. The script is +// inlined (rather than shipped as a `.sh`) so it works from the published npm tarball too, and the +// resolved anvil binary is passed via `$ANVIL_BIN` so it works without `anvil` on PATH. +// +// `$@` is the anvil argv; `bash -c