Skip to content

Commit a96d070

Browse files
Thunkaraztec-bot
authored andcommitted
feat: exported in-process testing network (#24629)
Upstreaming the utilities build for `aztec-kit` so external projects can benefit from our e2e scaffolding. Essentially the same thing we already use, but pure ts rather than the unpublishable .sh script. Verified with `ci-full-no-test-cache` (cherry picked from commit 7e09008)
1 parent 3949757 commit a96d070

11 files changed

Lines changed: 278 additions & 69 deletions

File tree

l1-contracts/scripts/forge_broadcast.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ const timeoutMs =
5959
(isAnvil ? 120_000 : 1_200_000);
6060

6161
const proc = spawn(
62-
"forge",
62+
process.env.FORGE_BIN || "forge",
6363
["script", ...args, "--broadcast", "--batch-size", batchSize],
6464
{
6565
stdio: ["ignore", "pipe", "inherit"],
66-
},
66+
}
6767
);
6868

6969
const stdout = [];
@@ -85,14 +85,14 @@ const exitCode = await new Promise((resolve) => {
8585
});
8686
proc.on("close", (code) => {
8787
clearTimeout(timeout);
88-
resolve(timedOut ? 1 : (code ?? 1));
88+
resolve(timedOut ? 1 : code ?? 1);
8989
});
9090
});
9191

9292
log(
9393
exitCode === 0
9494
? "Broadcast succeeded."
95-
: `Broadcast failed (exit ${exitCode}).`,
95+
: `Broadcast failed (exit ${exitCode}).`
9696
);
9797
const data = Buffer.concat(stdout);
9898
if (data.length > 0) writeSync(1, data);

yarn-project/aztec/bootstrap.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ function test_cmds {
1212
# All CLI tests share test/mixed-workspace/target so they must run sequentially
1313
# in a single jest invocation (--runInBand is set by run_test.sh).
1414
echo "$hash:ISOLATE=1:NAME=aztec/cli NARGO=$NARGO BB=$BB PROFILER_PATH=$PROFILER_PATH yarn-project/scripts/run_test.sh aztec/src/cli"
15+
# setupLocalNetwork smoke test: spins up anvil + an in-process node (network usage ⇒ ISOLATE).
16+
echo "$hash:ISOLATE=1:NAME=aztec/testing yarn-project/scripts/run_test.sh aztec/src/testing/local-network.test.ts"
1517
}
1618

1719
case "$cmd" in

yarn-project/aztec/src/local-network/local-network.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export type LocalNetworkConfig = AztecNodeConfig & {
8888
l1Mnemonic: string;
8989
/** Whether to deploy test accounts on local network start.*/
9090
testAccounts: boolean;
91+
/** Override the default per-address fee juice granted at genesis to funded addresses. */
92+
initialAccountFeeJuice?: Fr;
9193
};
9294

