Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions yarn-project/aztec/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ function test_cmds {
# All CLI tests share test/mixed-workspace/target so they must run sequentially
# in a single jest invocation (--runInBand is set by run_test.sh).
echo "$hash:ISOLATE=1:NAME=aztec/cli NARGO=$NARGO BB=$BB PROFILER_PATH=$PROFILER_PATH yarn-project/scripts/run_test.sh aztec/src/cli"
# setupLocalNetwork smoke test: spins up anvil + an in-process node (network usage ⇒ ISOLATE).
echo "$hash:ISOLATE=1:NAME=aztec/testing yarn-project/scripts/run_test.sh aztec/src/testing/local-network.test.ts"
}

case "$cmd" in
Expand Down
7 changes: 6 additions & 1 deletion yarn-project/aztec/src/local-network/local-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -176,7 +178,10 @@ export async function createLocalNetwork(config: Partial<LocalNetworkConfig> = {
...(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();

Expand Down
1 change: 1 addition & 0 deletions yarn-project/aztec/src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -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';
55 changes: 55 additions & 0 deletions yarn-project/aztec/src/testing/local-network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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';

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);

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);
});
93 changes: 93 additions & 0 deletions yarn-project/aztec/src/testing/local-network.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

/** 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<AztecNodeConfig>;
/** 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<LocalNetwork> {
// `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;
51 changes: 0 additions & 51 deletions yarn-project/ethereum/scripts/anvil_kill_wrapper.sh

This file was deleted.

115 changes: 108 additions & 7 deletions yarn-project/ethereum/src/test/start_anvil.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -14,6 +15,97 @@ export interface Anvil {
stop(): Promise<void>;
}

// 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. 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 <script> bash <...args>` puts the args in `$@` and `$0` = 'bash'.
//
// The EXIT trap reaps anvil; INT/TERM just `exit` (which fires the EXIT trap) so a signal terminates
// the supervisor promptly instead of being swallowed — a trapped TERM does NOT terminate the shell,
// so trapping the kill directly on TERM would leave the poll loop running and the caller's teardown
// hanging until its SIGKILL escalation. `sleep & wait` makes the poll interruptible, so INT/TERM are
// handled immediately rather than after the current `sleep` returns.
const ANVIL_WATCHDOG = `
set -u
parent=$PPID
"$ANVIL_BIN" "$@" &
anvil_pid=$!
trap 'kill "$anvil_pid" 2>/dev/null' EXIT
trap 'exit 0' INT TERM
while kill -0 "$parent" 2>/dev/null; do sleep 1 & wait $!; done
`;

/**
* 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.
*/
Expand Down Expand Up @@ -42,14 +134,18 @@ export async function startAnvil(
dateProvider?: TestDateProvider;
} = {},
): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise<void> }> {
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;

let detectedPort: number | undefined;

const anvil = await retry(
async () => {
// `--port 0` lets anvil bind an OS-assigned ephemeral port; the actual port is read back from
// its "Listening on host:port" stdout below, so independent suites can 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',
Expand All @@ -71,9 +167,11 @@ export async function startAnvil(
}
args.push('--slots-in-an-epoch', String(opts.slotsInAnEpoch ?? 1));

const child = spawn(anvilBinary, args, {
// Spawn the watchdog (see ANVIL_WATCHDOG). It launches anvil with these args and reaps it if we
// die; `$0` is 'bash' and `$@` is the anvil argv.
const child = spawn('bash', ['-c', ANVIL_WATCHDOG, 'bash', ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, RAYON_NUM_THREADS: '1' },
env: { ...process.env, ANVIL_BIN: anvilBinary, RAYON_NUM_THREADS: '1' },
});

// Wait for "Listening on" or an early exit.
Expand Down Expand Up @@ -183,7 +281,10 @@ function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDatePro
}
}

/** Send SIGTERM, wait up to 5 s, then SIGKILL. All timers are always cleared. */
/**
* Send SIGTERM to the watchdog, wait up to 5 s, then SIGKILL. The watchdog's trap forwards the
* signal to anvil, so terminating it tears down anvil too. All timers are always cleared.
*/
function killChild(child: ChildProcess): Promise<void> {
return new Promise<void>(resolve => {
if (child.exitCode !== null || child.killed) {
Expand Down
Loading