Skip to content

Commit a176d96

Browse files
spalladinoaztec-bot
authored andcommitted
perf(e2e): seed standard contracts at genesis (#24568)
Round-4 e2e speedup, PR 1b (adoption half of the AuthRegistry prize). **Stacked on #24567** (`spl/genesis-prefilled-nullifiers`, the genesis-nullifier mechanism). This PR targets that branch; retarget to `merge-train/spartan-v5` once #24567 merges. ## What Make e2e environments start with the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) already "published", so `ensureAuthRegistryPublished` and its two siblings skip their two publish txs each. At production cadence `setup:auth-registry` alone was ~16 min/run (2 sequential txs per process, paid per-process across the suite). This collapses that span to sub-second. ## Mechanism (three parts, all default-off outside e2e) - **Genesis nullifiers.** Per standard contract, seed `siloNullifier(ContractClassRegistry, classId)` and `siloNullifier(ContractInstanceRegistry, instanceAddress)` (the real derived address — standard contracts are not magic-address protocol contracts) into the fixtures' `getGenesisValues` call, via PR 1a's new 5th param. These are exactly the nullifiers the publish txs would emit, so the AVM's deployment-nullifier check passes when the contracts are called publicly. A single helper (`getStandardContractGenesisNullifiers`) feeds all four e2e genesis builders (`fixtures/setup.ts`, `e2e_prover_test.ts`, `p2p/p2p_network.ts`, `multi-node/governance/add_rollup.test.ts`) — the latter three recompute a genesis that must reproduce the L1-deployed archive root, so they must seed the identical set. Non-e2e callers (cli, sandbox/local-network) are untouched. - **Archiver preload.** `registerStandardContracts` mirrors `registerProtocolContracts`: it seeds each contract's class (with recomputed `publicBytecodeCommitment`), instance, and public-function signatures into the contract store at block 0, reading the bundled `@aztec/standard-contracts` artifacts. It is idempotent (skips already-registered classes on restart) and gated behind a new archiver config flag `testPreloadStandardContracts` (env `TEST_PRELOAD_STANDARD_CONTRACTS`, default **false**). The flag is set from the e2e node config so every spawned node (validators, prover nodes) picks it up through the normal config path. This adds `@aztec/standard-contracts` as an archiver dependency. - **Guard short-circuit.** The `ensure*Published` helpers are unchanged. Their guards read `wallet.getContractClassMetadata(id).isContractClassPubliclyRegistered` (to `aztecNode.getContractClass`) and `wallet.getContractMetadata(addr).isContractPublished` (to `aztecNode.getContract`) — both are archiver-store reads, not nullifier-tree reads. The store preload alone makes both short-circuit; the genesis nullifiers are what make the contract actually callable in the AVM afterwards. Keeping the helpers doubles as a regression check: if seeding ever breaks, the tx path re-engages and tests still pass, just slow. ## Why the flag is test-only (A-1257 rationale) Preloading unconditionally in production would recreate the A-1257 collision #24254 fixed: a real on-chain publish of a preloaded class would collide with the block-0 preload, because production genesis will **not** carry the matching nullifiers. The flag is only set by the e2e fixtures, which also seed the nullifiers, keeping the store and the nullifier tree consistent. A single source of truth (`getPublishableStandardContracts`) drives both the preloaded set and the seeded-nullifier set so they cannot drift. ## State-write verification Inspected the two publish txs on this base: - `ContractClassRegistry.publish` pushes one nullifier (`classId`, siloed to the class-registry address) and broadcasts a `ContractClassPublished` contract-class log carrying the packed bytecode. No public-data writes. - `ContractInstanceRegistry.publish_for_public_execution` asserts the class-registration nullifier exists, then pushes one nullifier (`address`, siloed to the instance-registry address) and broadcasts a `ContractInstancePublished` private log. No public-data writes. So the full state-write set is two nullifiers + two broadcast logs. The nullifiers are replicated via genesis `prefilledNullifiers`; the archiver normally learns the class/instance from those two logs (`data_store_updater.ts`), and the block-0 preload replaces that path exactly. No `genesisPublicData` is required. PXE-side registration (`wallet.registerContract`) is unaffected and still runs in the helpers. ## Local verification - `automine/accounts/authwit.test.ts` (public-authwit path against the standard AuthRegistry), timing on: passes; `setup:auth-registry` span = **10 ms** (was ~33 s at production cadence). No on-chain AuthRegistry publish (the only `ContractClassRegistry.publish` executions are the test's own AuthWitTest/GenericProxy deploys); the public AuthRegistry contract executes fine against the seeded nullifier. `testPreloadStandardContracts: true` confirmed in the node config dump. - `single-node/fees/account_init.test.ts` (production sequencer + simulated prover node), timing on: 5/5 pass; `setup:auth-registry` span = **10 ms**. Prover node syncs against the seeded genesis (no root divergence), and the flag propagates to it. No duplicate-nullifier errors. - New unit test in `archiver/src/modules/data_store_updater.test.ts`: `registerStandardContracts` preloads each publishable standard contract's class + instance into the store and is idempotent on a second call. ## Expected impact ~14–16 min/run from auth-registry alone, plus more from the two untagged siblings (PublicChecks / HandshakeRegistry) wherever used. ## Measured impact `setup:auth-registry` collapses from 702.2s of setup time run-wide (39 occurrences, 5–35s each) to 482ms total (max 87ms per occurrence) — sub-second run-wide, every shard clean, no seeding or flag-propagation slow path. - The measured beforeHooks reduction is larger than the auth-registry span alone, because the two untagged sibling publishes (PublicChecks, HandshakeRegistry) are removed too. Example: account_init beforeHooks −49.6s vs a 30.9s tagged auth-registry span; the extra ~19s is the two siblings. - Per-suite beforeHooks deltas: account_init −49.6s, fee_settings −49.5s, failures −48.7s, private_payments.parallel −41.8s/shard (×8), gas_estimation.parallel −41.5s/shard (×3), l1_to_l2 −38.1s, l2_to_l1 −37.5s, l1_to_l2_inbox_drift −38.2s, token_bridge −37.7s, fee_juice_payments −37.4s, bot −34.3s; automine/parallel suites shed 5–15s each (their fast-cadence publishes). - Total beforeHooks reduction over the suites present in both runs: −1,649.7s summed across shards (wall-clock benefit is smaller, since shards run in parallel across workers). Baseline CI run 1783389062016888 (#24567 `ci/x-fast`; mechanism-only, so timing-neutral vs merge-base). PR CI run 1783391194852197 (#24568 `ci/x-fast`). ## Notes for downstream - **PR 6 (fees harness):** `applyEnsureAuthRegistryPublished` now short-circuits; the fees setup chain loses the ~32 s/shard auth-registry step, so rebase the overlap/batch shape onto what remains (token deploy, FPC, mints). - **Fix phase / timing capture:** expected span assertion is `setup:auth-registry` around sub-second across the run (10 ms observed locally per process). Any shard still showing tens of seconds means the guards took the slow path (a seeding or flag-propagation bug), not noise. Fixes A-1404 (cherry picked from commit f320049)
1 parent 915501a commit a176d96

15 files changed

Lines changed: 156 additions & 2 deletions

File tree

yarn-project/archiver/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@aztec/l1-artifacts": "portal:../../l1-contracts/l1-artifacts",
7676
"@aztec/noir-protocol-circuits-types": "workspace:^",
7777
"@aztec/protocol-contracts": "workspace:^",
78+
"@aztec/standard-contracts": "workspace:^",
7879
"@aztec/stdlib": "workspace:^",
7980
"@aztec/telemetry-client": "workspace:^",
8081
"lodash.groupby": "^4.6.0",

yarn-project/archiver/src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ export const archiverConfigMappings: ConfigMappingsType<ArchiverConfig> = {
9090
description: 'Skip pruning orphan proposed blocks that have no matching proposed checkpoint.',
9191
...booleanConfigHelper(false),
9292
},
93+
testPreloadStandardContracts: {
94+
env: 'TEST_PRELOAD_STANDARD_CONTRACTS',
95+
description:
96+
'Preload the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) into the contract store at ' +
97+
'block 0. For test environments only, and only safe when genesis seeds the matching registration/deployment ' +
98+
'nullifiers; otherwise a later on-chain publish would collide with the block-0 preload.',
99+
...booleanConfigHelper(false),
100+
},
93101
...chainConfigMappings,
94102
...l1ReaderConfigMappings,
95103
viemPollingIntervalMS: {

yarn-project/archiver/src/factory.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { DateProvider } from '@aztec/foundation/timer';
1212
import { createStore } from '@aztec/kv-store/lmdb-v2';
1313
import { protocolContractNames } from '@aztec/protocol-contracts';
1414
import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle';
15+
import { getPublishableStandardContracts } from '@aztec/standard-contracts';
1516
import { FunctionType, decodeFunctionSignature } from '@aztec/stdlib/abi';
1617
import type { ArchiverEmitter, BlockHash } from '@aztec/stdlib/block';
1718
import { DEFAULT_BLOCK_DURATION_MS } from '@aztec/stdlib/config';
@@ -68,6 +69,9 @@ export async function createArchiver(
6869
): Promise<Archiver> {
6970
const archiverStore = await createArchiverStore(config, initialBlockHash);
7071
await registerProtocolContracts(archiverStore);
72+
if (config.testPreloadStandardContracts) {
73+
await registerStandardContracts(archiverStore);
74+
}
7175

7276
// Create Ethereum clients
7377
const chain = createEthereumChain(config.l1RpcUrls, config.l1ChainId);
@@ -227,3 +231,33 @@ export async function registerProtocolContracts(stores: ArchiverDataStores) {
227231
await stores.contractInstances.addContractInstances([contract.instance], BlockNumber(blockNumber));
228232
}
229233
}
234+
235+
/**
236+
* Preloads the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) into the archiver store at block 0,
237+
* mirroring {@link registerProtocolContracts}. Only invoked for test environments (via `testPreloadStandardContracts`),
238+
* which also seed the matching registration/deployment nullifiers into the genesis nullifier tree so the store and tree
239+
* stay consistent. Idempotent — skips contracts that already exist (e.g. on node restart).
240+
*/
241+
export async function registerStandardContracts(stores: ArchiverDataStores) {
242+
const blockNumber = 0;
243+
for (const contract of await getPublishableStandardContracts()) {
244+
// Skip if already registered (happens on node restart with a persisted store).
245+
if (await stores.contractClasses.getContractClass(contract.contractClass.id)) {
246+
continue;
247+
}
248+
249+
const publicBytecodeCommitment = await computePublicBytecodeCommitment(contract.contractClass.packedBytecode);
250+
const contractClassPublic: ContractClassPublicWithCommitment = {
251+
...contract.contractClass,
252+
publicBytecodeCommitment,
253+
};
254+
255+
const publicFunctionSignatures = contract.artifact.functions
256+
.filter(fn => fn.functionType === FunctionType.PUBLIC)
257+
.map(fn => decodeFunctionSignature(fn.name, fn.parameters));
258+
259+
await stores.functionNames.register(publicFunctionSignatures);
260+
await stores.contractClasses.addContractClasses([contractClassPublic], BlockNumber(blockNumber));
261+
await stores.contractInstances.addContractInstances([contract.instance], BlockNumber(blockNumber));
262+
}
263+
}

yarn-project/archiver/src/modules/data_store_updater.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ProtocolContractAddress } from '@aztec/protocol-contracts';
66
import { ContractClassPublishedEvent } from '@aztec/protocol-contracts/class-registry';
77
import { ContractInstancePublishedEvent } from '@aztec/protocol-contracts/instance-registry';
88
import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle';
9+
import { getPublishableStandardContracts } from '@aztec/standard-contracts';
910
import { bufferAsFields } from '@aztec/stdlib/abi';
1011
import { AztecAddress } from '@aztec/stdlib/aztec-address';
1112
import { GENESIS_BLOCK_HEADER_HASH, L2Block } from '@aztec/stdlib/block';
@@ -19,7 +20,7 @@ import { readFileSync } from 'fs';
1920
import { dirname, resolve } from 'path';
2021
import { fileURLToPath } from 'url';
2122

22-
import { registerProtocolContracts } from '../factory.js';
23+
import { registerProtocolContracts, registerStandardContracts } from '../factory.js';
2324
import { type ArchiverDataStores, createArchiverDataStores } from '../store/data_stores.js';
2425
import { L2TipsCache } from '../store/l2_tips_cache.js';
2526
import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js';
@@ -149,6 +150,32 @@ describe('ArchiverDataStoreUpdater', () => {
149150
expect(await store.contractClasses.getContractClass(protocolClassId)).toBeDefined();
150151
});
151152

153+
it('preloads standard contract classes and instances via registerStandardContracts', async () => {
154+
const standardContracts = await getPublishableStandardContracts();
155+
expect(standardContracts.length).toBeGreaterThan(0);
156+
157+
// Not present before the preload.
158+
for (const { contractClass } of standardContracts) {
159+
expect(await store.contractClasses.getContractClass(contractClass.id)).toBeUndefined();
160+
}
161+
162+
await registerStandardContracts(store);
163+
164+
// Both the class and the instance are queryable from the block-0 preload.
165+
for (const { contractClass, address } of standardContracts) {
166+
const retrievedClass = await store.contractClasses.getContractClass(contractClass.id);
167+
expect(retrievedClass?.id.equals(contractClass.id)).toBe(true);
168+
const retrievedInstance = await store.contractInstances.getContractInstance(address, 1n);
169+
expect(retrievedInstance?.address.equals(address)).toBe(true);
170+
}
171+
172+
// Calling again (e.g. on node restart with a persisted store) is idempotent and must not throw.
173+
await expect(registerStandardContracts(store)).resolves.not.toThrow();
174+
for (const { contractClass } of standardContracts) {
175+
expect(await store.contractClasses.getContractClass(contractClass.id)).toBeDefined();
176+
}
177+
});
178+
152179
it('removes contract class and instance data when blocks are pruned via setCheckpointData', async () => {
153180
// First, add a local provisional block with contract data
154181
const localBlock = await L2Block.random(BlockNumber(1), {

yarn-project/archiver/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
{
3434
"path": "../protocol-contracts"
3535
},
36+
{
37+
"path": "../standard-contracts"
38+
},
3639
{
3740
"path": "../stdlib"
3841
},

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { TestWallet } from '../test-wallet/test_wallet.js';
2424
import { getACVMConfig } from './get_acvm_config.js';
2525
import { getBBConfig } from './get_bb_config.js';
2626
import { getPrivateKeyFromIndex, getSponsoredFPCAddress, setup, setupPXEAndGetWallet } from './setup.js';
27+
import { getStandardContractGenesisNullifiers } from './standard_contracts_genesis.js';
2728

2829
type ProvenSetup = {
2930
wallet: TestWallet;
@@ -229,6 +230,7 @@ export class FullProverTest extends SingleNodeTestContext {
229230
undefined,
230231
undefined,
231232
this.context.genesis!.genesisTimestamp,
233+
await getStandardContractGenesisNullifiers(),
232234
);
233235

234236
const proverNodeConfig: Parameters<typeof createAztecNodeService>[0] = {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { MNEMONIC, TEST_MAX_PENDING_TX_POOL_COUNT, TEST_PEER_CHECK_INTERVAL_MS }
7777
import { getACVMConfig } from './get_acvm_config.js';
7878
import { getBBConfig } from './get_bb_config.js';
7979
import { isMetricsLoggingRequested, setupMetricsLogger } from './logging.js';
80+
import { getStandardContractGenesisNullifiers } from './standard_contracts_genesis.js';
8081
import { testSpan } from './timing.js';
8182
import { getEndToEndTestTelemetryClient } from './with_telemetry_utils.js';
8283

@@ -474,12 +475,20 @@ async function setupInner<TDeployExtraL1ContractsReturnType = unknown>(
474475
}
475476
logger.trace('Generated test accounts to fund at genesis');
476477

478+
// Preload the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) so their `ensure*Published` setup
479+
// helpers short-circuit their publish txs. The archiver flag seeds the bytecode/instance into every spawned node's
480+
// contract store; the genesis nullifiers make the AVM's deployment-nullifier check pass when they are called. Both
481+
// must go together (flag alone would recreate the publish-collision bug), so the flag lives here beside the seeding.
482+
config.testPreloadStandardContracts = true;
483+
const standardContractNullifiers = await getStandardContractGenesisNullifiers();
484+
477485
const genesisTimestamp = BigInt(Math.floor(Date.now() / 1000));
478486
const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues(
479487
addressesToFund,
480488
opts.initialAccountFeeJuice,
481489
opts.genesisPublicData,
482490
genesisTimestamp,
491+
standardContractNullifiers,
483492
);
484493
logger.trace('Computed genesis values');
485494

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Fr } from '@aztec/foundation/curves/bn254';
2+
import { ProtocolContractAddress } from '@aztec/protocol-contracts';
3+
import { getPublishableStandardContracts } from '@aztec/standard-contracts';
4+
import { siloNullifier } from '@aztec/stdlib/hash';
5+
6+
/**
7+
* Computes the genesis nullifiers that pre-publish the standard contracts (AuthRegistry, PublicChecks,
8+
* HandshakeRegistry) in e2e environments, mirroring the nullifiers their on-chain publish txs would emit. Per contract:
9+
* - `siloNullifier(ContractClassRegistry, classId)` — the class-registration nullifier that `ContractClassRegistry.publish` pushes.
10+
* - `siloNullifier(ContractInstanceRegistry, instanceAddress)` — the instance-deployment nullifier that
11+
* `ContractInstanceRegistry.publish_for_public_execution` pushes, using the contract's real derived address (standard
12+
* contracts are deployed at artifact-derived addresses, not magic protocol addresses).
13+
*
14+
* Seed these into the genesis nullifier tree (as the 5th `getGenesisValues` arg) alongside the archiver's
15+
* `testPreloadStandardContracts` preload: the store preload makes the `ensure*Published` guards short-circuit, and these
16+
* nullifiers make the AVM's deployment-nullifier check pass when the contracts are called. Every e2e node genesis that
17+
* feeds an L1 `genesisArchiveRoot` must seed the same set, or the world-state root diverges from the deployed rollup.
18+
*/
19+
export async function getStandardContractGenesisNullifiers(): Promise<Fr[]> {
20+
const classRegistry = ProtocolContractAddress.ContractClassRegistry;
21+
const instanceRegistry = ProtocolContractAddress.ContractInstanceRegistry;
22+
const nullifiers: Fr[] = [];
23+
for (const { contractClass, address } of await getPublishableStandardContracts()) {
24+
nullifiers.push(await siloNullifier(classRegistry, contractClass.id));
25+
nullifiers.push(await siloNullifier(instanceRegistry, address.toField()));
26+
}
27+
return nullifiers;
28+
}

yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { type Hex, decodeEventLog, encodeFunctionData, getAddress, getContract }
3434
import { foundry } from 'viem/chains';
3535

3636
import { sendL1ToL2Message } from '../../fixtures/l1_to_l2_messaging.js';
37+
import { getStandardContractGenesisNullifiers } from '../../fixtures/standard_contracts_genesis.js';
3738
import { getPrivateKeyFromIndex, getSponsoredFPCAddress } from '../../fixtures/utils.js';
3839
import { TestWallet } from '../../test-wallet/test_wallet.js';
3940
import {
@@ -136,7 +137,13 @@ describe('multi-node/governance/add_rollup', () => {
136137
genesisArchiveRoot,
137138
fundingNeeded,
138139
genesis: newGenesis,
139-
} = await getGenesisValues(genesisFundedAddresses, undefined, undefined, context.genesis!.genesisTimestamp + 1n);
140+
} = await getGenesisValues(
141+
genesisFundedAddresses,
142+
undefined,
143+
undefined,
144+
context.genesis!.genesisTimestamp + 1n,
145+
await getStandardContractGenesisNullifiers(),
146+
);
140147

141148
const { rollup: newRollup } = await deployRollupForUpgrade(
142149
deployerPrivateKey,

yarn-project/end-to-end/src/p2p/p2p_network.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
createValidatorConfig,
5050
generatePrivateKeys,
5151
} from '../fixtures/setup_p2p_test.js';
52+
import { getStandardContractGenesisNullifiers } from '../fixtures/standard_contracts_genesis.js';
5253
import { getEndToEndTestTelemetryClient } from '../fixtures/with_telemetry_utils.js';
5354
import type { TestWallet } from '../test-wallet/test_wallet.js';
5455

@@ -415,6 +416,7 @@ export class P2PNetworkTest {
415416
undefined,
416417
undefined,
417418
this.context.genesis!.genesisTimestamp,
419+
await getStandardContractGenesisNullifiers(),
418420
);
419421
this.genesis = genesis;
420422

0 commit comments

Comments
 (0)