9395
/**
@@ -176,7 +178,10 @@ export async function createLocalNetwork(config: Partial<LocalNetworkConfig> = {
176178
...(initialAccounts.length ? [bananaFPC, sponsoredFPC] : []),
177179
...prefundAddresses,
178180
];
179-
const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues(fundedAddresses);
181+
const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues(
182+
fundedAddresses,
183+
config.initialAccountFeeJuice,
184+
);
180185

181186
const dateProvider = new TestDateProvider();
182187

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { EthCheatCodes, RollupCheatCodes } from '@aztec/ethereum/test';
22
export { CheatCodes } from './cheat_codes.js';
33
export { EpochTestSettler } from './epoch_test_settler.js';
4+
export { setupLocalNetwork, TEST_FEE_PADDING, type LocalNetwork, type LocalNetworkOptions } from './local-network.js';
45
export { getTokenAllowedSetupFunctions } from './token_allowed_setup.js';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { getInitialTestAccountsData } from '@aztec/accounts/testing';
2+
import { TokenContract } from '@aztec/noir-contracts.js/Token';
3+
import { EmbeddedWallet } from '@aztec/wallets/embedded';
4+
5+
import { TEST_FEE_PADDING, setupLocalNetwork } from './local-network.js';
6+
7+
describe('setupLocalNetwork', () => {
8+
it('serves a live node on a random L1 port and tears down cleanly', async () => {
9+
await using net = await setupLocalNetwork();
10+
const info = await net.node.getNodeInfo();
11+
expect(info.l1ContractAddresses.rollupAddress).toBeDefined();
12+
expect(await net.node.getBlockNumber()).toBeGreaterThanOrEqual(0);
13+
expect(net.l1ChainId).toBe(31337);
14+
// OS-assigned ephemeral port, never the fixed default 8545.
15+
expect(net.l1RpcUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
16+
expect(net.l1RpcUrl).not.toContain(':8545');
17+
}, 300_000);
18+
19+
it('runs two networks in parallel on distinct ports', async () => {
20+
const [a, b] = await Promise.all([setupLocalNetwork(), setupLocalNetwork()]);
21+
try {
22+
expect(a.l1RpcUrl).not.toEqual(b.l1RpcUrl);
23+
expect(await a.node.getBlockNumber()).toBeGreaterThanOrEqual(0);
24+
expect(await b.node.getBlockNumber()).toBeGreaterThanOrEqual(0);
25+
} finally {
26+
await Promise.all([a.stop(), b.stop()]);
27+
}
28+
}, 300_000);
29+
30+
it('pre-funds addresses at genesis so they can pay for their own txs', async () => {
31+
const [alice] = await getInitialTestAccountsData();
32+
const net = await setupLocalNetwork({ fundedAddresses: [alice.address] });
33+
try {
34+
const wallet = await EmbeddedWallet.create(net.node, {
35+
ephemeral: true,
36+
pxeConfig: { proverEnabled: false },
37+
});
38+
await wallet.createSchnorrInitializerlessAccount(alice.secret, alice.salt, alice.signingKey);
39+
wallet.setMinFeePadding(TEST_FEE_PADDING);
40+
41+
const { contract } = await TokenContract.deploy(wallet, alice.address, 'TokenName', 'TKN', 18).send({
42+
from: alice.address,
43+
});
44+
expect(contract.address).toBeDefined();
45+
expect(await net.node.getBlockNumber()).toBeGreaterThan(0);
46+
47+
await wallet.stop();
48+
} finally {
49+
await net.stop();
50+
}
51+
}, 300_000);
52+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { AztecNodeService } from '@aztec/aztec-node';
2+
import type { AztecNodeConfig } from '@aztec/aztec-node/config';
3+
import type { Fr } from '@aztec/aztec.js/fields';
4+
import { startAnvil } from '@aztec/ethereum/test';
5+
import type { AztecAddress } from '@aztec/stdlib/aztec-address';
6+
7+
import { foundry } from 'viem/chains';
8+
9+
import { createLocalNetwork } from '../local-network/local-network.js';
10+
11+
/** A running in-process local network: an inline Aztec node backed by its own anvil L1. */
12+
export interface LocalNetwork extends AsyncDisposable {
13+
/** Fully-synced Aztec node, ready to serve client requests. */
14+
node: AztecNodeService;
15+
/** RPC URL of the spawned anvil instance. */
16+
l1RpcUrl: string;
17+
/** Chain id used on L1 (foundry's default 31337). */
18+
l1ChainId: number;
19+
/** Stops every process started by the fixture: node and anvil. Also invoked by `await using`. */
20+
stop: () => Promise<void>;
21+
}
22+
23+
/** Options for {@link setupLocalNetwork}. */
24+
export interface LocalNetworkOptions {
25+
/**
26+
* Addresses that should hold fee juice at genesis. Saves each of these the round-trip of bridging
27+
* + claiming fee juice before they can pay for gas.
28+
*/
29+
fundedAddresses?: AztecAddress[];
30+
/** Override the default per-address genesis fee juice granted to {@link fundedAddresses}. */
31+
initialAccountFeeJuice?: Fr;
32+
/** Node config overrides, e.g. `realProofs`, `aztecEpochDuration`, `p2pEnabled`. */
33+
config?: Partial<AztecNodeConfig>;
34+
}
35+
36+
/**
37+
* Spin up an in-process local network with the given addresses pre-funded.
38+
*
39+
* Each call spawns its own anvil on an OS-assigned random port and runs the Aztec node inline via
40+
* the same {@link createLocalNetwork} codepath that backs `aztec start --local-network` (with the
41+
* sandbox account/FPC/token setup skipped). Distinct ports let independent suites run in parallel.
42+
* The caller must `await result.stop()` in its teardown (or hold the result with `await using`).
43+
*
44+
* Requires a Foundry toolchain (`anvil`/`forge`), installed via `aztec-up` or `foundryup`. Binaries
45+
* are located in the standard install directories or on `PATH`; set `$ANVIL_BIN` / `$FORGE_BIN` to
46+
* pin specific ones.
47+
*/
48+
export async function setupLocalNetwork(opts: LocalNetworkOptions = {}): Promise<LocalNetwork> {
49+
// `--port 0` → anvil binds an ephemeral port that `startAnvil` reads back, so parallel suites
50+
// never collide on a fixed port.
51+
const { rpcUrl, stop: stopAnvil } = await startAnvil({ port: 0 });
52+
53+
try {
54+
const { node, stop: stopNode } = await createLocalNetwork(
55+
{
56+
...opts.config,
57+
l1RpcUrls: [rpcUrl],
58+
testAccounts: false,
59+
prefundAddresses: (opts.fundedAddresses ?? []).map(a => a.toString()),
60+
initialAccountFeeJuice: opts.initialAccountFeeJuice,
61+
},
62+
() => {},
63+
);
64+
65+
// Stop the node before anvil (its teardown still talks to L1); the finally guarantees anvil is
66+
// reaped even if node shutdown throws.
67+
const stop = async () => {
68+
try {
69+
await stopNode();
70+
} finally {
71+
await stopAnvil();
72+
}
73+
};
74+
75+
return {
76+
node,
77+
l1RpcUrl: rpcUrl,
78+
l1ChainId: foundry.id,
79+
stop,
80+
[Symbol.asyncDispose]: stop,
81+
};
82+
} catch (err) {
83+
await stopAnvil();
84+
throw err;
85+
}
86+
}
87+
88+
/**
89+
* Min-fee padding multiplier for test wallets whose txs may mine well after their fee estimate.
90+
* The automine sequencer builds one block per tx and advances L1 time in big jumps, and proposer
91+
* pipelining evolves the fee-asset price across the build/publish gap (~20x observed in CI), so the
92+
* network's congestion base fee can swing sharply between the wallet's fee estimate and the block
93+
* the tx actually lands in. The default wallet padding isn't enough and trips
94+
* `maxFeesPerGas.feePerL2Gas must be >= gasFees.feePerL2Gas`. Apply via
95+
* `wallet.setMinFeePadding(TEST_FEE_PADDING)` on every test wallet that sends txs.
96+
*/
97+
export const TEST_FEE_PADDING = 30;

