diff --git a/yarn-project/aztec/bootstrap.sh b/yarn-project/aztec/bootstrap.sh index c27fba277781..fadce4199f06 100755 --- a/yarn-project/aztec/bootstrap.sh +++ b/yarn-project/aztec/bootstrap.sh @@ -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 diff --git a/yarn-project/aztec/src/local-network/local-network.ts b/yarn-project/aztec/src/local-network/local-network.ts index eefbd37ed8df..f24bc28abd14 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; }; /** @@ -176,7 +178,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..20efcde3ca60 --- /dev/null +++ b/yarn-project/aztec/src/testing/local-network.test.ts @@ -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); +}); 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..49cc1e3d0548 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,97 @@ export interface Anvil { stop(): Promise; } +// 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