yarn-project/end-to-end/src/fixtures/fixtures.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AztecNode } from '@aztec/aztec.js/node';
2+
import { TEST_FEE_PADDING } from '@aztec/aztec/testing';
23
import type { GasFees } from '@aztec/stdlib/gas';
34

45
export const METRICS_PORT = 4318;
@@ -17,9 +18,10 @@ export const LARGE_MIN_FEE_PADDING = 15;
1718
* price modifier evolves faster across the build/publish gap, so client-set maxFeesPerGas (sized
1819
* for the default 5x padding) was getting bumped past by the time the tx mined a few slots later.
1920
* Observed worst case in CI: fee evolved ~20x between PXE snapshot and inclusion, exceeding even
20-
* LARGE_MIN_FEE_PADDING (15x).
21+
* LARGE_MIN_FEE_PADDING (15x). Same multiplier and same class of problem as the published
22+
* {@link TEST_FEE_PADDING}, re-exported under a name that captures the pipelining rationale.
2123
*/
22-
export const PIPELINED_FEE_PADDING = 30;
24+
export const PIPELINED_FEE_PADDING = TEST_FEE_PADDING;
2325

2426
/**
2527
* Setup option preset that opts a test into proposer pipelining. Use with `setup()`:
@@ -77,7 +79,7 @@ export const AUTOMINE_E2E_OPTS = {
7779
minTxsPerBlock: 0,
7880
aztecSlotDuration: 12,
7981
ethereumSlotDuration: 4,
80-
walletMinFeePadding: PIPELINED_FEE_PADDING,
82+
walletMinFeePadding: TEST_FEE_PADDING,
8183
} as const;
8284

8385
/** Returns worst-case predicted min fees with padding applied, mirroring the BaseWallet pattern. */

yarn-project/ethereum/scripts/anvil_kill_wrapper.sh

Lines changed: 0 additions & 51 deletions
This file was deleted.

yarn-project/ethereum/src/deploy_aztec_l1_contracts.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { createExtendedL1Client } from './client.js';
2222
import { type L1ContractsConfig, assertValidSlotDurations } from './config.js';
2323
import { deployMulticall3 } from './contracts/multicall.js';
2424
import { RollupContract } from './contracts/rollup.js';
25+
import { resolveFoundryBinary } from './foundry_binary.js';
2526
import type { L1ContractAddresses } from './l1_contract_addresses.js';
2627
import type { ExtendedViemWalletClient } from './types.js';
2728

@@ -95,11 +96,16 @@ function runProcess<T>(
9596

9697
// Covers an edge where where we may have a cached BlobLib that is not meant for production.
9798
// Despite the profile apparently sometimes cached code remains (so says Lasse after his ignition-monorepo arc).
98-
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+
) {
99105
if (chainId === mainnet.id) {
100106
logger.info(`Recompiling ${script} with production profile for mainnet deployment`);
101107
logger.info('This may take a minute but ensures production BlobLib is used.');
102-
await runProcess('forge', ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
108+
await runProcess(forgeBin, ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath);
103109
}
104110
}
105111

@@ -320,8 +326,9 @@ export async function deployAztecL1Contracts(
320326
// Use foundry-artifacts from l1-artifacts package
321327
const l1ContractsPath = prepareL1ContractsForDeployment();
322328

329+
const forgeBin = resolveFoundryBinary('forge');
323330
const FORGE_SCRIPT = 'script/deploy/DeployAztecL1Contracts.s.sol';
324-
await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
331+
await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
325332

326333
// Verify contracts on Etherscan when on mainnet/sepolia and ETHERSCAN_API_KEY is available.
327334
const isVerifiableChain = chainId === mainnet.id || chainId === sepolia.id;
@@ -346,6 +353,8 @@ export async function deployAztecL1Contracts(
346353
...(shouldVerify ? ['--verify'] : []),
347354
];
348355
const forgeEnv = {
356+
// Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
357+
FORGE_BIN: forgeBin,
349358
// Env vars required by l1-contracts/script/deploy/DeploymentConfiguration.sol.
350359
NETWORK: getActiveNetworkName(),
351360
FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
@@ -618,12 +627,15 @@ export const deployRollupForUpgrade = async (
618627
// Use foundry-artifacts from l1-artifacts package
619628
const l1ContractsPath = prepareL1ContractsForDeployment();
620629

630+
const forgeBin = resolveFoundryBinary('forge');
621631
const FORGE_SCRIPT = 'script/deploy/DeployRollupForUpgrade.s.sol';
622-
await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId);
632+
await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId);
623633

624634
const scriptPath = join(getL1ContractsPath(), 'scripts', 'forge_broadcast.js');
625635
const forgeArgs = [FORGE_SCRIPT, '--sig', 'run()', '--private-key', privateKey, '--rpc-url', rpcUrl];
626636
const forgeEnv = {
637+
// Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH.
638+
FORGE_BIN: forgeBin,
627639
FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined,
628640
// Env vars required by l1-contracts/script/deploy/RollupConfiguration.sol.
629641
REGISTRY_ADDRESS: registryAddress.toString(),

0 commit comments

Comments
 (0)