diff --git a/barretenberg/cpp/src/barretenberg/nodejs_module/world_state/world_state.cpp b/barretenberg/cpp/src/barretenberg/nodejs_module/world_state/world_state.cpp index 150540a270ca..65ffb710d2f1 100644 --- a/barretenberg/cpp/src/barretenberg/nodejs_module/world_state/world_state.cpp +++ b/barretenberg/cpp/src/barretenberg/nodejs_module/world_state/world_state.cpp @@ -48,6 +48,7 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) std::unordered_map tree_height; std::unordered_map tree_prefill; std::vector prefilled_public_data; + std::vector prefilled_nullifiers; std::vector tree_ids{ MerkleTreeId::NULLIFIER_TREE, MerkleTreeId::NOTE_HASH_TREE, MerkleTreeId::PUBLIC_DATA_TREE, MerkleTreeId::L1_TO_L2_MESSAGE_TREE, MerkleTreeId::ARCHIVE, @@ -112,7 +113,28 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) throw Napi::TypeError::New(env, "Prefilled public data must be an array"); } - size_t initial_header_generator_point_index = 4; + size_t prefilled_nullifiers_index = 4; + if (info.Length() > prefilled_nullifiers_index && info[prefilled_nullifiers_index].IsArray()) { + Napi::Array arr = info[prefilled_nullifiers_index].As(); + for (uint32_t i = 0; i < arr.Length(); ++i) { + if (!arr.Get(i).IsBuffer()) { + throw Napi::TypeError::New(env, "Prefilled nullifier value must be a buffer"); + } + Napi::Buffer nullifier_buf = arr.Get(i).As>(); + if (nullifier_buf.Length() != 32) { + throw Napi::TypeError::New(env, "Prefilled nullifier value must be a 32-byte buffer"); + } + uint256_t nullifier = 0; + for (size_t j = 0; j < 32; ++j) { + nullifier = (nullifier << 8) | nullifier_buf[j]; + } + prefilled_nullifiers.emplace_back(nullifier); + } + } else { + throw Napi::TypeError::New(env, "Prefilled nullifiers must be an array"); + } + + size_t initial_header_generator_point_index = 5; if (info.Length() > initial_header_generator_point_index && info[initial_header_generator_point_index].IsNumber()) { initial_header_generator_point = info[initial_header_generator_point_index].As().Uint32Value(); } else { @@ -120,7 +142,7 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) } uint64_t genesis_timestamp = 0; - size_t genesis_timestamp_index = 5; + size_t genesis_timestamp_index = 6; if (info.Length() > genesis_timestamp_index) { if (info[genesis_timestamp_index].IsNumber()) { genesis_timestamp = static_cast(info[genesis_timestamp_index].As().Int64Value()); @@ -130,7 +152,7 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) } // optional parameters - size_t map_size_index = 6; + size_t map_size_index = 7; if (info.Length() > map_size_index) { if (info[map_size_index].IsObject()) { Napi::Object obj = info[map_size_index].As(); @@ -160,7 +182,7 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) } } - size_t thread_pool_size_index = 7; + size_t thread_pool_size_index = 8; if (info.Length() > thread_pool_size_index) { if (!info[thread_pool_size_index].IsNumber()) { throw Napi::TypeError::New(env, "Thread pool size must be a number"); @@ -173,7 +195,7 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) // commits never block on fsync, files stay sparse, and a crash mid-write yields an // unrecoverable env. Intended for throwaway scratch state (TXE test sessions). bool ephemeral = false; - size_t ephemeral_index = 8; + size_t ephemeral_index = 9; if (info.Length() > ephemeral_index) { if (!info[ephemeral_index].IsBoolean()) { throw Napi::TypeError::New(env, "Ephemeral flag must be a boolean"); @@ -187,6 +209,7 @@ WorldStateWrapper::WorldStateWrapper(const Napi::CallbackInfo& info) tree_height, tree_prefill, prefilled_public_data, + prefilled_nullifiers, initial_header_generator_point, genesis_timestamp, ephemeral); diff --git a/barretenberg/cpp/src/barretenberg/world_state/world_state.cpp b/barretenberg/cpp/src/barretenberg/world_state/world_state.cpp index e2076c3144c5..e95a73a41413 100644 --- a/barretenberg/cpp/src/barretenberg/world_state/world_state.cpp +++ b/barretenberg/cpp/src/barretenberg/world_state/world_state.cpp @@ -39,6 +39,7 @@ WorldState::WorldState(uint64_t thread_pool_size, const std::unordered_map& tree_heights, const std::unordered_map& tree_prefill, const std::vector& prefilled_public_data, + const std::vector& prefilled_nullifiers, uint32_t initial_header_generator_point, uint64_t genesis_timestamp, bool ephemeral) @@ -51,7 +52,7 @@ WorldState::WorldState(uint64_t thread_pool_size, { // We set the max readers to be high, at least the number of given threads or the default if higher uint64_t maxReaders = std::max(thread_pool_size, DEFAULT_MIN_NUMBER_OF_READERS); - create_canonical_fork(data_dir, map_size, prefilled_public_data, maxReaders, ephemeral); + create_canonical_fork(data_dir, map_size, prefilled_public_data, prefilled_nullifiers, maxReaders, ephemeral); try { attempt_tree_resync(); } catch (std::exception& e) { @@ -73,6 +74,7 @@ WorldState::WorldState(uint64_t thread_pool_size, tree_heights, tree_prefill, std::vector(), + std::vector(), initial_header_generator_point, genesis_timestamp, ephemeral) @@ -84,6 +86,7 @@ WorldState::WorldState(uint64_t thread_pool_size, const std::unordered_map& tree_heights, const std::unordered_map& tree_prefill, const std::vector& prefilled_public_data, + const std::vector& prefilled_nullifiers, uint32_t initial_header_generator_point, uint64_t genesis_timestamp, bool ephemeral) @@ -99,6 +102,7 @@ WorldState::WorldState(uint64_t thread_pool_size, tree_heights, tree_prefill, prefilled_public_data, + prefilled_nullifiers, initial_header_generator_point, genesis_timestamp, ephemeral) @@ -118,6 +122,7 @@ WorldState::WorldState(uint64_t thread_pool_size, tree_heights, tree_prefill, std::vector(), + std::vector(), initial_header_generator_point, genesis_timestamp, ephemeral) @@ -126,6 +131,7 @@ WorldState::WorldState(uint64_t thread_pool_size, void WorldState::create_canonical_fork(const std::string& dataDir, const std::unordered_map& dbSize, const std::vector& prefilled_public_data, + const std::vector& prefilled_nullifiers, uint64_t maxReaders, bool ephemeral) { @@ -148,9 +154,15 @@ void WorldState::create_canonical_fork(const std::string& dataDir, { uint32_t levels = _tree_heights.at(MerkleTreeId::NULLIFIER_TREE); index_t initial_size = _initial_tree_size.at(MerkleTreeId::NULLIFIER_TREE); + std::vector prefilled_nullifier_leaves; + prefilled_nullifier_leaves.reserve(prefilled_nullifiers.size()); + for (const auto& nullifier : prefilled_nullifiers) { + prefilled_nullifier_leaves.emplace_back(nullifier); + } auto store = std::make_unique( getMerkleTreeName(MerkleTreeId::NULLIFIER_TREE), levels, _persistentStores->nullifierStore); - auto tree = std::make_unique(std::move(store), _workers, initial_size); + auto tree = + std::make_unique(std::move(store), _workers, initial_size, prefilled_nullifier_leaves); fork->_trees.insert({ MerkleTreeId::NULLIFIER_TREE, TreeWithStore(std::move(tree)) }); } { diff --git a/barretenberg/cpp/src/barretenberg/world_state/world_state.hpp b/barretenberg/cpp/src/barretenberg/world_state/world_state.hpp index c145138bc4e2..1a07e4e4d94b 100644 --- a/barretenberg/cpp/src/barretenberg/world_state/world_state.hpp +++ b/barretenberg/cpp/src/barretenberg/world_state/world_state.hpp @@ -82,11 +82,15 @@ class WorldState { const std::unordered_map& tree_heights, const std::unordered_map& tree_prefill, const std::vector& prefilled_public_data, + const std::vector& prefilled_nullifiers, uint32_t initial_header_generator_point, uint64_t genesis_timestamp = 0, bool ephemeral = false); /** + * @param prefilled_nullifiers Nullifier leaves to pre-insert into the genesis nullifier tree (e.g. the protocol + * contract registration nullifiers). Must be unique and strictly increasing in field value, and + * distinct from the padding leaves implied by the nullifier tree prefill size. * @param ephemeral When true, every underlying LMDB env opens with `MDB_NOSYNC | * MDB_NOMETASYNC`. Commits return without waiting for fsync; the kernel * flushes lazily, files stay sparse. Intended for throwaway scratch @@ -99,6 +103,7 @@ class WorldState { const std::unordered_map& tree_heights, const std::unordered_map& tree_prefill, const std::vector& prefilled_public_data, + const std::vector& prefilled_nullifiers, uint32_t initial_header_generator_point, uint64_t genesis_timestamp = 0, bool ephemeral = false); @@ -326,6 +331,7 @@ class WorldState { void create_canonical_fork(const std::string& dataDir, const std::unordered_map& dbSize, const std::vector& prefilled_public_data, + const std::vector& prefilled_nullifiers, uint64_t maxReaders, bool ephemeral); diff --git a/barretenberg/cpp/src/barretenberg/world_state/world_state.test.cpp b/barretenberg/cpp/src/barretenberg/world_state/world_state.test.cpp index 4b6181fc9c98..6cdd0d264521 100644 --- a/barretenberg/cpp/src/barretenberg/world_state/world_state.test.cpp +++ b/barretenberg/cpp/src/barretenberg/world_state/world_state.test.cpp @@ -211,6 +211,57 @@ TEST_F(WorldStateTest, GetInitialTreeInfoForAllTrees) } } +TEST_F(WorldStateTest, GetInitialTreeInfoWithPrefilledNullifiers) +{ + // Prefilled nullifier leaves must be unique and strictly increasing, and larger than the padding leaves that fill + // the initial 128-leaf prefill region (whose keys are the low integers 0..127), so we use full-size field values. + std::vector prefilled_nullifiers = { + bb::fr("0x073b5e41abe9d7f8466bca9c81c9572b558f953bbd70081317f6a80ac65f3dd5"), + bb::fr("0x0d99507b7ecac720c73bf197a0e7366a5ed80c1c1b0afe8ff8c6ecc7b5a7aefe"), + bb::fr("0x1c0bf82e0c51834780e61ef091b17e3a1d39ae891db7a70bfdb5221f134996ac"), + }; + + std::string data_dir_prefilled = random_temp_directory(); + std::filesystem::create_directories(data_dir_prefilled); + + WorldState ws_prefilled(thread_pool_size, + data_dir_prefilled, + map_size, + tree_heights, + tree_prefill, + std::vector(), + prefilled_nullifiers, + initial_header_generator_point); + + // Baseline world state with no prefilled nullifiers (the canonical empty genesis). + WorldState ws(thread_pool_size, data_dir, map_size, tree_heights, tree_prefill, initial_header_generator_point); + + auto prefilled = ws_prefilled.get_tree_info(WorldStateRevision::committed(), MerkleTreeId::NULLIFIER_TREE); + auto info = ws.get_tree_info(WorldStateRevision::committed(), MerkleTreeId::NULLIFIER_TREE); + + // The prefilled nullifiers occupy the last slots of the 128-leaf initial prefill region (they replace padding + // leaves rather than being appended), so the tree size stays 128 for both. + EXPECT_EQ(prefilled.meta.size, 128); + EXPECT_EQ(info.meta.size, 128); + + // Seeding the nullifiers changes the nullifier-tree root away from the empty-genesis baseline. + EXPECT_NE(prefilled.meta.root, info.meta.root); + // The empty-genesis baseline root is unchanged from the canonical value, confirming that a default (empty) + // prefilled-nullifiers list leaves the genesis nullifier-tree root bit-identical to today. + EXPECT_EQ(info.meta.root, bb::fr("0x18935581a8ed73d08ffd00386fba55ba6c89f3ab848a76b8fedfa9034cee0454")); + + // The seeded nullifiers are present in the tree. + for (const auto& nullifier : prefilled_nullifiers) { + assert_leaf_exists(ws_prefilled, + WorldStateRevision::committed(), + MerkleTreeId::NULLIFIER_TREE, + NullifierLeafValue(nullifier), + true); + } + + std::filesystem::remove_all(data_dir_prefilled); +} + TEST_F(WorldStateTest, GetInitialTreeInfoWithPrefilledPublicData) { std::string data_dir_prefilled = random_temp_directory(); @@ -225,6 +276,7 @@ TEST_F(WorldStateTest, GetInitialTreeInfoWithPrefilledPublicData) tree_heights, tree_prefill, prefilled_values, + std::vector(), initial_header_generator_point); WorldState ws(thread_pool_size, data_dir, map_size, tree_heights, tree_prefill, initial_header_generator_point); diff --git a/barretenberg/cpp/src/barretenberg/wsdb/cli.cpp b/barretenberg/cpp/src/barretenberg/wsdb/cli.cpp index 7f9fd1899886..66bc4c48a7bb 100644 --- a/barretenberg/cpp/src/barretenberg/wsdb/cli.cpp +++ b/barretenberg/cpp/src/barretenberg/wsdb/cli.cpp @@ -83,6 +83,11 @@ int parse_and_run_wsdb(int argc, char* argv[]) msgpack_run_command->add_option( "--prefilled-public-data", prefilled_public_data_json, "Prefilled public data as JSON array"); + // Prefilled nullifiers as JSON array of nullifier_hex strings + std::string prefilled_nullifiers_json; + msgpack_run_command->add_option( + "--prefilled-nullifiers", prefilled_nullifiers_json, "Prefilled genesis nullifiers as JSON array"); + uint64_t genesis_timestamp = 0; msgpack_run_command->add_option("--genesis-timestamp", genesis_timestamp, "Genesis block timestamp (default: 0)"); @@ -121,6 +126,7 @@ int parse_and_run_wsdb(int argc, char* argv[]) threads, initial_header_generator_point, prefilled_public_data_json, + prefilled_nullifiers_json, genesis_timestamp, request_ring_size, response_ring_size); diff --git a/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.cpp b/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.cpp index b124326e8347..1d255a20f6a3 100644 --- a/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.cpp +++ b/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.cpp @@ -168,6 +168,34 @@ static std::vector parse_prefilled_public_data(const std::s return result; } +// --------------------------------------------------------------------------- +// Parse prefilled nullifiers from JSON: ["nullifier_hex",...] +// Each hex string is a 64-char (32-byte) hex-encoded field element. +// --------------------------------------------------------------------------- + +static std::vector parse_prefilled_nullifiers(const std::string& json) +{ + std::vector result; + if (json.empty() || json == "[]") { + return result; + } + + std::string current; + bool in_string = false; + + for (char c : json) { + if (c == '"') { + in_string = !in_string; + } else if (in_string) { + current += c; + } else if ((c == ',' || c == ']') && !current.empty()) { + result.push_back(hex_to_fr(current)); + current.clear(); + } + } + return result; +} + // --------------------------------------------------------------------------- // IPC server execution // --------------------------------------------------------------------------- @@ -180,6 +208,7 @@ int execute_wsdb_server(const std::string& input_path, uint32_t threads, uint32_t initial_header_generator_point, const std::string& prefilled_public_data_json, + const std::string& prefilled_nullifiers_json, uint64_t genesis_timestamp, size_t request_ring_size, size_t response_ring_size) @@ -211,6 +240,14 @@ int execute_wsdb_server(const std::string& input_path, std::cerr << "Parsed " << prefilled_public_data.size() << " prefilled public data entries" << '\n'; } + // Parse prefilled nullifiers: JSON array of "nullifier_hex" strings. The caller (TS world-state) passes the same + // canonical genesis nullifiers it seeds via the napi path, so the IPC genesis nullifier-tree root matches. + std::vector prefilled_nullifiers; + if (!prefilled_nullifiers_json.empty()) { + prefilled_nullifiers = parse_prefilled_nullifiers(prefilled_nullifiers_json); + std::cerr << "Parsed " << prefilled_nullifiers.size() << " prefilled nullifiers" << '\n'; + } + // Create WorldState std::cerr << "Creating WorldState at " << data_dir << " with " << threads << " threads" << '\n'; auto ws = std::make_unique(threads, @@ -219,6 +256,7 @@ int execute_wsdb_server(const std::string& input_path, tree_height, tree_prefill, prefilled_public_data, + prefilled_nullifiers, initial_header_generator_point, genesis_timestamp); diff --git a/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.hpp b/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.hpp index afb03cba3512..d1004802ab3b 100644 --- a/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.hpp +++ b/barretenberg/cpp/src/barretenberg/wsdb/wsdb_ipc_server.hpp @@ -20,6 +20,7 @@ int execute_wsdb_server(const std::string& input_path, uint32_t threads, uint32_t initial_header_generator_point, const std::string& prefilled_public_data_json, + const std::string& prefilled_nullifiers_json, uint64_t genesis_timestamp, size_t request_ring_size, size_t response_ring_size); diff --git a/barretenberg/sol/foundry.toml b/barretenberg/sol/foundry.toml index a0ed4ad08f3c..54c16203d2fb 100644 --- a/barretenberg/sol/foundry.toml +++ b/barretenberg/sol/foundry.toml @@ -9,14 +9,14 @@ gas_limit = 900000000000000000 bytecode_hash = "none" evm_version = "cancun" -[fuzz] -runs = 2 - # Use the pre-downloaded solc binary from l1-contracts. l1-contracts' build is # the single owner of the svm download; pointing other forge projects at the # same binary avoids parallel svm downloads racing on ~/.svm. solc = "../../l1-contracts/solc-0.8.30" +[fuzz] +runs = 2 + [lint] ignore = ["./lib/**"] exclude_lints = [ diff --git a/yarn-project/.claude/skills/writing-e2e-tests/SKILL.md b/yarn-project/.claude/skills/writing-e2e-tests/SKILL.md new file mode 100644 index 000000000000..c62e1c156758 --- /dev/null +++ b/yarn-project/.claude/skills/writing-e2e-tests/SKILL.md @@ -0,0 +1,327 @@ +--- +name: writing-e2e-tests +description: How to write end-to-end tests in yarn-project/end-to-end. Use when adding e2e coverage for a feature, creating a new e2e test or suite, or deciding where an e2e test should live. Covers the test categories (automine, single-node, multi-node, p2p, composed), setup reuse, readability conventions, speed techniques, and flakiness prevention. +--- + +# Writing E2E Tests + +E2E tests live in `yarn-project/end-to-end/src`. They spin up a real stack — anvil, an Aztec node +(archiver, world state, sequencer, p2p), a PXE-backed `TestWallet`, and optionally prover and +validator nodes — so they are the most expensive tests in the repo. Every decision below follows +from that: reuse setup, pick the cheapest category that exercises the feature, and make the test +robust against timing jitter because CI machines are slow and noisy. + +For debugging a failing e2e test, use the `debug-e2e` skill; for profiling where suite time goes, +`track-e2e-times`. For unit tests, use `unit-test-implementation` — and prefer a unit test whenever +the feature doesn't genuinely need the full stack. + +## Step 0: do you need a new test at all? + +Work down this ladder and stop at the first step that fits. Each step down costs CI minutes forever. + +1. **A unit test in the owning package.** If the behavior is observable without a live chain, it's + not an e2e test. +2. **A new expectation in an existing test.** If an existing test already drives the code path + (e.g. it sends the tx type you care about), add an `expect` there instead of paying another + setup. Grep for the contract method or subsystem you're touching. +3. **A new `it` in an existing suite.** Suites share one setup in `beforeAll`; a new `it` costs + seconds, a new file costs minutes. +4. **A new file on an existing category context or domain harness** (e.g. `AutomineTestContext`, + `setupWithProver`, `TokenContractTest`, `FeesTest`, `MultiNodeTestContext`). +5. **A brand-new standalone test.** Last resort — justified when the feature needs a setup shape + no existing suite has. + +## Where to place the test + +The top level of `src/` groups tests **by node topology**; the second level names the primary +behavior under test. Each category directory has a `README.md` describing its base class, setup +factories, helper surface, and subfolders — **read the README of the category you pick before +writing**; it is the authoritative, up-to-date reference and this skill only summarizes it. + +### Categories + +Pick the **cheapest category whose machinery your feature actually needs**. Cost and flake risk +increase down the table. + +| Category | Context / entrypoint | Use for | +|---|---|---| +| `automine/` | `AutomineTestContext.setup({ numberOfAccounts })` (`automine_test_context.ts`) | Contract or protocol behavior that doesn't depend on real block-building or consensus: tokens, accounts, authwits, notes/events/effects, deploys, simulation. Deterministic and fast: the `AutomineSequencer` builds one block per submitted tx, publishes synchronously, no committee/prover/validator. | +| `single-node/` | `setupWithProver(opts)` or `setupBlockProducer(opts)` (`single-node/setup.ts`, over `SingleNodeTestContext`) | One production sequencer, no committee: block building, sequencer config/governance signalling, fees, cross-chain messaging, world-state sync, the proving/epoch lifecycle, partial proofs, L1 reorgs, recovery. `setupWithProver` adds a fake in-process prover; `setupBlockProducer` has no prover (and points the PXE at the `proposed` tip). Real Barretenberg proofs live in `single-node/prover/` on `FullProverTest`. | +| `multi-node/` | `MultiNodeTestContext` (extends `SingleNodeTestContext`) + presets in `multi_node_test_context.ts` | N validators on an **in-memory `MockGossipSubNetwork` bus** (no real libp2p): committee block production, attestations, invalid-attestation handling, HA pairs, slashing/offense detection, governance upgrades. Presets: `buildMockGossipValidators(n)`, `MOCK_GOSSIP_MULTI_VALIDATOR_OPTS`, `SLASHER_ENABLED_MULTI_VALIDATOR_OPTS`, `setupHaPairs`. | +| `p2p/` | `P2PNetworkTest` (`p2p/p2p_network.ts`) + `runGossipScenario` (`p2p/shared.ts`) | **Real libp2p only**: peer discovery/rediscovery, gossip mesh formation, req/resp, preferred-peer topologies, peer auth. Slowest and most flake-prone; nodes bind fixed ports, so two p2p files can never run concurrently locally. | +| `composed/` | docker-compose against a running network (`scripts/run_test.sh compose`) | The packaged sandbox/network as users see it: persistence, cheat codes, tutorials, uniswap, HA, web3signer. Also `guides/` for docs examples. | +| `infra/`, `spartan/`, `bench/` | see their READMEs | Deployment/ops smoke tests, k8s network tests, and benchmarks (see the `adding-benchmarks` skill) — not homes for feature coverage. | + +The decision that trips people up most: **multi-node vs p2p**. If the subject — proposals, +attestations, checkpointing, pruning/recovery, offense detection — is faithfully reproduced by the +mock-gossip bus, it belongs in `multi-node/`, which is far cheaper. Only reach for `p2p/` when the +behavior genuinely cannot be reproduced without real networking. + +Also cheaper than jumping categories: `setup()` options can bend a category upward — +`startProverNode: true`, `skipInitialSequencer: true`, and `mockGossipSubNetwork: true` give you +extra nodes without real libp2p. + +### File placement and CI registration + +- Second-level folders name the behavior under test (`token/`, `proving/`, `slashing/`), not the + shared setup. A new folder is created only when it earns its keep: a shared harness, a coherent + domain of several files. Otherwise the file lives flat in the category. +- Each file has a **single top-level `describe` named to match its path** + (`describe('automine/token/transfer', ...)`), and starts with a short header comment describing + the coverage and the setup shape (see `automine/token/transfer.test.ts` for the pattern). +- A co-located `setup.ts` in the subfolder holds shared timing profiles/option wiring (e.g. + `single-node/l1-reorgs/setup.ts`, `multi-node/slashing/setup.ts`); domain harnesses are + co-located `*_test.ts` files (not `.test.ts`, so jest doesn't run them). +- CI picks up new files **automatically**: `end-to-end/bootstrap.sh` `test_cmds` globs each + category. Each file runs as its own isolated job with a default `TIMEOUT=20m`; if your suite + legitimately needs more, add a per-test override in the `case` block there — and keep it in sync + with the file's `jest.setTimeout`. +- `*.parallel.test.ts` marks a file with more than one top-level `it`: CI extracts each `it` title + and runs it as a **separate job** (`jest -t ''`). Every `it` must pass in isolation — no + cross-test state — and titles must be unique and stable (they become job/container names). +- `*.notest.ts` parks a test without running it (prefer fixing or deleting). +- Jest gives each test/hook 300s (`--testTimeout=300000` in `test:e2e`). Set an explicit + `jest.setTimeout(...)` at the top of the `describe` when setup or waits legitimately exceed it — + and only then (see Flakiness below). + +## Setup reuse + +**Search for an existing setup before building one.** The layers, outermost first: + +1. **Category context classes** (table above) own the environment: anvil + L1 deploy, node + spawning, the `ChainMonitor`, waiters, and teardown. Don't call the root `setup()` + (`fixtures/setup.ts`) directly from a new test — go through the category's context/factory, and + pass options through it. +2. **Domain harnesses** extend a context with domain state and opt-in setup phases: + `automine/token/token_contract_test.ts` (`TokenContractTest`: `applyBaseSnapshots()`, + `applyMint()`), `automine/token/blacklist_token_contract_test.ts`, + `single-node/fees/fees_test.ts` (`FeesTest`: `applyBaseSetup()`, `applyFPCSetup()`, + `applyFundAliceWithBananas()`, ...), `single-node/cross-chain/cross_chain_messaging_test.ts`, + `single-node/prover/` (`FullProverTest`), `multi-node/slashing/inactivity_setup.ts`. +3. **Root `setup()` options** (`SetupOptions` in `fixtures/setup.ts`) cover most needs without new + code: genesis-funded accounts (`initialFundedAccounts`, `numberOfInitialFundedAccounts`), + `fundSponsoredFPC`, `startProverNode`, `skipInitialSequencer`, validators + (`initialValidators`), custom genesis (`genesisPublicData`), timing (`aztecSlotDuration`, + `ethereumSlotDuration`, `aztecEpochDuration`), `mockGossipSubNetwork`, and any + `AztecNodeConfig` field. Read the type before adding a new option. + +The standard shape of a suite test file: + +```typescript +describe('automine/token/transfer', () => { + const t = new TokenContractTest('transfer'); + let { asset, adminAddress, wallet, otherAddress, tokenSim } = t; + + beforeAll(async () => { + t.applyBaseSnapshots(); + await t.setup(); + await t.applyMint(); + ({ asset, adminAddress, wallet, otherAddress, tokenSim } = t); + }); + + afterAll(() => t.teardown()); + afterEach(async () => { + await t.tokenSim.check(); // model-based invariant check after every test + }); + + it('transfers between accounts', async () => { /* ... */ }); +}); +``` + +Rules of thumb: + +- **One environment per file, set up in `beforeAll`** — never per test. If tests can't share + state, make them not need to (fresh contract instance per test is fine; fresh network per test + is not), or split the file. +- Only apply the setup phases you need — every `apply*` costs txs (and therefore blocks). +- New shared state for several tests → an `apply*` method on the harness (or a new harness + extending the context), so other files can reuse it. +- `afterAll(() => teardown())`, and if a local `teardown` variable is set inside `beforeAll`, + guard it: `afterAll(() => teardown?.())` — if setup throws, an unguarded call masks the real + error with `TypeError: teardown is not a function`. +- When combining a preset with overrides, **spread the preset first** so your overrides win: + `{ ...MOCK_GOSSIP_MULTI_VALIDATOR_OPTS, aztecEpochDuration: 4 }`. Spreading the preset last + silently reverts your options. + +## Readability + +The test body should read as **intent**: what is executed, what is asserted. Push mechanics into +helpers, preferably shared ones. + +- **Prefer the named waiters over hand-rolled polling.** Node/wallet-level waits live in + `fixtures/wait_helpers.ts` (`waitForBlockNumber`, `waitForProvenBlock`, `waitForNodeCheckpoint`, + `waitForTxs`, `waitForTxStatus`, `waitForPendingTxCount`, `waitForSequencerState`, ...); context + waiters live on `SingleNodeTestContext`/`MultiNodeTestContext` (`waitUntilEpochStarts`, + `waitUntilProvenCheckpointNumber`, `waitForNodeToSync`, `waitForSequencerEvent`, + `waitForAllNodes*`, `findSlotsWithProposers`); L1-side waits on `ChainMonitor` + (`waitUntilCheckpoint`, `waitUntilL2Slot`, `waitUntilL1Timestamp`). A raw + `retryUntil`/`.on`/`sleep` in a test body is a smell — wrap it or find the existing helper. +- **Reuse the shared helpers** before writing inline plumbing: `fixtures/token_utils.ts` + (`deployToken`, `mintTokensToPrivate`, `mintNotes`, `expectTokenBalance`), + `shared/submit-transactions.ts`, `shared/cross_chain_test_harness.ts`, + `fixtures/l1_to_l2_messaging.ts`, `expectMapping` from `fixtures/setup.ts`. Suite-local + assertion helpers go in a co-located file (e.g. `automine/token/token_test_helpers.ts`) — never + duplicated across test bodies. +- **Simulators for stateful suites**: `TokenSimulator` (`src/simulators/`) mirrors expected + balances in memory; an `afterEach` calls `tokenSim.check()` so every test gets full-state + verification without per-test assertion boilerplate. Follow this pattern for new stateful + suites. +- **Expected errors are shared constants**, not inline strings: `U128_UNDERFLOW_ERROR`, + `DUPLICATE_NULLIFIER_ERROR`, `NO_L1_TO_L2_MSG_ERROR`, etc. in `fixtures/fixtures.ts`. Add new + protocol-level error patterns there. +- Destructure the harness once in `beforeAll` so test bodies use plain names (see the example + above). +- Log with the context logger (`t.logger.info('...')`) at phase boundaries of long tests — it is + what makes CI logs debuggable — but don't narrate every line. +- A comment in a test explains a non-obvious *why* (e.g. "proxy makes msg_sender differ from the + note owner to trigger authwit validation"), never *what* the next line does. + +## Speed + +We favor robustness over speed, but e2e minutes are the bottleneck of every CI run. Techniques +that have actually paid off in the ongoing e2e speedup effort: + +1. **Don't create setup you don't need.** The single biggest cost is network + account setup. + Joining an existing suite costs ~0; every avoided account deploy saves a proof + a block. Use + genesis-funded accounts over deploys, the hardcoded schnorr account + (`fixtures/schnorr_hardcoded_account_contract.ts`) when the test doesn't care about identity, + `AutomineTestContext.registerContract(...)` / `TestContract` for contracts usable without an + on-chain deploy, and only the `apply*` phases you need. +2. **Seed state at genesis instead of executing setup txs.** `setup()` options like + `initialFundedAccounts`, `fundSponsoredFPC: true`, and `genesisPublicData` bake state into the + genesis trees for free. Prefer these over bridging/minting/deploying in `beforeAll`. (The + current speedup round extends this: standard-contract registration and FPC funding seeded via + prefilled genesis nullifiers/public data — check `SetupOptions` for `prefilled*`/preload + options and reuse them when available.) +3. **Batch same-sender setup txs into one `BatchCall`** — one proof and one block instead of N. + See `mintNotes` in `fixtures/token_utils.ts`. Note limits apply (e.g. only one contract-class + log per tx, so two contract deploys can't batch). +4. **Overlap independent setup txs with `Promise.all`** — but know the ceiling: the PXE + serializes simulation/proving on one queue, so concurrent sends often land in consecutive + blocks anyway. Batching (one tx) beats overlapping (N txs) when the sender is the same. +5. **Warp over dead waits.** When the test waits for a timestamp/epoch boundary and *nothing + needs to be produced* during the wait, jump: `cheatCodes.warpL2TimeAtLeastTo/By` (L1+L2 + together), `cheatCodes.eth.warp(ts, { resetBlockInterval: true })` (L1 only, for big jumps), + `markProvenAndWarp` on `AutomineTestContext` (marks checkpoints proven first so a long warp + doesn't trip the pruning window), `warpWithSequencersPaused` on `SingleNodeTestContext` (pauses + sequencers across the warp so in-flight jobs don't cascade). **An honest wait beats a flaky + warp**: if sequencers/provers must actually do something during the window (attest, prove, + slash), warping skips the behavior under test — don't convert those. +6. **Don't tighten slot durations ad hoc.** Slot/epoch durations interact with the sequencer + timetable; too-tight cadences are the top historical flake source (see Flakiness). Use the + named timing profiles (category presets, the co-located `setup.ts` profiles) instead of + inventing per-test numbers, and leave slack — CI event loops stall for hundreds of ms + routinely. +7. **Measure before optimizing.** Setup and wait helpers are span-instrumented via `testSpan` + (`fixtures/timing.ts`, enabled by `TEST_TIMING_FILE`); use the `track-e2e-times` skill to get a + ranked breakdown of where the time goes. Wrap new expensive shared helpers in `testSpan` so + they show up. Cite before/after span numbers when proposing a speed change. + +## Flakiness + +A test that fails 1-in-50 runs costs more than it's worth. These are the recurring root causes +from six months of deflake PRs — write the test right the first time. + +### The golden rules + +1. **Never `sleep()` to wait for a state change.** Poll the condition with the named waiters + (Readability above), or `retryUntil(fn, name, timeoutSec, intervalSec)` from + `@aztec/foundation/retry` when no named helper fits. A raw sleep is only acceptable to yield + the event loop, never to "give X time to finish". +2. **Assert against the tx receipt, not the chain tip.** Use `receipt.blockNumber`; never + `getBlock('latest')` right after sending — an empty block/checkpoint may have landed in + between. For `contract.methods.foo().send()`, destructure `{ receipt }`. +3. **Mined ≠ checkpointed ≠ proven.** A tx wait proves a node saw the tx mined — not that the + archiver indexed it, that it survived a reorg, or that it was proven. + - Asserting on archiver/world-state after a tx: wait on the subsystem's own durable marker, + e.g. `waitForNodeCheckpoint(node, target)` or `waitForBlockNumber(node, n, { tag: + 'checkpointed' })`. + - If the test can experience pruning/reorgs (proving, recovery, multi-node — anything stopping + nodes), anchor the PXE to the durable tip: `syncChainTip: 'checkpointed'` in pxe opts, and + use `send({ wait: { waitForStatus: TxStatus.CHECKPOINTED } })` for setup txs that later + assertions depend on. (`setupBlockProducer` deliberately uses `'proposed'` so tests can + assert on fresh blocks — know which one your suite needs.) Classic flake signature: + `Block not found in the node. This might indicate a reorg has occurred`, or a receipt + wait hanging forever. + - **Proven/finalized never advances by itself in most setups**: the `AnvilTestWatcher`'s + auto-prove is dormant once anvil is in interval mining. If the test needs the proven tip to + move, use `markProvenAndWarp` / `cheatCodes.rollup.markAsProven()` or run a prover + (`setupWithProver`, `startProverNode: true`). Otherwise a `while (proven < n)` loop hangs to + wall clock. +4. **Leave timing margin.** Under proposer pipelining the sequencer builds slot N during slot + N-1, so effects land a slot later than naive math suggests, and config injected via + `node.setConfig()` is snapshotted when a job is *constructed* — one slot early. Anchor slot + arithmetic to a fresh boundary (`monitor.waitUntilNextL2Slot()`) before reading `currentSlot`; + target slots with `+3/+4` margin rather than `+2`; when targeting a specific proposer, use + `findSlotsWithProposers` rather than hard-coded slot pairs (the prior pipelined slot must not + share the proposer); after a mid-test `setConfig` of block-gating options, wait for the + sequencer to pick it up before sending dependent txs. Historical top-flake: timetable too + tight — the presets' slot durations exist because validators must simulate, attest, and + publish within them on a loaded CI machine; don't undercut them in a new test. +5. **Don't force `minTxsPerBlock >= 1` under a wall-clock sequencer** unless tx-gated block + production is the behavior under test. It stalls scheduled empty checkpoints and drops txs. +6. **Assert the invariant, not an incidental exact value.** Exact block numbers, exact slots, + exact committee members, and `toBeGreaterThan` off-by-ones are the most common deflake diffs. + If the exact value matters, *derive* it from the receipt/committed header/actual committee; + if the system decides it (which slot a fault lands on, when a prune executes), **discover it + by polling, then assert on the discovered value** — don't hardcode the assumption. For + ramp-up/settle phases, assert a tolerance budget, not zero. When filtering sequencer events, + exclude known-benign failures explicitly (see the existing filters in `watchSequencerEvents` + call sites) rather than asserting no events at all. +7. **Serialize against shared resources.** + - L1 accounts: never reuse mnemonic index 0 (the sequencer's publisher) for a test actor; + take a dedicated unused index via `getPrivateKeyFromIndex(i)` (see + `L1_DIRECT_WRITE_ACCOUNT_INDEX` in `fixtures/fixtures.ts`). Nonce races present as + `nonce too low` / stuck publishers. + - Await the receipt of a prerequisite L1 tx before sending a dependent one. + - Parallel local runs need distinct `ANVIL_PORT`s (the fixture honors the env var); p2p tests + bind fixed UDP/TCP ports, so never run two p2p files at the same time. + - Data directories: use the context's management (`P2PNetworkTest.dataDirFor(label)`), don't + `mkdtemp`/`rmSync` in test files. +8. **Attach listeners before causing the event**, and freeze time across restarts. Listeners + registered after initial sync miss events that fire during sync — if the test stops/recreates + nodes while an L1 deadline approaches, pause anvil mining across the gap and resume + deterministically (set next timestamp + mine) so the transition happens while someone is + listening. Add a fail-fast assertion that the deadline hasn't passed yet. +9. **P2P: connectivity ≠ gossip readiness.** Wait for the gossip mesh + (`waitForP2PMeshConnectivity`; raise `minMeshPeerCount` when a proposal must reach the whole + committee within a slot) before sending txs, or they publish to zero peers and silently + expire. Prefer the `runGossipScenario` skeleton over hand-rolling the bootstrap→nodes→mesh + sequence. Don't over-specify topology (requiring a full clique flakes on one missing edge); + assert the property the test needs. Gossip is not a durable record — late attestations get + rejected by acceptance windows before downstream consumers see them. +10. **Fees evolve between snapshot and inclusion.** Use `getPaddedMaxFeesPerGas` / + `walletMinFeePadding` rather than exact predicted fees, and derive expected committed fees + from the block header, not from a later `getCurrentBaseFees()` call. +11. **Keep the test deterministic.** Mock `Math.random` when the code under test makes random + choices; never mix fake clocks with real sleeps — drive all timing through the fake clock. +12. **Timeouts express expected duration, not hope.** Raise `jest.setTimeout` only when the flow + legitimately takes that long (proving, multiple epochs) and say why; a bumped timeout that + hides a hang just moves the failure to the 20m CI kill. Keep the bootstrap.sh `TIMEOUT` and + `jest.setTimeout` in sync. + +### Before you ship it + +- Run the test repeatedly: `scripts/deflaker.sh yarn workspace @aztec/end-to-end test:e2e ` + (100 runs, stops at first failure). At minimum run it 3-5 times locally, including once under + load. +- Run with verbose logs once and read them: `LOG_LEVEL='info; debug:sequencer,archiver,publisher' + yarn workspace @aztec/end-to-end test:e2e src//.test.ts -t 'test name'`. +- If a known-unfixable external flake remains, the last resort is an entry in `.test_patterns.yml` + (repo root) with a **tightly-scoped `error_regex`** and an owner — it alerts instead of failing + CI. This is for tracked product fragility, not a substitute for fixing the test. + +## Checklist + +- [ ] Couldn't be a unit test, an added expectation, or a new `it` in an existing suite +- [ ] Cheapest category that exercises the feature (automine → single-node → multi-node → p2p); + category README read +- [ ] One environment per file in `beforeAll`, via the category context/factory; preset spread + first; guarded teardown +- [ ] Single top-level `describe` named to match the path; header comment; `.parallel` suffix iff + multiple independent top-level `it`s +- [ ] Test body reads as intent; named waiters and shared helpers; shared error constants +- [ ] No sleeps; receipt-anchored assertions; correct tip tag (`proposed` vs `checkpointed`) +- [ ] No exact-value assertions on system-decided values; timing margin per rule 4 +- [ ] `jest.setTimeout` justified and in sync with bootstrap.sh `TIMEOUT` if overridden +- [ ] Deflaker/local repeat runs pass; verbose-log run reviewed diff --git a/yarn-project/THREAT_MODEL.md b/yarn-project/THREAT_MODEL.md deleted file mode 100644 index 1dee4b96cba8..000000000000 --- a/yarn-project/THREAT_MODEL.md +++ /dev/null @@ -1,341 +0,0 @@ -# Aztec Network Threat Model - -This document describes the threat model of the Aztec L2 network: how transactions flow from users into the proven -chain, what each participant can and cannot do, and the properties the implementation must uphold. It is intended as a -guideline for the security of the node implementation (`yarn-project`) and its interaction with the L1 rollup -contracts (`l1-contracts`). - -**In scope**: transaction dissemination and the mempool, the p2p layer, block and checkpoint production, committee -attestation, L1 checkpoint submission and sync, epoch proving, slashing, and the escape hatch. - -**Out of scope**: client-side private execution and proving (PXE, wallets), hardening of a node's public RPC interface, -the cryptographic soundness of the proving system itself (treated as an assumption below), and L1 governance internals. - -## 1. System overview - -### Actors - -| Actor | Role | -| --- | --- | -| User | Executes private functions locally, produces a client-side proof, submits the tx to a node via RPC. | -| Node | Syncs L2 state from L1 and p2p, maintains a mempool, serves RPC. Every other server-side actor runs one. | -| Proposer | The validator elected for a slot. Builds blocks, collects attestations, submits the checkpoint to L1. | -| Committee | Per-epoch sample of validators. Re-executes proposals and attests to checkpoints; its attestations gate proof acceptance (training wheels for the proving system, A2) and back data availability (A8). | -| Prover | Generates the epoch validity proof and submits it to L1. Permissionless. | -| Escape hatch proposer | Bonded candidate, randomly selected, may propose without a committee during periodic windows. | -| Vetoer | Designated L1 role that can block slash payloads during the execution delay. | -| L1 contracts | Rollup (checkpoints, proofs, pruning, invalidation), Inbox/Outbox (cross-chain messages), slashing and governance periphery. | - -### Time and validator selection - -Time is divided into **slots** (fixed windows, e.g. 72 s): each slot has exactly one elected proposer and produces at -most one checkpoint, built as several blocks in fixed sub-slots. Slots group into **epochs**: each epoch has one -committee, and epochs are the unit of proving and pruning. - -The committee for epoch N is sampled (Fisher–Yates, without replacement) from the registered validator set, seeded from -Ethereum's RANDAO. Both inputs are taken from the past: the validator set is snapshotted `lagInEpochsForValidatorSet` -epochs before N and the seed `lagInEpochsForRandao` epochs before N, with set lag ≥ seed lag — by the time the -randomness is known, the population it samples from can no longer be changed. L1 stores a commitment to each epoch's -committee to prevent substitution (`ValidatorSelectionLib`); nodes mirror the computation locally -([epoch-cache README](epoch-cache/README.md)). The proposer for a slot is -`keccak(epoch, slot, seed) % committeeSize` — deterministic and computable by anyone. - -Selection bias is an audit surface in its own right: seed grinding via L1 `prevrandao`, and timing games against the -validator-set snapshot. The lag scheme above and the escape hatch's snapshot-before-seed ordering are the existing -defenses. Edge case: an empty committee (target size 0) means anyone may propose, and nodes accept checkpoints without -attestation validation. - -### Transaction lifecycle - -1. **Submission.** A user sends a tx (with its client-side proof) to a node via JSON-RPC `sendTx`. The node fully - validates it — proof verification plus protocol rules (double-spend, fees, gas limits, expiration, metadata) — - before admitting it to the mempool ([server.ts](aztec-node/src/aztec-node/server.ts), - [tx validators](p2p/src/msg_validators/tx_validator/)). -2. **Propagation.** The node gossips the tx on the p2p `tx` topic. Every receiving node runs the same validation - pipeline *before* the message is re-propagated: gossipsub only forwards messages the local node accepted. Peers that - originate invalid data are penalized; validation outcomes that could be another node's fault are dropped without - penalty (see §6). -3. **Mempool.** The pool ([tx_pool_v2](p2p/src/mem_pools/tx_pool_v2/)) admits txs subject to nullifier-conflict, - fee-payer-balance, and priority rules, and evicts txs that become ineligible (nullifiers mined by a block, expired - timestamps, insufficient fee-payer balance, invalid anchor block after a reorg, lowest priority when full). -4. **Block building.** The proposer for a slot builds several blocks back-to-back (sub-slots), pulling txs from its - mempool and executing public calls against a fork of world state. Each block is signed and broadcast as a - `BlockProposal`; the last block ships inside the `CheckpointProposal` that closes the slot. Production runs - pipelined: blocks for slot N are built during slot N−1 ([sequencer-client README](sequencer-client/README.md)). -5. **Attestation.** Committee members re-execute every block in the proposal and, if the result matches, sign a - `CheckpointAttestation` over the checkpoint header and archive root. Attestations are checkpoint-only; individual - blocks are never attested ([validator-client README](validator-client/README.md)). -6. **L1 submission.** Once the proposer holds a quorum of attestations — ⌊2n/3⌋+1 of the committee — it submits the - checkpoint to the rollup contract in a single Multicall3 tx (invalidations first, then propose, then - governance/slashing votes). At propose time L1 validates the header, blob commitments, and the proposer signature, - but **not** the attestations (posted as calldata) and **not** tx validity (`ProposeLib`). -7. **Sync.** Nodes track two chains. The **proposed chain** comes from p2p: proposals are re-executed locally and - pushed into the archiver as provisional blocks. The **checkpointed (pending) chain** comes from L1 - `CheckpointProposed` events: each node verifies the posted attestations from calldata (committee membership and - quorum, per *delayed attestation verification*) before fetching and decoding blobs; checkpointed blocks are **not** - re-executed ([archiver README](archiver/README.md), [validation.ts](archiver/src/modules/validation.ts)). -8. **Proving.** Prover nodes prove epochs optimistically, starting sub-tree work as checkpoints land on L1. The rollup - accepts an epoch proof only if the proof verifies **and** the last checkpoint in the range carries valid committee - attestations (`EpochProofLib`). The proven tip then advances. If no proof - lands within the proof submission window, all unproven checkpoints are pruned — the pending chain reorgs back to the - proven tip; nodes unwind preemptively and the mempool resurrects the affected txs. -9. **Finality.** Proven state enables L2→L1 message consumption via the Outbox, and fees/rewards are distributed from - the committee-attested checkpoint headers. Once the L1 block containing the verified proof is itself finalized on - L1, the state can no longer be reorged out. - -### Chain states - -| Chain | Source | Trust | -| --- | --- | --- | -| Proposed | p2p proposals | Re-executed locally; trustless but reorgs freely within/across slots. | -| Checkpointed (pending) | L1 events + blobs | Attestation-gated; contents trusted from the committee (not re-executed). Reorgs on prune or invalidation. | -| Proven | L1 verified proof | Trust reduces to circuit soundness + L1 verifier. Still subject to L1 reorgs. | -| Finalized | Proof's L1 block finalized | The L1 block containing the verified proof is finalized; cannot be reorged out of L1. | - -### Where the details live - -| Topic | Document | -| --- | --- | -| Proposer flow, pipelining, timetable, L1 publisher | [sequencer-client README](sequencer-client/README.md) | -| Proposal validation, attestation creation, building limits | [validator-client README](validator-client/README.md) | -| Committee/proposer selection, RANDAO seed, epoch caching | [epoch-cache README](epoch-cache/README.md) | -| Gossip topics, message validation, peer scoring, req/resp | [p2p README](p2p/README.md) and sub-READMEs | -| Mempool state machine and eviction | [tx_pool_v2 README](p2p/src/mem_pools/tx_pool_v2/README.md) | -| L1 sync, reorgs, invalid checkpoints, pruning | [archiver README](archiver/README.md) | -| Epoch proving pipeline | [prover-node README](prover-node/README.md) | -| Slashing architecture and offenses | [slasher README](slasher/README.md) | -| Operator-facing slashing guide (amounts, veto, ejection) | [slashing configuration guide](../docs/docs-operate/operators/sequencer-management/slashing-configuration.md) | - -## 2. Trust assumptions - -- **A1 — Committee quorum.** Safety of the *pending* chain assumes fewer than ⌊2n/3⌋+1 members of any epoch committee - are malicious; liveness assumes at least ⌊2n/3⌋+1 are honest and online (`computeQuorum` in - [epoch-helpers](stdlib/src/epoch-helpers/), mirrored in `InvalidateLib`). -- **A2 — Proven-chain soundness.** It must be impossible to prove an invalid state transition: the *proven* chain - assumes the protocol circuits are sound and the L1 verifier is correct, without relying on committee honesty. The - committee is nevertheless a second, independent gate: proof submission requires committee attestations on the proven - range (V4), so it acts as training wheels for the proving system — exploiting a soundness bug also requires a - colluding quorum (or an escape-hatch bond, §7). Invalid state reaches the proven chain only if *both* layers fail. -- **A3 — Completeness.** Every state transition the network considers valid must be provable. An unprovable-but-attested - checkpoint forces a prune, so a completeness bug converts into a liveness attack. -- **A4 — Prover liveness.** At least one prover is willing and able to prove each epoch; otherwise the chain reorgs at - the proof submission deadline. -- **A5 — L1 liveness.** Ethereum remains live and censorship-resistant enough for time-windowed actions to land: - checkpoint proposal, invalidation, proof submission, slashing votes and execution. -- **A6 — Blind checkpoint trust.** Nodes follow attested checkpoints without re-executing them. A committee quorum can - therefore feed nodes invalid pending state (bounded by A2 + pruning). This assumption may be revisited. -- **A7 — Uniform validation config.** Slashing relies on all honest validators making identical deterministic validity - decisions; operators must not run divergent block/checkpoint validation limits. -- **A8 — Data availability.** Checkpoint effects are published as L1 blobs; tx preimages needed for proving are served - over p2p. Committees are slashed if the data behind their attested checkpoints is not made available. - -## 3. Threat actors and worst-case impact - -| Actor | Can do (accepted worst case) | Cannot do (must hold) | Mitigations | -| --- | --- | --- | --- | -| Malicious user | Submit txs that later become ineligible (e.g. nullifier races); attempt mempool flooding | Saturate mempools with unincludable txs; get an invalid tx included; force an unprovable state transition | Full validation incl. proof verification at every hop; eviction rules; priority fees + price-bump replacement; per-tx gas floors | -| Malicious peer (non-validator) | Send garbage, replay, or spam over gossip/req-resp | Get invalid data re-broadcast by honest nodes; cause honest nodes to be penalized by their peers; eclipse a node cheaply | Validate-before-forward; peer scoring, rate limits, bans (§6); AUTH handshake on nodes that only accept validator peers | -| Malicious proposer | Censor txs; waste its own slot and the next one (pipelining); post an unattested/badly-attested checkpoint to L1; equivocate | Convince honest nodes of an invalid state transition; corrupt state beyond slot N+1; brick other nodes' sync | Re-execution before attestation/adoption; pipeline depth capped at 2; delayed attestation verification + permissionless invalidation; slashing (§5) | -| Committee minority (< quorum, not proposer) | Withhold their own attestations; equivocate (slashable) | Prevent a slot from being attested; get honest members slashed | Quorum only needs ⌊2n/3⌋+1 of n; equivocation slashing | -| Committee quorum (≥ ⌊2n/3⌋+1 malicious) | Post invalid-but-attested checkpoints that nodes follow blindly; withhold tx data; halt the pending chain until the epoch prune | Get invalid state proven or exited on L1 (A2); avoid slashing for data withholding / attested-invalid | Unprovable state → prune at proof-window expiry; archiver preemptive unwind; slashing incl. `DATA_WITHHOLDING` | -| Malicious prover | Nothing by proving (proofs are verified); grief by *not* proving | Prove an invalid transition (A2); steal fees/rewards via forged proof calldata | Proof verification; attestation gate at proof submission (A2); fees bound to attested headers (see §4.4); A4 for liveness | -| Escape hatch proposer | Same as a malicious committee during its hatch window: post arbitrary unattested checkpoints, halt pending chain until prune | Exceed malicious-committee damage; escape its bond | Bond + punishment for failing to propose-and-prove; hatch windows are bounded (`ACTIVE_DURATION` out of every `FREQUENCY` epochs) | -| Malicious slashing majority | Vote through an unfair slash (requires >50% of a round's proposers) | Execute it silently or instantly | Execution delay + vetoer; offense votes are public on L1 | - -## 4. Security properties - -Properties are numbered for reference from audit findings. Each states the requirement, the enforcing mechanism, and -notable caveats. - -### 4.1 P2P and mempool - -- **P1 — Validate before forward.** No node re-broadcasts a gossip message it has not fully validated (including tx - proof verification). Enforced by running validation inside the gossipsub message-validation hook; only `Accept` - results propagate. -- **P2 — No penalty for relayed faults.** A peer must not be penalized for forwarding data that was plausibly valid - from its point of view. Encoded three ways: (a) tx validation splits outcomes into `REJECT` + penalty for - sender-attributable faults (malformed data, invalid proof) vs `IGNORE`/low-severity for state-divergence-explainable - ones (recent double-spend, timestamp expiry); (b) proposal gossip validators only run shallow, syntactic checks - (signature, slot window, proposer identity, tx-hash consistency) — deep re-execution failures are attributed to the - *proposer* via slashing and never penalize the relaying peer; (c) equivocating or oversized proposals are - deliberately `Accept`ed and re-broadcast as slashing evidence so the network can witness the offense, again without - penalizing relayers. A malicious node must not be able to craft data that honest nodes accept, re-broadcast, and are - then penalized for. -- **P3 — Mempool inclusion-eligibility.** Every tx in the pending pool must be includable in a future block; txs - invalidated by chain progress are evicted (mined nullifiers, expiry, fee-payer balance, reorged anchor blocks). - Accepted residual: executing a tx inside a block can invalidate sibling txs mid-slot (nullifier collision); at least - one tx of any colliding set is includable, and the builder drops the rest during execution. -- **P4 — Flood resistance.** Pool size is capped with priority-fee eviction; replacement requires a configurable price - bump; gossip messages have per-topic size caps; req/resp protocols have per-peer and global rate limits (§6). -- **P5 — Sybil/eclipse resistance.** Peer discovery (discv5), connection gating, auth handshakes on nodes that only - accept validator peers (`p2pAllowOnlyValidators`, off by default; no such nodes are deployed today), failed-auth - lockouts, and IP colocation penalties raise the cost of surrounding a node. This is a defense-in-depth area, not an - absolute guarantee. - -### 4.2 Block proposal and attestation - -- **B1 — Proposer exclusivity.** Only the slot's elected proposer can land a checkpoint: L1 verifies the proposer - signature over the payload digest at propose time (`ValidatorSelectionLib.verifyProposer`); validators independently - check proposal provenance before processing. During hatch windows, only the designated escape hatch proposer may - propose. -- **B2 — Re-execution gate.** Honest validators attest only after re-executing all blocks in the checkpoint and - matching the resulting state (archive root, header hash). Honest full nodes adopt proposed blocks into their view - only via the same re-execution path. A proposal that fails validation is rejected and triggers a slash vote - (`BROADCASTED_INVALID_BLOCK_PROPOSAL` / `BROADCASTED_INVALID_CHECKPOINT_PROPOSAL`). -- **B3 — Equivocation detection.** Two proposals for the same position with different content, or two attestations by - the same signer for the same slot, are slashable (`DUPLICATE_PROPOSAL`, `DUPLICATE_ATTESTATION`). The first duplicate - proposal is deliberately propagated so other validators can witness the offense. Checkpoint-vs-L1 equivocation is - caught by comparing signed p2p proposals against L1-confirmed checkpoints. -- **B4 — Bounded proposer blast radius.** Pipelining lets a proposer build on an in-flight parent, so a failed or - malicious slot can waste the *next* proposer's work, but no more: pipeline depth is capped at 2 checkpoints beyond the - confirmed tip, and a parent that fails to land cleanly causes the child's work to be discarded and an invalidation to - be enqueued ([checkpoint_proposal_job.ts](sequencer-client/src/sequencer/checkpoint_proposal_job.ts)). -- **B5 — Attestation quorum.** A checkpoint is valid only with ⌊2n/3⌋+1 committee signatures over the consensus payload - (EIP-712, slot-bound). Enforced off-chain by every syncing node, and on-chain at proof submission and in - `invalidateInsufficientAttestations`. - -### 4.3 Checkpointed chain and delayed attestation verification - -L1 does **not** verify committee attestations at propose time (gas optimization). The security burden moves to: - -- **C1 — Nodes never follow bad-attestation checkpoints.** Every node validates attestations (signatures, committee - membership at the correct index, quorum) from L1 **calldata** before fetching or decoding blobs, so a checkpoint with - invalid attestations is rejected without touching potentially malformed blob data - ([validation.ts](archiver/src/modules/validation.ts)). -- **C2 — Sync never bricks.** The archiver skips invalid checkpoints, advances its syncpoint past them, and keeps - processing. The `inHash` check cannot be weaponized: L1 enforces `header.inHash == inbox.consume(...)` at propose - time, so a local mismatch indicates a node bug, not attacker-controlled input (`ProposeLib`). -- **C3 — Bad-attestation checkpoints are removable and unprovable.** Anyone can call `invalidateBadAttestation` / - `invalidateInsufficientAttestations` to purge them (no rebate; expected callers: next proposer, then committee, then - any validator, with timed fallbacks in the sequencer). They cannot enter the proven chain: `submitEpochRootProof` - re-verifies the last checkpoint's attestations (`InvalidateLib`, `EpochProofLib`). -- **C4 — Posting bad attestations is punished.** `PROPOSED_INSUFFICIENT_ATTESTATIONS`, - `PROPOSED_INCORRECT_ATTESTATIONS`, and `PROPOSED_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS` target the - publishing proposer (only the one who publishes to L1 — a pipelined builder that discarded its work is not at fault). -- **C5 — Malicious-quorum damage is time-bounded.** Invalid-but-attested state persists at most until the epoch's proof - submission window expires, at which point the rollup prunes on the next propose and nodes preemptively unwind - (`canPruneAtTime`). Honest checkpoints built on top are collateral damage of the prune (accepted). - -### 4.4 Proving - -- **V1 — Soundness (A2).** No prover input may yield an accepted proof of an invalid transition. Circuit-level; out of - scope here but the anchor for everything above. -- **V2 — Completeness (A3).** Anything the network accepts as valid must be provable. Consequence for node config: - restrictions enforced only in validator software (e.g. block/tx caps) must never make circuit-valid state unprovable - or unsyncable — see S1. -- **V3 — Prune on missing proof.** Epochs unproven past the window are removed; the chain falls back to the proven tip. - This is the designed recovery from both prover outages and malicious-quorum garbage. -- **V4 — Unsound-verifier blast radius.** Defense in depth for a hypothetical L1 verifier bug: (a) epoch-proof fee - data is not free calldata — fees and rewards derive from checkpoint headers that L1 re-hashes against the - committee-attested header hashes, so a forged proof cannot redirect or inflate fees; (b) proof submission - requires valid committee attestations on the range's last checkpoint, so a lone prover cannot promote arbitrary state - — it needs a colluding quorum (or a hatch window, see §7). This is the committee's training-wheels role from A2. - Residual exposure: Outbox withdrawals from a maliciously-proven state; tracked as a known gap in §7. - -### 4.5 L1 sync completeness - -- **S1 — Sync everything provable.** Nodes must be able to sync from L1 any checkpoint that provers can prove, even if - local validator policy would have refused to attest to it. Validator-side caps (`VALIDATOR_MAX_*`) apply only to p2p - proposal validation; the archiver's L1 path enforces only attestation validity, `inHash` consistency, and structural - blob decoding. Rationale: escape-hatch and malicious-quorum checkpoints bypass validator policy but can still reach - the proven chain, and a node that refuses to sync them forks itself off. -- **S2 — L1 reorg resilience.** Message and checkpoint sync detect L1 reorgs (rolling hashes, archive root comparison), - unwind to the common ancestor, and re-fetch — including the subtle case of checkpoints added *behind* the syncpoint. - See [archiver README](archiver/README.md) § Edge Cases. - -### 4.6 Slashing fairness - -- **F1 — Honest validators are never slashable.** Every offense requires either the offender's own signature - (proposals, attestations — EIP-712 slot-bound, so replay across slots is impossible) or sustained, locally-observable - inactivity. An honest validator signs only what it built or successfully re-executed, so no message from a malicious - peer can route it into slashable behavior. HA deployments coordinate signing via a shared store to prevent - self-equivocation ([validator-ha-signer](validator-ha-signer/)). -- **F2 — Individual accountability.** Offenses target individuals: an honest committee member is not slashed because - the majority misbehaved. Note `DATA_WITHHOLDING` targets *all attesters* of the checkpoint — making the data - available is part of the attester's duty, and any single honest attester publishing the txs clears the whole - committee. -- **F3 — Governance backstop.** Slashes need >50% of a round's proposers to vote, wait out an execution delay, and can - be vetoed. Protects against correlated false positives from software bugs (and from A7 violations). - -## 5. Slashing - -Consensus-based: proposers vote (2 bits per validator: 0–3 slash units) during round N on offenses from round N−2; the -L1 `SlashingProposer` tallies and executes after the delay unless vetoed. Full mechanics in -[slasher README](slasher/README.md); offense rationale in AZIP-7. - -| Offense | Target | Trigger | -| --- | --- | --- | -| `DATA_WITHHOLDING` | All attesters of the checkpoint | Checkpoint txs not available on p2p within the tolerance window | -| `INACTIVITY` | Individual validator | Missed proposals/attestations beyond threshold for consecutive committee epochs (Sentinel) | -| `BROADCASTED_INVALID_BLOCK_PROPOSAL` | Proposer | Block proposal fails validation/re-execution | -| `BROADCASTED_INVALID_CHECKPOINT_PROPOSAL` | Proposer | Checkpoint proposal invalid: truncates its own later block, header mismatch on recomputation, malformed fee modifier | -| `PROPOSED_INSUFFICIENT_ATTESTATIONS` | Publishing proposer | L1 checkpoint with < ⌊2n/3⌋+1 signatures | -| `PROPOSED_INCORRECT_ATTESTATIONS` | Publishing proposer | L1 checkpoint with non-committee or invalid signatures | -| `PROPOSED_DESCENDANT_OF_CHECKPOINT_WITH_INVALID_ATTESTATIONS` | Publishing proposer | Built on an invalid-attestation checkpoint | -| `DUPLICATE_PROPOSAL` | Proposer | Conflicting proposals for the same position (p2p or p2p-vs-L1) | -| `DUPLICATE_ATTESTATION` | Attester | Conflicting attestations for the same slot | -| `ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL` | Attesters | Attested in a slot where the node detected (via re-execution) a slashable invalid proposal | - -Amounts map to three L1-configured tiers. On the v5 testnet (AZIP-16 preset) any offense effectively ejects the -validator (slash drops stake below the local ejection threshold, forcing full exit); mainnet uses smaller amounts. -Grace period after genesis; own validators are auto-protected from a node's own votes; `SLASH_VALIDATORS_NEVER/ALWAYS` -allow operator overrides. - -Audit angles: offense detection soundness under A7 (config divergence), vote encoding and tally correctness on L1, -veto/delay windows vs validator exit timing, and whether any honest behavior can satisfy an offense predicate. - -## 6. P2P peer penalization - -Peer standing combines two layers. The **application-level score** -([peer_scoring.ts](p2p/src/services/peer-manager/peer_scoring.ts)) -accumulates penalties only (there is no reward path), decays by ×0.9 per minute, and drives the peer manager: score -below −50 → GOODBYE + disconnect on the next heartbeat (~30s); below −100 → 24h ban. While banned, the score is frozen -(no decaying out early) and fresh inbound connections are refused; a mere "disconnect"-scored peer may reconnect. The -**gossipsub score** independently combines the app score (weight 10, `-Infinity` for unauthenticated peers when the -node only accepts validator peers via `p2pAllowOnlyValidators`), per-topic delivery scoring (rewards up to +33/topic on predictable-rate topics; the `tx` -topic has no delivery scoring), an invalid-message penalty (P4, weight −20, ~4-slot decay, incremented by every gossip -`REJECT`), and an IP-colocation penalty (−5). Tuning math: [gossipsub README](p2p/src/services/gossipsub/README.md). - -Application-level severities (penalty points against the −50/−100 thresholds; boundaries are strict, so e.g. two -`LowTolerance` strikes reach exactly −100 and a third is needed to ban): - -| Severity | Points | Example conditions | -| --- | --- | --- | -| `LowToleranceError` | 50 | Invalid tx proof, undeserializable message, double-spend of a long-settled nullifier, malformed req/resp payload | -| `MidToleranceError` | 10 | Most tx validation failures (metadata, gas, phases, size), inconsistent req/resp batch responses | -| `HighToleranceError` | 2 | Conditions plausibly caused by state lag or transient faults: recent double-spend, timestamp expiry, per-peer rate-limit breach, connection resets/timeouts | - -Req/resp (PING, STATUS, AUTH, GOODBYE, TX, BLOCK_TXS): per-peer and global rate limits per protocol (handshake -protocols 5/s per peer, 10/s global; TX and BLOCK_TXS 10/s per peer, 200/s global). The per-peer check runs before the -global one, so a single peer cannot exhaust the shared budget and have others blamed; exceeding a per-peer limit costs -a `HighToleranceError`, exceeding the global limit returns `RATE_LIMIT_EXCEEDED` with no penalty. Malformed -requests/responses penalize the sender (`LowToleranceError`); transport errors and timeouts are treated as transient -(`HighToleranceError`); self-inflicted aborts are never penalized. - -Handshake failures are a separate subsystem that bypasses scoring: failed AUTH attempts (nodes running with -`p2pAllowOnlyValidators`) get -exponential dial backoff (5 min doubling to 160 min) and, past `p2pMaxFailedAuthAttemptsAllowed` (default 3), inbound -denial until 1h passes without failures. Trusted/private/preferred peers are exempt from manager-initiated -disconnection and capacity eviction, but **not** from scoring or gossipsub graylisting. - -The reject-vs-ignore mapping per message type (which failures penalize the sender vs get silently dropped) is the -enforcement of P2 and is catalogued per validator in [message validator READMEs](p2p/src/msg_validators/). - -## 7. Known gaps - -1. **Descendants of invalid checkpoints.** If a malicious quorum attests to a *descendant* of an invalid-attestation - checkpoint, nodes currently follow it (they assume an honest majority) instead of ignoring it until proven. Flagged - in [archiver README](archiver/README.md); the corresponding slash (`PROPOSED_DESCENDANT_…`) exists, but node-side - chain-selection does not. -2. **Escape hatch × unsound verifier.** `submitEpochRootProof` skips attestation verification for hatch epochs (there - is no committee), so during a hatch window the attestation gate of V4(b) is absent: a malicious hatch proposer plus - an L1 verifier bug could prove invalid state. Fee theft is still blocked (V4(a)); Outbox withdrawals are the - remaining exposure. Compensating controls today: bond, random selection among bonded candidates, bounded window. -3. **Validation config divergence (A7).** Divergent `VALIDATOR_MAX_*` limits across honest validators can produce - divergent invalidity verdicts and therefore divergent slash votes against honest proposers. Mitigated by the >50% - vote quorum and the vetoer; no protocol-level enforcement of config uniformity exists. -4. **Committee trust is a standing decision (A6).** Following attested checkpoints without re-execution is deliberate - and may be revisited (e.g. stateless validation of checkpointed data); until then, C1–C5 are the whole story for - pending-chain integrity. -5. **Blob retention.** Nodes syncing later than the L1 blob retention window depend on out-of-protocol blob archives to - reconstruct the checkpointed chain; availability of that path is operational, not protocol-enforced. diff --git a/yarn-project/archiver/package.json b/yarn-project/archiver/package.json index a62c68bd8378..1a30dd0f97be 100644 --- a/yarn-project/archiver/package.json +++ b/yarn-project/archiver/package.json @@ -75,6 +75,7 @@ "@aztec/l1-artifacts": "workspace:^", "@aztec/noir-protocol-circuits-types": "workspace:^", "@aztec/protocol-contracts": "workspace:^", + "@aztec/standard-contracts": "workspace:^", "@aztec/stdlib": "workspace:^", "@aztec/telemetry-client": "workspace:^", "lodash.groupby": "^4.6.0", diff --git a/yarn-project/archiver/src/config.ts b/yarn-project/archiver/src/config.ts index 6c0e996c9d44..c01c048f7fb3 100644 --- a/yarn-project/archiver/src/config.ts +++ b/yarn-project/archiver/src/config.ts @@ -90,6 +90,14 @@ export const archiverConfigMappings: ConfigMappingsType = { description: 'Skip pruning orphan proposed blocks that have no matching proposed checkpoint.', ...booleanConfigHelper(false), }, + testPreloadStandardContracts: { + env: 'TEST_PRELOAD_STANDARD_CONTRACTS', + description: + 'Preload the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) into the contract store at ' + + 'block 0. For test environments only, and only safe when genesis seeds the matching registration/deployment ' + + 'nullifiers; otherwise a later on-chain publish would collide with the block-0 preload.', + ...booleanConfigHelper(false), + }, ...chainConfigMappings, ...l1ReaderConfigMappings, viemPollingIntervalMS: { diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index 2521ba1e0a88..fbdbaf4aca84 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -12,6 +12,7 @@ import { DateProvider } from '@aztec/foundation/timer'; import { createStore } from '@aztec/kv-store/lmdb-v2'; import { protocolContractNames } from '@aztec/protocol-contracts'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; +import { getPublishableStandardContracts } from '@aztec/standard-contracts'; import { FunctionType, decodeFunctionSignature } from '@aztec/stdlib/abi'; import type { ArchiverEmitter, BlockHash } from '@aztec/stdlib/block'; import { DEFAULT_BLOCK_DURATION_MS } from '@aztec/stdlib/config'; @@ -68,6 +69,9 @@ export async function createArchiver( ): Promise { const archiverStore = await createArchiverStore(config, initialBlockHash); await registerProtocolContracts(archiverStore); + if (config.testPreloadStandardContracts) { + await registerStandardContracts(archiverStore); + } // Create Ethereum clients const chain = createEthereumChain(config.l1RpcUrls, config.l1ChainId); @@ -227,3 +231,33 @@ export async function registerProtocolContracts(stores: ArchiverDataStores) { await stores.contractInstances.addContractInstances([contract.instance], BlockNumber(blockNumber)); } } + +/** + * Preloads the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) into the archiver store at block 0, + * mirroring {@link registerProtocolContracts}. Only invoked for test environments (via `testPreloadStandardContracts`), + * which also seed the matching registration/deployment nullifiers into the genesis nullifier tree so the store and tree + * stay consistent. Idempotent — skips contracts that already exist (e.g. on node restart). + */ +export async function registerStandardContracts(stores: ArchiverDataStores) { + const blockNumber = 0; + for (const contract of await getPublishableStandardContracts()) { + // Skip if already registered (happens on node restart with a persisted store). + if (await stores.contractClasses.getContractClass(contract.contractClass.id)) { + continue; + } + + const publicBytecodeCommitment = await computePublicBytecodeCommitment(contract.contractClass.packedBytecode); + const contractClassPublic: ContractClassPublicWithCommitment = { + ...contract.contractClass, + publicBytecodeCommitment, + }; + + const publicFunctionSignatures = contract.artifact.functions + .filter(fn => fn.functionType === FunctionType.PUBLIC) + .map(fn => decodeFunctionSignature(fn.name, fn.parameters)); + + await stores.functionNames.register(publicFunctionSignatures); + await stores.contractClasses.addContractClasses([contractClassPublic], BlockNumber(blockNumber)); + await stores.contractInstances.addContractInstances([contract.instance], BlockNumber(blockNumber)); + } +} diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index ff086c1c6a27..8a385ff54876 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -6,6 +6,7 @@ import { ProtocolContractAddress } from '@aztec/protocol-contracts'; import { ContractClassPublishedEvent } from '@aztec/protocol-contracts/class-registry'; import { ContractInstancePublishedEvent } from '@aztec/protocol-contracts/instance-registry'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; +import { getPublishableStandardContracts } from '@aztec/standard-contracts'; import { bufferAsFields } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { GENESIS_BLOCK_HEADER_HASH, L2Block } from '@aztec/stdlib/block'; @@ -19,7 +20,7 @@ import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; -import { registerProtocolContracts } from '../factory.js'; +import { registerProtocolContracts, registerStandardContracts } from '../factory.js'; import { type ArchiverDataStores, createArchiverDataStores } from '../store/data_stores.js'; import { L2TipsCache } from '../store/l2_tips_cache.js'; import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js'; @@ -149,6 +150,32 @@ describe('ArchiverDataStoreUpdater', () => { expect(await store.contractClasses.getContractClass(protocolClassId)).toBeDefined(); }); + it('preloads standard contract classes and instances via registerStandardContracts', async () => { + const standardContracts = await getPublishableStandardContracts(); + expect(standardContracts.length).toBeGreaterThan(0); + + // Not present before the preload. + for (const { contractClass } of standardContracts) { + expect(await store.contractClasses.getContractClass(contractClass.id)).toBeUndefined(); + } + + await registerStandardContracts(store); + + // Both the class and the instance are queryable from the block-0 preload. + for (const { contractClass, address } of standardContracts) { + const retrievedClass = await store.contractClasses.getContractClass(contractClass.id); + expect(retrievedClass?.id.equals(contractClass.id)).toBe(true); + const retrievedInstance = await store.contractInstances.getContractInstance(address, 1n); + expect(retrievedInstance?.address.equals(address)).toBe(true); + } + + // Calling again (e.g. on node restart with a persisted store) is idempotent and must not throw. + await expect(registerStandardContracts(store)).resolves.not.toThrow(); + for (const { contractClass } of standardContracts) { + expect(await store.contractClasses.getContractClass(contractClass.id)).toBeDefined(); + } + }); + it('removes contract class and instance data when blocks are pruned via setCheckpointData', async () => { // First, add a local provisional block with contract data const localBlock = await L2Block.random(BlockNumber(1), { diff --git a/yarn-project/archiver/tsconfig.json b/yarn-project/archiver/tsconfig.json index 62b5345315e8..d01d9c0c6690 100644 --- a/yarn-project/archiver/tsconfig.json +++ b/yarn-project/archiver/tsconfig.json @@ -36,6 +36,9 @@ { "path": "../protocol-contracts" }, + { + "path": "../standard-contracts" + }, { "path": "../stdlib" }, diff --git a/yarn-project/bot/src/factory.test.ts b/yarn-project/bot/src/factory.test.ts new file mode 100644 index 000000000000..936cfc6ed233 --- /dev/null +++ b/yarn-project/bot/src/factory.test.ts @@ -0,0 +1,87 @@ +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import type { AztecNode, AztecNodeAdmin, AztecNodeAdminConfig } from '@aztec/stdlib/interfaces/client'; +import type { EmbeddedWallet } from '@aztec/wallets/embedded'; + +import { mock } from 'jest-mock-extended'; + +import { type BotConfig, getBotDefaultConfig } from './config.js'; +import { BotFactory } from './factory.js'; +import type { BotStore } from './store/index.js'; + +class TestBotFactory extends BotFactory { + public override withNoMinTxsPerBlock(fn: () => Promise): Promise { + return super.withNoMinTxsPerBlock(fn); + } +} + +describe('BotFactory.withNoMinTxsPerBlock', () => { + let minTxsPerBlock: number | undefined; + let setConfigCalls: (number | undefined)[]; + let factory: TestBotFactory; + + beforeEach(() => { + minTxsPerBlock = 3; + setConfigCalls = []; + + const aztecNodeAdmin = mock(); + aztecNodeAdmin.getConfig.mockImplementation(() => Promise.resolve({ minTxsPerBlock } as AztecNodeAdminConfig)); + aztecNodeAdmin.setConfig.mockImplementation(config => { + setConfigCalls.push(config.minTxsPerBlock); + minTxsPerBlock = config.minTxsPerBlock; + return Promise.resolve(); + }); + + const config: BotConfig = { ...getBotDefaultConfig(), flushSetupTransactions: true }; + const wallet = mock(); + factory = new TestBotFactory(config, wallet, mock(), mock(), aztecNodeAdmin); + }); + + it('zeroes minTxsPerBlock around a call and restores it after', async () => { + await factory.withNoMinTxsPerBlock(() => { + expect(minTxsPerBlock).toBe(0); + return Promise.resolve(); + }); + + expect(setConfigCalls).toEqual([0, 3]); + expect(minTxsPerBlock).toBe(3); + }); + + it('zeroes once and restores once across overlapping calls', async () => { + const gates = [promiseWithResolvers(), promiseWithResolvers(), promiseWithResolvers()]; + const calls = gates.map(gate => factory.withNoMinTxsPerBlock(() => gate.promise)); + + gates[0].resolve(); + await calls[0]; + expect(minTxsPerBlock).toBe(0); + + gates[1].resolve(); + await calls[1]; + expect(minTxsPerBlock).toBe(0); + + gates[2].resolve(); + await calls[2]; + expect(setConfigCalls).toEqual([0, 3]); + expect(minTxsPerBlock).toBe(3); + }); + + it('restores minTxsPerBlock when the wrapped call fails', async () => { + await expect(factory.withNoMinTxsPerBlock(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom'); + + expect(setConfigCalls).toEqual([0, 3]); + expect(minTxsPerBlock).toBe(3); + }); + + it('restores minTxsPerBlock when one of several overlapping calls fails', async () => { + const gate = promiseWithResolvers(); + const failing = factory.withNoMinTxsPerBlock(() => Promise.reject(new Error('boom'))); + const succeeding = factory.withNoMinTxsPerBlock(() => gate.promise); + + await expect(failing).rejects.toThrow('boom'); + expect(minTxsPerBlock).toBe(0); + + gate.resolve(); + await succeeding; + expect(setConfigCalls).toEqual([0, 3]); + expect(minTxsPerBlock).toBe(3); + }); +}); diff --git a/yarn-project/bot/src/factory.ts b/yarn-project/bot/src/factory.ts index a4785ac02106..05d4d1067c2d 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -45,6 +45,11 @@ const FEE_JUICE_TOP_UP_THRESHOLD = 100n * 10n ** 18n; export class BotFactory { private log = createLogger('bot'); + /** Number of in-flight withNoMinTxsPerBlock calls; see that method for why they are counted. */ + private noMinTxsPerBlockDepth = 0; + /** Set by the first withNoMinTxsPerBlock entrant; resolves to the minTxsPerBlock value to restore. */ + private savedMinTxsPerBlock?: Promise<{ minTxsPerBlock?: number }>; + constructor( private readonly config: BotConfig, private readonly wallet: EmbeddedWallet, @@ -88,23 +93,35 @@ export class BotFactory { }> { const defaultAccountAddress = await this.setupAccount(); await this.ensureFeeJuiceBalance(defaultAccountAddress); - const token0 = await this.setupTokenContract(defaultAccountAddress, this.config.tokenSalt, 'BotToken0', 'BOT0'); - const token1 = await this.setupTokenContract(defaultAccountAddress, this.config.tokenSalt, 'BotToken1', 'BOT1'); - const liquidityToken = await this.setupTokenContract( - defaultAccountAddress, - this.config.tokenSalt, - 'BotLPToken', - 'BOTLP', - ); - const amm = await this.setupAmmContract( - defaultAccountAddress, - this.config.tokenSalt, - token0, - token1, - liquidityToken, - ); - await this.fundAmm(defaultAccountAddress, defaultAccountAddress, amm, token0, token1, liquidityToken); + const salt = this.config.tokenSalt; + + // token0, token1 and the LP token are independent contracts with no shared state, so deploy them + // concurrently rather than one slot at a time. + const [token0, token1, liquidityToken] = await Promise.all([ + this.setupTokenContract(defaultAccountAddress, salt, 'BotToken0', 'BOT0'), + this.setupTokenContract(defaultAccountAddress, salt, 'BotToken1', 'BOT1'), + this.setupTokenContract(defaultAccountAddress, salt, 'BotLPToken', 'BOTLP'), + ]); + + const ammDeploy = AMMContract.deploy(this.wallet, token0.address, token1.address, liquidityToken.address, { + salt, + universalDeploy: true, + }); + const ammAddress = (await ammDeploy.getInstance()).address; + + // The AMM constructor only stores the (already-derived) token addresses, and set_minter only records + // the AMM address on the LP token: neither reads the other's on-chain state, so the AMM deploy, the + // LP-minter grant, and the token0/token1 mints are mutually independent and run concurrently. + const [amm] = await Promise.all([ + this.deployAmmContract(defaultAccountAddress, ammDeploy), + this.grantLpTokenMinter(defaultAccountAddress, liquidityToken, ammAddress), + this.mintAmmLiquidity(defaultAccountAddress, token0, token1), + ]); + + // add_liquidity spends the minted token0/token1 balances and mints LP tokens, so it must follow both + // the mints and the minter grant, and target the deployed AMM. + await this.addAmmLiquidity(defaultAccountAddress, defaultAccountAddress, amm, token0, token1, liquidityToken); this.log.info(`AMM initialized and funded`); return { wallet: this.wallet, defaultAccountAddress, amm, token0, token1, node: this.aztecNode }; @@ -142,25 +159,32 @@ export class BotFactory { const rollupContract = new RollupContract(l1Client, l1ContractAddresses.rollupAddress.toString()); const rollupVersion = await rollupContract.getVersion(); - // Deploy TestContract (pays from the standing balance funded above). - const contract = await this.setupTestContract(defaultAccountAddress); + // Derive the TestContract address up front (deterministic from the salt). Seeding L1→L2 messages only + // needs the L2 recipient address — the messages are queued on L1 and don't require the L2 contract to + // exist yet (they're consumed later, after setup completes) — so the deploy (an L2 tx paying from the + // standing balance funded above) and the L1 seeding run concurrently. + const testContractDeploy = TestContract.deploy(this.wallet, { + salt: this.config.tokenSalt, + universalDeploy: true, + }); + const contractAddress = (await testContractDeploy.getInstance()).address; // Recover any pending messages from store (clean up stale ones first) await this.store.cleanupOldPendingMessages(); const pendingMessages = await this.store.getUnconsumedL1ToL2Messages(); - // Seed initial L1→L2 messages if pipeline is empty + // Seed initial L1→L2 messages if pipeline is empty. The seeds are sent one at a time: they share the + // bot's L1 account, so concurrent sends would race on the L1 nonce. const seedCount = Math.max(0, this.config.l1ToL2SeedCount - pendingMessages.length); - for (let i = 0; i < seedCount; i++) { - await seedL1ToL2Message( - l1Client, - EthAddress.fromString(l1ContractAddresses.inboxAddress.toString()), - contract.address, - rollupVersion, - this.store, - this.log, - ); - } + const inboxAddress = EthAddress.fromString(l1ContractAddresses.inboxAddress.toString()); + const [contract] = await Promise.all([ + this.deployTestContract(defaultAccountAddress, testContractDeploy), + (async () => { + for (let i = 0; i < seedCount; i++) { + await seedL1ToL2Message(l1Client, inboxAddress, contractAddress, rollupVersion, this.store, this.log); + } + })(), + ]); // Block until at least one message is ready const allMessages = await this.store.getUnconsumedL1ToL2Messages(); @@ -184,10 +208,8 @@ export class BotFactory { }; } - private async setupTestContract(deployer: AztecAddress): Promise { - const deployOpts: DeployOptions = { from: deployer }; - const deploy = TestContract.deploy(this.wallet, { salt: this.config.tokenSalt, universalDeploy: true }); - const instance = await this.registerOrDeployContract('TestContract', deploy, deployOpts); + private async deployTestContract(deployer: AztecAddress, deploy: DeployMethod): Promise { + const instance = await this.registerOrDeployContract('TestContract', deploy, { from: deployer }); return TestContract.at(instance.address, this.wallet); } @@ -292,34 +314,37 @@ export class BotFactory { return TokenContract.at(instance.address, this.wallet); } - private async setupAmmContract( - deployer: AztecAddress, - salt: Fr, - token0: TokenContract, - token1: TokenContract, - lpToken: TokenContract, - ): Promise { - const deployOpts: DeployOptions = { from: deployer }; - const deploy = AMMContract.deploy(this.wallet, token0.address, token1.address, lpToken.address, { - salt, - universalDeploy: true, - }); - const instance = await this.registerOrDeployContract('AMM', deploy, deployOpts); + private async deployAmmContract(deployer: AztecAddress, deploy: DeployMethod): Promise { + const instance = await this.registerOrDeployContract('AMM', deploy, { from: deployer }); const amm = AMMContract.at(instance.address, this.wallet); - this.log.info(`AMM deployed at ${amm.address}`); - const setMinterInteraction = lpToken.methods.set_minter(amm.address, true); - const { receipt: minterReceipt } = await setMinterInteraction.send({ + return amm; + } + + /** Grants the AMM minting rights over the LP token. set_minter only records the address, so it does not + * require the AMM contract to be deployed first. */ + private async grantLpTokenMinter(deployer: AztecAddress, lpToken: TokenContract, amm: AztecAddress): Promise { + const { receipt } = await lpToken.methods.set_minter(amm, true).send({ from: deployer, wait: { timeout: this.config.txMinedWaitSeconds }, }); - this.log.info(`Set LP token minter to AMM txHash=${minterReceipt.txHash.toString()}`); - this.log.info(`Liquidity token initialized`); + this.log.info(`Set LP token minter to AMM txHash=${receipt.txHash.toString()}`); + } - return amm; + private async mintAmmLiquidity(minter: AztecAddress, token0: TokenContract, token1: TokenContract): Promise { + this.log.info(`Minting ${MINT_BALANCE} tokens of each BotToken0 and BotToken1 for ${minter}`); + const mintBatch = new BatchCall(this.wallet, [ + token0.methods.mint_to_private(minter, MINT_BALANCE), + token1.methods.mint_to_private(minter, MINT_BALANCE), + ]); + const { receipt } = await mintBatch.send({ + from: minter, + wait: { timeout: this.config.txMinedWaitSeconds }, + }); + this.log.info(`Sent mint tx: ${receipt.txHash.toString()}`); } - private async fundAmm( + private async addAmmLiquidity( defaultAccountAddress: AztecAddress, liquidityProvider: AztecAddress, amm: AMMContract, @@ -327,22 +352,6 @@ export class BotFactory { token1: TokenContract, lpToken: TokenContract, ): Promise { - const getPrivateBalances = () => - Promise.all([ - token0.methods - .balance_of_private(liquidityProvider) - .simulate({ from: liquidityProvider }) - .then(r => r.result), - token1.methods - .balance_of_private(liquidityProvider) - .simulate({ from: liquidityProvider }) - .then(r => r.result), - lpToken.methods - .balance_of_private(liquidityProvider) - .simulate({ from: liquidityProvider }) - .then(r => r.result), - ]); - const authwitNonce = Fr.random(); // keep some tokens for swapping @@ -351,12 +360,6 @@ export class BotFactory { const amount1Max = MINT_BALANCE / 2; const amount1Min = MINT_BALANCE / 4; - const [t0Bal, t1Bal, lpBal] = await getPrivateBalances(); - - this.log.info( - `Minting ${MINT_BALANCE} tokens of each BotToken0 and BotToken1. Current private balances of ${liquidityProvider}: token0=${t0Bal}, token1=${t1Bal}, lp=${lpBal}`, - ); - // Add authwitnesses for the transfers in AMM::add_liquidity function const token0Authwit = await this.wallet.createAuthWit(defaultAccountAddress, { caller: amm.address, @@ -381,36 +384,33 @@ export class BotFactory { .getFunctionCall(), }); - const mintBatch = new BatchCall(this.wallet, [ - token0.methods.mint_to_private(liquidityProvider, MINT_BALANCE), - token1.methods.mint_to_private(liquidityProvider, MINT_BALANCE), - ]); - const { receipt: mintReceipt } = await mintBatch.send({ - from: liquidityProvider, - wait: { timeout: this.config.txMinedWaitSeconds }, - }); - - this.log.info(`Sent mint tx: ${mintReceipt.txHash.toString()}`); - - const addLiquidityInteraction = amm.methods.add_liquidity( - amount0Max, - amount1Max, - amount0Min, - amount1Min, - authwitNonce, - ); - const { receipt: addLiquidityReceipt } = await addLiquidityInteraction.send({ - from: liquidityProvider, - authWitnesses: [token0Authwit, token1Authwit], - wait: { timeout: this.config.txMinedWaitSeconds }, - }); + const { receipt } = await amm.methods + .add_liquidity(amount0Max, amount1Max, amount0Min, amount1Min, authwitNonce) + .send({ + from: liquidityProvider, + authWitnesses: [token0Authwit, token1Authwit], + wait: { timeout: this.config.txMinedWaitSeconds }, + }); - this.log.info(`Sent tx to add liquidity to the AMM: ${addLiquidityReceipt.txHash.toString()}`); + this.log.info(`Sent tx to add liquidity to the AMM: ${receipt.txHash.toString()}`); this.log.info(`Liquidity added`); - const [newT0Bal, newT1Bal, newLPBal] = await getPrivateBalances(); + const [t0Bal, t1Bal, lpBal] = await Promise.all([ + token0.methods + .balance_of_private(liquidityProvider) + .simulate({ from: liquidityProvider }) + .then(r => r.result), + token1.methods + .balance_of_private(liquidityProvider) + .simulate({ from: liquidityProvider }) + .then(r => r.result), + lpToken.methods + .balance_of_private(liquidityProvider) + .simulate({ from: liquidityProvider }) + .then(r => r.result), + ]); this.log.info( - `Updated private balances of ${defaultAccountAddress} after minting and funding AMM: token0=${newT0Bal}, token1=${newT1Bal}, lp=${newLPBal}`, + `Updated private balances of ${defaultAccountAddress} after minting and funding AMM: token0=${t0Bal}, token1=${t1Bal}, lp=${lpBal}`, ); } @@ -593,19 +593,36 @@ export class BotFactory { return claim as L2AmountClaim; } - private async withNoMinTxsPerBlock(fn: () => Promise): Promise { + protected async withNoMinTxsPerBlock(fn: () => Promise): Promise { if (!this.aztecNodeAdmin || !this.config.flushSetupTransactions) { this.log.verbose(`No node admin client or flushing not requested (not setting minTxsPerBlock to 0)`); return fn(); } - const { minTxsPerBlock } = await this.aztecNodeAdmin.getConfig(); - this.log.warn(`Setting sequencer minTxsPerBlock to 0 from ${minTxsPerBlock} to flush setup transactions`); - await this.aztecNodeAdmin.setConfig({ minTxsPerBlock: 0 }); + const aztecNodeAdmin = this.aztecNodeAdmin; + // Setup steps run concurrently, so this wrapper can be re-entered while another call is in flight. + // Reference-count the entrants: the first saves the current value and zeroes it, the last restores it. + // A naive save/zero/restore per call could interleave, with a late entrant reading the already-zeroed + // value and "restoring" 0 at the end. + if (this.noMinTxsPerBlockDepth++ === 0) { + this.savedMinTxsPerBlock = (async () => { + const { minTxsPerBlock } = await aztecNodeAdmin.getConfig(); + this.log.warn(`Setting sequencer minTxsPerBlock to 0 from ${minTxsPerBlock} to flush setup transactions`); + await aztecNodeAdmin.setConfig({ minTxsPerBlock: 0 }); + return { minTxsPerBlock }; + })(); + } try { + await this.savedMinTxsPerBlock; return await fn(); } finally { - this.log.warn(`Restoring sequencer minTxsPerBlock to ${minTxsPerBlock}`); - await this.aztecNodeAdmin.setConfig({ minTxsPerBlock }); + if (--this.noMinTxsPerBlockDepth === 0) { + // If saving/zeroing itself failed there is nothing to restore. + const saved = await this.savedMinTxsPerBlock!.catch(() => undefined); + if (saved) { + this.log.warn(`Restoring sequencer minTxsPerBlock to ${saved.minTxsPerBlock}`); + await aztecNodeAdmin.setConfig({ minTxsPerBlock: saved.minTxsPerBlock }); + } + } } } } diff --git a/yarn-project/end-to-end/README.md b/yarn-project/end-to-end/README.md index 085fac046e10..5b97d49fbb5f 100644 --- a/yarn-project/end-to-end/README.md +++ b/yarn-project/end-to-end/README.md @@ -14,15 +14,21 @@ Run a single test (spawns its own in-process anvil): ```bash yarn test:e2e src/automine/token/access_control.parallel.test.ts -yarn test:e2e src/single-node/block-building/block_building.test.ts -t 'rejects double spend' +yarn test:e2e src/single-node/block-building/block_building.test.ts -t 'rejects a private then private double-spend' ``` Turn up logging with `LOG_LEVEL` (`verbose` is the useful default; `debug:sequencer,archiver` scopes it): ```bash -LOG_LEVEL=verbose yarn test:e2e src/single-node/proving/empty_blocks.test.ts +LOG_LEVEL=verbose yarn test:e2e src/single-node/proving/default_node.test.ts ``` +Each run spawns anvil on port 8545, so two tests can only run side by side if each gets its own +`ANVIL_PORT` (p2p tests additionally bind fixed p2p ports and can never run concurrently — see +[`src/p2p/README.md`](src/p2p/README.md)). To shake flakiness out of a test, +`scripts/deflaker.sh yarn test:e2e ` reruns it up to 100 times and stops at the first failure +(output lands in `scripts/deflaker.log`). + Compose-based tests (those under `src/composed/`) need a running local network — see [Compose / HA / web3signer tests](#compose--ha--web3signer-tests). @@ -37,7 +43,7 @@ its environment, so a test file only describes the scenario, not the wiring. | [`automine/`](src/automine/README.md) | One node, deterministic `AutomineSequencer` — one block per tx, no committee/prover/validator. Fast. | it exercises contract or protocol behavior that doesn't depend on real block-building or consensus (transfers, nested calls, note discovery, tx semantics). | yes | | [`single-node/`](src/single-node/README.md) | One node, production sequencer (interval block production), optional prover. | it asserts on sequencer, proving, partial-proof, L1-reorg, recovery, fee, or cross-chain behavior on a single sequencer. | yes | | [`multi-node/`](src/multi-node/README.md) | N validators on an in-memory mock-gossip bus. | it needs a committee: consensus, attestations, slashing, governance, or multi-validator block production. | yes | -| `p2p/` | Real libp2p transport between nodes. | the networking transport itself is under test (gossip, rediscovery, req/resp). | — | +| [`p2p/`](src/p2p/README.md) | Real libp2p transport between nodes. | the networking transport itself is under test (gossip, rediscovery, req/resp). | yes | | [`infra/`](src/infra/README.md) | Targets a deployed/external network (local anvil or a public testnet). | its concern is deployment or network targeting, not a specific protocol behavior. | yes | A handful of tests live **outside** this package, next to the code they test — see @@ -78,7 +84,7 @@ Each category centralizes its environment in a base class. The hierarchy: (both wrap `fixtures/setup.ts:setup()`), but fixes the automine topology and makes `AUTOMINE_E2E_OPTS` the default. Exposes `markProvenAndWarp`, `registerContract`, `applyManualParentChild`. - `p2p/p2p_network.ts` → **`P2PNetworkTest`**. Real libp2p; node creation goes through - `setup_p2p_test.ts`. + `fixtures/setup_p2p_test.ts`. - `infra/` has no shared base — its tests target a network selected by `L1_CHAIN_ID` (local anvil in CI, a public testnet with credentials). @@ -91,8 +97,8 @@ Categories expose thin factories over their base's static `setup`, named by what calls the factory instead of spreading option presets: - `single-node/setup.ts`: `setupWithProver` (fake in-process prover — the single-node default) and - `setupBlockProducer` (no prover; raises `aztecProofSubmissionEpochs` to `1024` so unproven blocks - aren't pruned, and points the PXE at `syncChainTip: 'proposed'`). + `setupBlockProducer` (no prover; raises `aztecProofSubmissionEpochs` to `NO_REORG_SUBMISSION_EPOCHS` + (1024) so unproven blocks aren't pruned, and points the PXE at `syncChainTip: 'proposed'`). - `automine` tests call `AutomineTestContext.setup({ numberOfAccounts })` directly. ### The harness pattern (domain setup on top of a category) @@ -136,15 +142,20 @@ CI splits each `it` in a `.parallel.test.ts` file into its own docker job, runni ### CI test discovery — `bootstrap.sh` -`end-to-end/bootstrap.sh` enumerates tests in two arrays, and a test must appear in the relevant one or it -**won't run in CI**: +`end-to-end/bootstrap.sh` enumerates tests in two arrays, and a test must resolve through the relevant one +or it **won't run in CI**: -- `test_cmds` (~line 37) — the standard run. -- `compat_test_cmds` (~line 290) — the forward/legacy-compat run (a subset). +- `test_cmds` — the standard run. Covers each category with a recursive glob (e.g. + `src/automine/!(simulation)/**/*.test.ts`, `src/multi-node/**/*.test.ts`), so a new file or sub-folder + inside an existing category is picked up automatically; only a new top-level category needs its own glob + line. Tests with bespoke handling sit outside the globs: the `single-node/prover/` lanes at the top of + the function (real proofs and custom resources under `CI_FULL`, `FAKE_PROOFS=1` otherwise) and + `avm_simulator` (below). +- `compat_test_cmds` — the forward/legacy-compat run (a subset). This one enumerates **single-level leaf + globs** (e.g. `src/automine/token/*.test.ts`), so a new sub-folder whose tests should run against legacy + contract artifacts needs its own line here. -Each leaf folder needs its own single-level glob line (e.g. `src/automine/token/*.test.ts`) in each array; -globs are not recursive, so every sub-folder is listed explicitly. Folders that organize by behavior get -one line per leaf. Bespoke handling to be aware of: +Bespoke handling to be aware of: - **`avm_simulator`** (`automine/simulation/avm_simulator.test.ts`) has a dedicated line in `test_cmds` that sets `DUMP_AVM_INPUTS_TO_DIR` (feeds the downstream `avm_check_circuit` job) and is therefore @@ -153,8 +164,8 @@ one line per leaf. Bespoke handling to be aware of: - **`kernelless_simulation`** is excluded from `compat_test_cmds` only. After editing the arrays, confirm every `*.test.ts` resolves through exactly one line (no duplicate, no -omission). Per-test bash `TIMEOUT` overrides live in the `case` block in `test_cmds` and must stay in sync -with the test's `jest.setTimeout`. +omission — anything excluded via `!(...)` must be matched by its dedicated line). Per-test bash `TIMEOUT` +overrides live in the `case` block in `test_cmds` and must stay in sync with the test's `jest.setTimeout`. ### Flaky tests — `.test_patterns.yml` @@ -190,11 +201,12 @@ These run in their own package's test lane (both packages already run anvil-back ### Support directories (not test categories) -- `fixtures/` — the shared `setup()`, option presets (`fixtures.ts`), `CrossChainTestHarness`, - `l1_to_l2_messaging`, and common utils. -- `shared/` — shared test bodies and `timing_env.mjs`, a **custom jest `testEnvironment`** referenced from - this package's `package.json`. `yarn prepare` / the package-json check will try to revert it to the - default — don't let it. +- `fixtures/` — the shared `setup()`, option presets (`fixtures.ts`), the named node-level waiters + (`wait_helpers.ts`), the span instrumentation (`timing.ts` — `testSpan`, zero-cost unless + `TEST_TIMING_FILE` is set), `l1_to_l2_messaging`, and common utils. +- `shared/` — shared test bodies, the `CrossChainTestHarness`, and `timing_env.mjs`, a **custom jest + `testEnvironment`** referenced from this package's `package.json`. `yarn prepare` / the package-json + check will try to revert it to the default — don't let it. - `simulators/` — in-TS reference models (`TokenSimulator`, `LendingSimulator`) used to assert contract behavior. - `test-wallet/`, `bench/`, `spartan/`, `quality_of_service/`, `forward-compatibility/` — helpers, diff --git a/yarn-project/end-to-end/src/fixtures/e2e_prover_test.ts b/yarn-project/end-to-end/src/fixtures/e2e_prover_test.ts index 8ed88d839301..76ab016520bc 100644 --- a/yarn-project/end-to-end/src/fixtures/e2e_prover_test.ts +++ b/yarn-project/end-to-end/src/fixtures/e2e_prover_test.ts @@ -24,6 +24,7 @@ import { TestWallet } from '../test-wallet/test_wallet.js'; import { getACVMConfig } from './get_acvm_config.js'; import { getBBConfig } from './get_bb_config.js'; import { getPrivateKeyFromIndex, getSponsoredFPCAddress, setup, setupPXEAndGetWallet } from './setup.js'; +import { getStandardContractGenesisNullifiers } from './standard_contracts_genesis.js'; type ProvenSetup = { wallet: TestWallet; @@ -234,6 +235,7 @@ export class FullProverTest extends SingleNodeTestContext { undefined, undefined, this.context.genesis!.genesisTimestamp, + await getStandardContractGenesisNullifiers(), ); const proverNodeConfig: Parameters[0] = { diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 3dc87c409dd2..8281c8104cce 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -77,6 +77,7 @@ import { MNEMONIC, TEST_MAX_PENDING_TX_POOL_COUNT, TEST_PEER_CHECK_INTERVAL_MS } import { getACVMConfig } from './get_acvm_config.js'; import { getBBConfig } from './get_bb_config.js'; import { isMetricsLoggingRequested, setupMetricsLogger } from './logging.js'; +import { getStandardContractGenesisNullifiers } from './standard_contracts_genesis.js'; import { testSpan } from './timing.js'; import { getEndToEndTestTelemetryClient } from './with_telemetry_utils.js'; @@ -220,6 +221,14 @@ export type SetupOptions = { zkPassportArgs?: ZKPassportArgs; /** Whether to fund the sponsored FPC in genesis (defaults to false). */ fundSponsoredFPC?: boolean; + /** + * Compute extra addresses to fund at genesis from the accounts setup just generated (passed as the + * argument). Runs after the default accounts are created and before genesis values are computed, so a + * test can genesis-fund a contract whose address derives from a default account (e.g. an FPC whose + * admin is the first account) instead of bridging fee juice to it during setup. Each returned address + * is funded with the same fee juice as an initial account and included in the L1 portal `fundingNeeded`. + */ + computeExtraGenesisFundedAddresses?: (defaultAccounts: InitialAccountData[]) => Promise; /** L1 contracts deployment arguments. */ l1ContractsArgs?: Partial; /** Wallet minimum fee padding multiplier */ @@ -458,14 +467,28 @@ async function setupInner( const sponsoredFPCAddress = await getSponsoredFPCAddress(); addressesToFund.push(sponsoredFPCAddress); } + + // Fund any extra addresses whose value depends on the just-generated accounts (e.g. an FPC admin'd + // by a default account), so a test can genesis-fund them instead of bridging fee juice during setup. + if (opts.computeExtraGenesisFundedAddresses) { + addressesToFund.push(...(await opts.computeExtraGenesisFundedAddresses(defaultAccounts))); + } logger.trace('Generated test accounts to fund at genesis'); + // Preload the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) so their `ensure*Published` setup + // helpers short-circuit their publish txs. The archiver flag seeds the bytecode/instance into every spawned node's + // contract store; the genesis nullifiers make the AVM's deployment-nullifier check pass when they are called. Both + // must go together (flag alone would recreate the publish-collision bug), so the flag lives here beside the seeding. + config.testPreloadStandardContracts = true; + const standardContractNullifiers = await getStandardContractGenesisNullifiers(); + const genesisTimestamp = BigInt(Math.floor(Date.now() / 1000)); const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues( addressesToFund, opts.initialAccountFeeJuice, opts.genesisPublicData, genesisTimestamp, + standardContractNullifiers, ); logger.trace('Computed genesis values'); diff --git a/yarn-project/end-to-end/src/fixtures/standard_contracts_genesis.ts b/yarn-project/end-to-end/src/fixtures/standard_contracts_genesis.ts new file mode 100644 index 000000000000..14c52180234b --- /dev/null +++ b/yarn-project/end-to-end/src/fixtures/standard_contracts_genesis.ts @@ -0,0 +1,28 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import { ProtocolContractAddress } from '@aztec/protocol-contracts'; +import { getPublishableStandardContracts } from '@aztec/standard-contracts'; +import { siloNullifier } from '@aztec/stdlib/hash'; + +/** + * Computes the genesis nullifiers that pre-publish the standard contracts (AuthRegistry, PublicChecks, + * HandshakeRegistry) in e2e environments, mirroring the nullifiers their on-chain publish txs would emit. Per contract: + * - `siloNullifier(ContractClassRegistry, classId)` — the class-registration nullifier that `ContractClassRegistry.publish` pushes. + * - `siloNullifier(ContractInstanceRegistry, instanceAddress)` — the instance-deployment nullifier that + * `ContractInstanceRegistry.publish_for_public_execution` pushes, using the contract's real derived address (standard + * contracts are deployed at artifact-derived addresses, not magic protocol addresses). + * + * Seed these into the genesis nullifier tree (as the 5th `getGenesisValues` arg) alongside the archiver's + * `testPreloadStandardContracts` preload: the store preload makes the `ensure*Published` guards short-circuit, and these + * nullifiers make the AVM's deployment-nullifier check pass when the contracts are called. Every e2e node genesis that + * feeds an L1 `genesisArchiveRoot` must seed the same set, or the world-state root diverges from the deployed rollup. + */ +export async function getStandardContractGenesisNullifiers(): Promise { + const classRegistry = ProtocolContractAddress.ContractClassRegistry; + const instanceRegistry = ProtocolContractAddress.ContractInstanceRegistry; + const nullifiers: Fr[] = []; + for (const { contractClass, address } of await getPublishableStandardContracts()) { + nullifiers.push(await siloNullifier(classRegistry, contractClass.id)); + nullifiers.push(await siloNullifier(instanceRegistry, address.toField())); + } + return nullifiers; +} diff --git a/yarn-project/end-to-end/src/multi-node/README.md b/yarn-project/end-to-end/src/multi-node/README.md index 9751a9856240..522079aa206f 100644 --- a/yarn-project/end-to-end/src/multi-node/README.md +++ b/yarn-project/end-to-end/src/multi-node/README.md @@ -31,7 +31,8 @@ copy-pasted: `getPrivateKeyFromIndex(i + 3)`), passed as `initialValidators`. - `MOCK_GOSSIP_MULTI_VALIDATOR_OPTS` — a tight committee on the mock bus with no prover (`{ mockGossipSubNetwork, skipInitialSequencer, startProverNode: false, aztecProofSubmissionEpochs: - 1024, numberOfAccounts: 0 }`). Tests that want a prover leave `startProverNode` explicit. + NO_REORG_SUBMISSION_EPOCHS, numberOfAccounts: 0 }`, the constant re-exported from `../single-node/setup.ts`). + Tests that want a prover leave `startProverNode` explicit. - `SLASHER_ENABLED_MULTI_VALIDATOR_OPTS` — the same committee with the slasher turned on, used by the offense-detection tests. - `defaultSlashingPenalties(unit?)` / `withOnlyOffense(offense, unit?)` — build the per-offense diff --git a/yarn-project/end-to-end/src/multi-node/block-production/high_tps.test.ts b/yarn-project/end-to-end/src/multi-node/block-production/high_tps.test.ts index 4c4c9611a6bf..37b5a1f36d8f 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/high_tps.test.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/high_tps.test.ts @@ -76,6 +76,11 @@ describe('multi-node/block-production/high_tps', () => { ({ test, context, logger, validators, nodes, from } = await setupSimpleBlockProduction({ nodeCount: NODE_COUNT, setupOpts: { + // Pin the old 36s/6s cadence (overriding MULTI_VALIDATOR_BLOCK_PRODUCTION_TIMING's 24s/4s): this + // suite's per-block budget is 2 txs x 2.5s = 5s, which needs a 6s block sub-slot (the full T=0..36s + // budget in this file's header is built around it) and does not fit the profile's 4s block. + aztecSlotDurationInL1Slots: 3, + blockDurationMs: 6000, fakeProcessingDelayPerTxMs: TX_DURATION_MS, attestationPropagationTime: 1, minTxsPerBlock: 1, diff --git a/yarn-project/end-to-end/src/multi-node/block-production/proof_boundary.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/block-production/proof_boundary.parallel.test.ts index a48bdff9d1e7..94487bef9f4c 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/proof_boundary.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/proof_boundary.parallel.test.ts @@ -28,8 +28,8 @@ type PublishedEvent = Parameters[0]; // Suite: 5 parallel scenarios testing the interaction between the proof submission deadline and // the pipelining boundary slot. MultiNodeTestContext: 3 validator nodes + 1 prover node, -// mockGossipSubNetwork, skipInitialSequencer. Timing: ethSlot=12s, aztecSlot=3×12=36s, -// epoch=default 6, proofSubmissionEpochs=1 (overridden per test via setupTest), blockDurationMs=6s, +// mockGossipSubNetwork, skipInitialSequencer. Timing: ethSlot=12s, aztecSlot=2×12=24s, +// epoch=default 6, proofSubmissionEpochs=1 (overridden per test via setupTest), blockDurationMs=4s, // inboxLag=2 (v5 always enforces the timetable, so the former enforceTimeTable/disableAnvilTestWatcher // overrides are gone). The Delayer is used to steer proof tx timing. describe('multi-node/block-production/proof_boundary', () => { diff --git a/yarn-project/end-to-end/src/multi-node/block-production/simple.test.ts b/yarn-project/end-to-end/src/multi-node/block-production/simple.test.ts index 2f21f3ce519b..3ad7f4bcfe66 100644 --- a/yarn-project/end-to-end/src/multi-node/block-production/simple.test.ts +++ b/yarn-project/end-to-end/src/multi-node/block-production/simple.test.ts @@ -16,7 +16,7 @@ const TX_COUNT_SIMPLE = 8; // Verifies that 3 validator nodes can build blocks without sequencer errors. Lightweight RPC-only // initial node (skipInitialSequencer), mockGossipSubNetwork, no prover. Timing: ethSlot=12s, -// aztecSlot=36s, epoch=default 6, proofSubmissionEpochs=1024, blockDurationMs=6s. Pre-proved txs sent +// aztecSlot=24s, epoch=default 6, proofSubmissionEpochs=1024, blockDurationMs=4s. Pre-proved txs sent // from the hardcoded genesis-funded account (no on-chain account deploy needed). describe('multi-node/block-production/simple', () => { let context: EndToEndContext; diff --git a/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts b/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts index 17a096978ba1..c55e63c0db7c 100644 --- a/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/multi-node/governance/add_rollup.test.ts @@ -34,6 +34,7 @@ import { type Hex, decodeEventLog, encodeFunctionData, getAddress, getContract } import { foundry } from 'viem/chains'; import { sendL1ToL2Message } from '../../fixtures/l1_to_l2_messaging.js'; +import { getStandardContractGenesisNullifiers } from '../../fixtures/standard_contracts_genesis.js'; import { getPrivateKeyFromIndex, getSponsoredFPCAddress } from '../../fixtures/utils.js'; import { TestWallet } from '../../test-wallet/test_wallet.js'; import { @@ -136,7 +137,13 @@ describe('multi-node/governance/add_rollup', () => { genesisArchiveRoot, fundingNeeded, genesis: newGenesis, - } = await getGenesisValues(genesisFundedAddresses, undefined, undefined, context.genesis!.genesisTimestamp + 1n); + } = await getGenesisValues( + genesisFundedAddresses, + undefined, + undefined, + context.genesis!.genesisTimestamp + 1n, + await getStandardContractGenesisNullifiers(), + ); const { rollup: newRollup } = await deployRollupForUpgrade( deployerPrivateKey, diff --git a/yarn-project/end-to-end/src/multi-node/high-availability/ha_checkpoint_handoff.test.ts b/yarn-project/end-to-end/src/multi-node/high-availability/ha_checkpoint_handoff.test.ts index 91fc8e5ac6a5..2a42d2bc0bb6 100644 --- a/yarn-project/end-to-end/src/multi-node/high-availability/ha_checkpoint_handoff.test.ts +++ b/yarn-project/end-to-end/src/multi-node/high-availability/ha_checkpoint_handoff.test.ts @@ -57,8 +57,8 @@ const VALIDATOR_COUNT = 4; * `mockGossipSubNetwork` bus, then 4 validator nodes created via `test.createValidatorNode` in 2 HA pairs. Each pair * shares its two validator keys plus an in-memory `createSharedSlashingProtectionDb` (so only one peer signs per duty) * — explicitly NOT the Postgres-backed docker-compose HA suite, so this is an in-proc `multi-node` test, not infra. - * Production `Sequencer`, no prover node. Timing: ethSlot=6s, aztecSlot=36s, epoch=4, proofSubEpochs=1024, - * blockDurationMs=8s, committeeSize=4, attestationPropagationTime=0.5, inboxLag=2; anvil on interval mining. Nodes build + * Production `Sequencer`, no prover node. Timing: ethSlot=6s, aztecSlot=24s, epoch=4, proofSubEpochs=1024, + * blockDurationMs=5s, committeeSize=4, attestationPropagationTime=0.5, inboxLag=2; anvil on interval mining. Nodes build * empty checkpoints (`buildCheckpointIfEmpty` + `minTxsPerBlock: 0`) so no txs are needed, and each node uses a distinct * coinbase so the secondary assertion can prove which peer produced S2. Time is warped with `cheatCodes.eth.warp`: * `findConsecutiveSamePairSlots` recovers from `ValidatorSelection__EpochNotStable` by warping forward one epoch, and diff --git a/yarn-project/end-to-end/src/multi-node/recovery/equivocation_recovery.test.ts b/yarn-project/end-to-end/src/multi-node/recovery/equivocation_recovery.test.ts index 61fac1f986b9..ab293a3286c5 100644 --- a/yarn-project/end-to-end/src/multi-node/recovery/equivocation_recovery.test.ts +++ b/yarn-project/end-to-end/src/multi-node/recovery/equivocation_recovery.test.ts @@ -57,12 +57,12 @@ describe('multi-node/recovery/equivocation_recovery', () => { // Build 4 validators (V1..V4) using the shared deterministic builder (keys from index 3). const validators = buildMockGossipValidators(NODE_COUNT); - // Timing calculation for 3 blocks per checkpoint with 8s sub-slots: + // Timing calculation for 3 blocks per checkpoint with 5s sub-slots: // - initializationOffset = 0.5s (test mode, ethereumSlotDuration < 8) - // - 3 blocks x 8s = 24s + // - 3 blocks x 5s = 15s // - checkpointFinalization = 0.5s (assemble) + 0 (p2p in test) + 2s (L1 publish) = 2.5s - // - finalBlockDuration = 8s (re-execution) - // - Total: 0.5 + 24 + 8 + 2.5 = 35s => use 36s + // - finalBlockDuration = 5s (re-execution) + // - Total: 0.5 + 15 + 5 + 2.5 = 23s => use 24s const slashingUnit = BigInt(1e14); test = await MultiNodeTestContext.setup({ ...MOCK_GOSSIP_MULTI_VALIDATOR_OPTS, diff --git a/yarn-project/end-to-end/src/multi-node/recovery/proposal_failure_recovery.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/recovery/proposal_failure_recovery.parallel.test.ts index 35387b9d38b8..16867863a69f 100644 --- a/yarn-project/end-to-end/src/multi-node/recovery/proposal_failure_recovery.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/recovery/proposal_failure_recovery.parallel.test.ts @@ -2,7 +2,6 @@ import type { Archiver } from '@aztec/archiver'; import type { AztecNodeService } from '@aztec/aztec-node'; import { EthAddress } from '@aztec/aztec.js/addresses'; import type { Logger } from '@aztec/aztec.js/log'; -import { waitUntilL1Timestamp } from '@aztec/ethereum/l1-tx-utils'; import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { retryUntil } from '@aztec/foundation/retry'; @@ -30,7 +29,7 @@ const NODE_COUNT = 4; * blocks, and the next proposer rebuilds a fresh checkpoint that lands on L1. * * Both scenarios share the same 4-validator mock-gossip cluster (one key per node, no prover) on the - * multi-validator reorg cadence (ethSlot=6s, aztecSlot=36s, epoch=4, proofSubmissionEpochs=1024, blockDurationMs=8000, + * multi-validator reorg cadence (ethSlot=6s, aztecSlot=24s, epoch=4, proofSubmissionEpochs=1024, blockDurationMs=5000, * inboxLag=2 — v5 always enforces the timetable). Each test warps L1 to align with its target build slot. */ describe('multi-node/recovery/proposal_failure_recovery', () => { @@ -188,11 +187,16 @@ describe('multi-node/recovery/proposal_failure_recovery', () => { logger.warn(`Waiting for proposed chain to reach slot ${slotTwo} on all nodes (build during slotOne)`); await test.waitForAllNodesToReachBlockAtSlot(slotTwo, 'proposed', undefined, { timeout: slotAdvanceTimeout }); - // (3) Wait until slotOne has fully ended on L1 — the archiver only prunes once slotAtNextL1Block > slotOne. - // The end-of-slotOne timestamp equals the start-of-slotTwo timestamp. + // (3) Collapse the dead gap where the chain just waits for the L1 clock to roll past slotOne so the + // archiver prunes the uncheckpointed slotOne/slotTwo blocks (it only prunes once slotAtNextL1Block > + // slotOne, and the end-of-slotOne timestamp equals the start-of-slotTwo timestamp). The pipelined slotTwo + // broadcast has already reached every node (step 2) and slotThree does not build until slotTwo, so nothing + // needs to be produced in this window. The sequencers are paused across the warp — warping under a running + // sequencer would interrupt in-flight builds — and kept stopped (restart: false) until the prune is + // confirmed, so no proposer builds against the still-unpruned tip; they are restarted for recovery below. const slotOneEndTimestamp = getTimestampForSlot(slotTwo, test.constants); - logger.warn(`Waiting until L1 timestamp ${slotOneEndTimestamp} (end of slot ${slotOne})`); - await waitUntilL1Timestamp(test.l1Client, slotOneEndTimestamp, undefined, test.L2_SLOT_DURATION_IN_S * 3); + logger.warn(`Warping past the end of slot ${slotOne} (L1 timestamp ${slotOneEndTimestamp}) to trigger the prune`); + await test.warpWithSequencersPaused(nodes, test.context.cheatCodes, slotOneEndTimestamp, { restart: false }); // (4) After slotOne ends without a checkpoint, all nodes should prune. // Verify rollback via the prune event itself: the pruned slot must equal slotOne, and the @@ -216,9 +220,13 @@ describe('multi-node/recovery/proposal_failure_recovery', () => { expect(prunedSlots).toContain(slotOne); } - // (5) Allow the formerly suppressed node to publish again so the chain can recover. + // (5) Allow the formerly suppressed node to publish again, then restart the paused sequencers so the + // chain can build the recovery checkpoint. Restarting only now (after the prune is confirmed) keeps any + // proposer from building on the still-unpruned tip. logger.warn(`Re-enabling checkpoint publishing on node ${proposerOneNodeIndex}`); await nodes[proposerOneNodeIndex].setConfig({ skipPublishingCheckpointsPercent: 0 }); + await test.startSequencers(nodes); + logger.warn('Restarted all sequencers for recovery'); // (6) During slotTwo: the pipelined proposer for slotThree builds and broadcasts → proposed advances again. // The chain must have rewound past slotOne and slotTwo and now build on whatever was diff --git a/yarn-project/end-to-end/src/p2p/p2p_network.ts b/yarn-project/end-to-end/src/p2p/p2p_network.ts index 045ca4529360..fc154d3cb13d 100644 --- a/yarn-project/end-to-end/src/p2p/p2p_network.ts +++ b/yarn-project/end-to-end/src/p2p/p2p_network.ts @@ -49,6 +49,7 @@ import { createValidatorConfig, generatePrivateKeys, } from '../fixtures/setup_p2p_test.js'; +import { getStandardContractGenesisNullifiers } from '../fixtures/standard_contracts_genesis.js'; import { getEndToEndTestTelemetryClient } from '../fixtures/with_telemetry_utils.js'; import type { TestWallet } from '../test-wallet/test_wallet.js'; @@ -415,6 +416,7 @@ export class P2PNetworkTest { undefined, undefined, this.context.genesis!.genesisTimestamp, + await getStandardContractGenesisNullifiers(), ); this.genesis = genesis; diff --git a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts index b2cfe4ac419c..acd4cf12ba1e 100644 --- a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts +++ b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts @@ -76,19 +76,17 @@ export async function deployAndInitializeTokenAndBridgeContracts( client: l1Client, }); - // deploy l2 token - const { contract: token } = await testSpan('deploy:token', () => - TokenContract.deploy(wallet, owner, 'TokenName', 'TokenSymbol', 18).send({ - from: owner, - }), - ); - - // deploy l2 token bridge and attach to the portal - const { contract: bridge } = await testSpan('deploy:bridge', () => - TokenBridgeContract.deploy(wallet, token.address, tokenPortalAddress).send({ - from: owner, - }), - ); + // Deploy the L2 token and its bridge concurrently so they share a slot: the bridge takes the token + // address as a constructor arg, but that address is known deterministically before the token deploy + // mines, and the bridge's constructor only stores it without calling into the token. + const tokenDeploy = TokenContract.deploy(wallet, owner, 'TokenName', 'TokenSymbol', 18, { deployer: owner }); + const tokenAddress = await tokenDeploy.getAddress(); + const [{ contract: token }, { contract: bridge }] = await Promise.all([ + testSpan('deploy:token', () => tokenDeploy.send({ from: owner })), + testSpan('deploy:bridge', () => + TokenBridgeContract.deploy(wallet, tokenAddress, tokenPortalAddress).send({ from: owner }), + ), + ]); if ((await token.methods.get_admin().simulate({ from: owner })).result !== owner.toBigInt()) { throw new Error(`Token admin is not ${owner}`); diff --git a/yarn-project/end-to-end/src/single-node/README.md b/yarn-project/end-to-end/src/single-node/README.md index 0c4eca43beeb..16b4f7b7b212 100644 --- a/yarn-project/end-to-end/src/single-node/README.md +++ b/yarn-project/end-to-end/src/single-node/README.md @@ -19,6 +19,11 @@ All tests use `SingleNodeTestContext` (`single_node_test_context.ts`), which own `MultiNodeTestContext` (in `../multi-node/`) extends this base with the N-validator topology, so the multi-node category inherits the same environment and waiters. +Prefer these named waiters — and the node-level ones in `../fixtures/wait_helpers.ts` — over hand-rolled +`retryUntil` / `.on` / `sleep` polling in test bodies; the full catalog, including the helpers this base +class defines, is listed under [Helper surface](../multi-node/README.md#helper-surface) in the multi-node +README. + ## Setup factories `setup.ts` holds thin factories over `SingleNodeTestContext.setup`, named by the prover mode a test @@ -27,9 +32,10 @@ wants. Tests call the factory rather than the static method directly: - `setupWithProver(opts)` — a single sequencer plus the context's fake in-process prover node. This is the default the proving / partial-proofs / l1-reorgs / recovery / misc suites use. - `setupBlockProducer(opts)` — a single production sequencer with **no prover node**, used by the - block-building / sequencer / sync suites. It raises `aztecProofSubmissionEpochs` to `1024` so unproven - blocks are not pruned without a prover, and points the PXE at `syncChainTip: 'proposed'` so tests can - assert on freshly proposed blocks. Both are overridable via `opts`. + block-building / sequencer / sync suites. It raises `aztecProofSubmissionEpochs` to + `NO_REORG_SUBMISSION_EPOCHS` (1024) so unproven blocks are not pruned without a prover, and points the + PXE at `syncChainTip: 'proposed'` so tests can assert on freshly proposed blocks. Both are overridable + via `opts`. The `prover/` suite (real Barretenberg proofs) builds its environment through `FullProverTest`, which extends `SingleNodeTestContext` directly rather than going through a factory. @@ -47,13 +53,13 @@ top-level `it`; CI splits each `it` into its own job. | Path | Contents | |---|---| | `block-building/` | Block assembly mechanics under the production sequencer with pipelining. `block_building` (multi-tx blocks, double-spend rejection, log ordering, regressions, and L1 reorgs), `debug_trace` (blocks proposed through a Forwarder proxy, including a failing-then-succeeding propose call), `multiple_blobs` (a block whose combined side effects span more than one EIP-4844 blob). | -| `sequencer/` | Sequencer configuration, governance signalling, and publisher management on a single node. `gov_proposal.parallel` (a 16-validator committee proposes blocks while casting governance votes, and votes even when block building is disabled), `escape_hatch_vote_only` (governance signals advance while the escape hatch is closed), `reload_keystore` (the keystore is hot-reloaded to add a validator and pick up new coinbases), `slasher_config` (slasher config updated at runtime via the admin API), `multi_eoa` (publisher rotation when an L1 tx is withheld; exercises multi-EOA publisher failover), `publisher_funding_multi` (PublisherManager auto top-up of publisher EOAs when balances drop below threshold), `sequencer_config` (runtime `maxL2BlockGas`/`manaTarget` reconfiguration via a live Bot). | -| `fees/` | Fee mechanics on a single node. `fee_asset_price_oracle` (on-chain fee-asset price-oracle convergence; starts its own Anvil instance with a MockStateView etched at the StateView address). The remaining files run on the `FeesTest` harness (`fees_test.ts`), which extends `SingleNodeTestContext` with the fee/gas domain setup (FPC funding, fee-juice bridging, banana token): `account_init` (fee payment during account-contract initialization), `failures.parallel` (fees still charged when txs revert in setup/app/teardown), `fee_juice_payments` (direct Fee Juice payment with and without initial funds), `fee_settings` (max-fee-per-gas handling, stale-fee-snapshot race, governance fee-config bump), `gas_estimation.parallel` (gas-estimation accuracy and FPC teardown gas prediction), `private_payments.parallel` (private fee payment via the BananaCoin FPC), `public_payments` (public fee payment via the BananaCoin FPC), `sponsored_payments` (sponsored fee payment via SponsoredFPC). | -| `cross-chain/` | L1↔L2 messaging on a single node, on the `CrossChainMessagingTest` harness (`cross_chain_messaging_test.ts`), which extends `SingleNodeTestContext` and owns a `CrossChainTestHarness` plus the L1 inbox/outbox handles (it auto-proves via an `EpochTestSettler` when no prover node runs). `l1_to_l2.parallel` (L1→L2 message readiness, duplicate messages, and inbox drift after a rollup reorg), `l2_to_l1.parallel` (L2→L1 message inclusion across single/multi-message txs, subtree-root balancing, and a reorg-and-remine case), `token_bridge_private.parallel` (private L1→L2 deposit and L2→L1 withdrawal via the TokenBridge), `token_bridge_public.parallel` (public L1→L2 deposit and L2→L1 withdrawal, including mint-on-behalf), `token_bridge_failure_cases.parallel` (rejected withdrawals without approval and mismatched public/private claim attempts). | +| `sequencer/` | Sequencer configuration, governance signalling, and publisher management on a single node. `gov_proposal.parallel` (a 16-validator committee proposes blocks while casting governance votes, and votes even when block building is disabled), `escape_hatch_vote_only` (governance signals advance while the escape hatch is closed), `reload_keystore` (the keystore is hot-reloaded to add a validator and pick up new coinbases), `multi_eoa` (publisher rotation when an L1 tx is withheld; exercises multi-EOA publisher failover), `publisher_funding_multi` (PublisherManager auto top-up of publisher EOAs when balances drop below threshold), `runtime_config` (the former `slasher_config` and `sequencer_config` admin-API checks on one node: slasher inactivity-config getters, plus runtime `maxL2BlockGas`/`manaTarget` reconfiguration enforced via a live Bot). | +| `fees/` | Fee mechanics on a single node. `fee_asset_price_oracle` (on-chain fee-asset price-oracle convergence; starts its own Anvil instance with a MockStateView etched at the StateView address). The remaining files run on the `FeesTest` harness (`fees_test.ts`), which extends `SingleNodeTestContext` with the fee/gas domain setup (FPC funding, fee-juice bridging, banana token): `account_init` (fee payment during account-contract initialization), `failures` (fees still charged when txs revert in setup/app/teardown), `fee_juice_payments` (direct Fee Juice payment with and without initial funds), `fee_settings` (max-fee-per-gas handling, stale-fee-snapshot race, governance fee-config bump), `gas_estimation.parallel` (gas-estimation accuracy and FPC teardown gas prediction), `private_payments.parallel` (private fee payment via the BananaCoin FPC, plus the single-assertion public-FPC and sponsored-FPC payment checks folded onto the same fixture from the former `public_payments`/`sponsored_payments` files). | +| `cross-chain/` | L1↔L2 messaging on a single node, on the `CrossChainMessagingTest` harness (`cross_chain_messaging_test.ts`), which extends `SingleNodeTestContext` and owns a `CrossChainTestHarness` plus the L1 inbox/outbox handles (it auto-proves via an `EpochTestSettler` when no prover node runs). The shared L1→L2 message helpers live in `message_test_helpers.ts` (`createL1ToL2MessageHelpers`, whose injected `markAsProven` lets each suite plug in its own proving policy). `l1_to_l2` (L1→L2 message readiness and duplicate messages, over private and public scope), `l1_to_l2_inbox_drift` (inbox checkpoint drift after a rollup reorg, over private and public scope), `l2_to_l1` (L2→L1 message inclusion across single/multi-message txs and multi-block checkpoints, subtree-root balancing, and a reorg-and-remine case), `token_bridge` (private and public L1→L2 deposits and L2→L1 withdrawals via the TokenBridge — including mint-on-behalf — plus the withdrawal/claim failure cases, merged from the former three `token_bridge_*` files). | | `bot/` | Transaction bot implementations. `bot` (transfer bot, AMM bot, and cross-chain bot; exercises fee-juice portal deposits, L2→L1 messages, and bot contract reuse). | -| `sync/` | World-state sync stress and reorg-replay harness. `synching` builds fixture block data (env-gated, slow) and replays it for sync benchmarks and prune/reorg scenarios; only the outer `it.each` runs in CI. | -| `proving/` | Epoch and proof lifecycle. `world_state_pruning` (consecutive epochs prove and finalized blocks are purged from world state beyond the checkpoint-history window), `empty_blocks` (a proof is submitted even with no txs), `long_proving_time` (a prover delay spanning multiple epochs), `multi_proof` (multiple prover nodes prove one epoch), `optimistic.parallel` (checkpoint-driven proving across the happy path and several mid-epoch / last-slot / during-proving reorg cases), `proof_fails.parallel` (proof not accepted after epoch end; proving aborts when the next epoch ends), `cross_chain_public_message` (an epoch with a public tx that consumes an L1→L2 message in the block it lands, guarding against a sequencer/prover state-root mismatch), `upload_failed_proof` (a failed proving job's state is uploaded and re-run on a fresh instance). | -| `prover/` | Real-proof exercises on the `FullProverTest` harness (real Barretenberg when `FAKE_PROOFS=0`, fake otherwise). `client` (client-side proof generation and `verifyProof` for private and public transfers, no on-chain submission) and `full` (the end-to-end pipeline: client proves, node builds blocks, prover node generates epoch proofs, L1 verifies them). | +| `sync/` | World-state sync stress and snapshot sync. `synching` builds fixture block data (env-gated, slow) and replays it for sync benchmarks and prune/reorg scenarios (only the outer `it.each` runs in CI); `snapshot_sync` exercises the node snapshot upload/download path, syncing fresh nodes from one or multiple snapshot URLs, including fallback from a corrupted snapshot. | +| `proving/` | Epoch and proof lifecycle. `default_node` (basic proving/node coverage that shares one context-default node across three suites: consecutive epochs prove and finalized blocks are purged from world state beyond the checkpoint-history window, a proof is submitted even with no txs, and the node returns initial genesis-block data — the former `world_state_pruning`, `empty_blocks`, and `node_block_api` files), `long_proving_time` (a prover delay spanning multiple epochs), `multi_proof` (multiple prover nodes prove one epoch), `optimistic.parallel` (checkpoint-driven proving across the happy path and several mid-epoch / last-slot / during-proving reorg cases), `proof_fails.parallel` (proof not accepted after epoch end; proving aborts when the next epoch ends), `cross_chain_public_message` (an epoch with a public tx that consumes an L1→L2 message in the block it lands, guarding against a sequencer/prover state-root mismatch), `upload_failed_proof` (a failed proving job's state is uploaded and re-run on a fresh instance). | +| `prover/` | Real-proof exercises on the `FullProverTest` harness (real Barretenberg when `FAKE_PROOFS=0`, fake otherwise), split by where the proving runs so CI can size their containers independently: `client/client` (client-side proof generation and `verifyProof` for private and public transfers, no on-chain submission) and `server/full` (the end-to-end pipeline: client proves, node builds blocks, prover node generates epoch proofs, L1 verifies them). | | `partial-proofs/` | Manually driven partial-proof submission. `single_root` (the prover node's `startProof` path on a single root) and `multi_root` (three partial-proof roots are staged and messages consume against any covering root, exercising the multi-root Outbox semantics). | | `l1-reorgs/` | Behavior under L1 reorgs, split by what reorgs. `blocks.parallel` (prune L2 blocks when a reorg drops a proof, hold when a replacement proof lands in the window, restore blocks when a proof reappears, prune pending-chain blocks, and see new blocks added by a reorg) and `messages.parallel` (L1→L2 messages updated by a reorg, and a missed message inserted by one). `setup.ts` holds the shared `FAST_REORG_TIMING` profile and delayer wiring. | | `recovery/` | Reorg and pending-chain recovery. `manual_rollback` (the `rollbackTo` admin API rolls back to an unfinalized block), `sync_after_reorg` (a fresh node syncs world state past an unpruned reorg window), `prune_when_cannot_build` (a solo sequencer prunes the pending chain via the fallback path when it cannot propose). | diff --git a/yarn-project/end-to-end/src/single-node/bot/bot.test.ts b/yarn-project/end-to-end/src/single-node/bot/bot.test.ts index 3a73306a5c8a..75a54e5b92f2 100644 --- a/yarn-project/end-to-end/src/single-node/bot/bot.test.ts +++ b/yarn-project/end-to-end/src/single-node/bot/bot.test.ts @@ -22,6 +22,7 @@ import { EmbeddedWallet } from '@aztec/wallets/embedded'; import { jest } from '@jest/globals'; import { PIPELINED_FEE_PADDING, PIPELINING_SETUP_OPTS } from '../../fixtures/fixtures.js'; +import { testSpan } from '../../fixtures/timing.js'; import { getPrivateKeyFromIndex, setup } from '../../fixtures/utils.js'; import { NO_REORG_SUBMISSION_EPOCHS } from '../setup.js'; @@ -54,8 +55,10 @@ describe('single-node/bot/bot', () => { cheatCodes, config: { l1RpcUrls }, } = setupResult); - wallet = await EmbeddedWallet.create(aztecNode, { ephemeral: true }); - await wallet.createSchnorrInitializerlessAccount(botAccount.secret, botAccount.salt, botAccount.signingKey); + wallet = await testSpan('setup:wallet', () => EmbeddedWallet.create(aztecNode, { ephemeral: true })); + await testSpan('wallet:create', () => + wallet.createSchnorrInitializerlessAccount(botAccount.secret, botAccount.salt, botAccount.signingKey), + ); }); afterAll(() => teardown()); @@ -73,7 +76,9 @@ describe('single-node/bot/bot', () => { botMode: 'transfer', minFeePadding: PIPELINED_FEE_PADDING, }; - bot = await Bot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))); + bot = await testSpan('setup:bot', async () => + Bot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))), + ); }); // Runs bot.run() once and asserts recipient private and public balances each increase by 1. @@ -131,7 +136,7 @@ describe('single-node/bot/bot', () => { let store: BotStore; beforeAll(async () => { - store = new BotStore(await openTmpStore('bot')); + store = await testSpan('setup:bot', async () => new BotStore(await openTmpStore('bot'))); }); afterAll(async () => { @@ -238,7 +243,9 @@ describe('single-node/bot/bot', () => { followChain: 'CHECKPOINTED', botMode: 'amm', }; - bot = await AmmBot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))); + bot = await testSpan('setup:bot', async () => + AmmBot.create(config, wallet, aztecNode, undefined, new BotStore(await openTmpStore('bot'))), + ); }); // Runs the AMM bot once and asserts one of the two private token balances decreased and @@ -302,12 +309,8 @@ describe('single-node/bot/bot', () => { flushSetupTransactions: true, l1ToL2SeedCount: 2, }; - bot = await CrossChainBot.create( - config, - wallet, - aztecNode, - aztecNodeAdmin, - new BotStore(await openTmpStore('bot')), + bot = await testSpan('setup:bot', async () => + CrossChainBot.create(config, wallet, aztecNode, aztecNodeAdmin, new BotStore(await openTmpStore('bot'))), ); }, 600_000); diff --git a/yarn-project/end-to-end/src/single-node/fees/account_init.test.ts b/yarn-project/end-to-end/src/single-node/fees/account_init.test.ts index b8aee5c64243..12da781fbdf1 100644 --- a/yarn-project/end-to-end/src/single-node/fees/account_init.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/account_init.test.ts @@ -33,8 +33,9 @@ describe('single-node/fees/account_init', () => { beforeAll(async () => { await t.setup({ ...PIPELINING_SETUP_OPTS }); - await t.applyFundAliceWithBananas(); - await t.applyFPCSetup(); + // Alice's banana mints and the BananaFPC deploy each depend only on the BananaCoin deployed + // during setup, so they run concurrently and share slots. + await Promise.all([t.applyFundAliceWithBananas(), t.applyFPCSetup()]); ({ aliceAddress, wallet, bananaCoin, bananaFPC, logger, aztecNode } = t); }); diff --git a/yarn-project/end-to-end/src/single-node/fees/failures.test.ts b/yarn-project/end-to-end/src/single-node/fees/failures.test.ts index 234b84c54f44..44a5124cb0f5 100644 --- a/yarn-project/end-to-end/src/single-node/fees/failures.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/failures.test.ts @@ -57,7 +57,7 @@ describe('single-node/fees/failures', () => { aztecNode = t.aztecNode; // Prove up until the current state by advancing the epoch and waiting for the prover node. - await t.cheatCodes.rollup.advanceToNextEpoch(); + await t.advanceToNextEpoch(); await t.catchUpProvenChain(); }); @@ -123,7 +123,7 @@ describe('single-node/fees/failures', () => { // @note There is a potential race condition here if other tests send transactions that get into the same // epoch and thereby pays out fees at the same time (when proven). - await t.cheatCodes.rollup.advanceToNextEpoch(); + await t.advanceToNextEpoch(); const provenTimeout = (t.context.config.aztecProofSubmissionEpochs + 1) * t.context.config.aztecEpochDuration * @@ -362,7 +362,7 @@ describe('single-node/fees/failures', () => { ); // Prove the block containing the teardown-reverted tx (revert_code = 2). - await t.cheatCodes.rollup.advanceToNextEpoch(); + await t.advanceToNextEpoch(); const provenTimeout = (t.context.config.aztecProofSubmissionEpochs + 1) * t.context.config.aztecEpochDuration * @@ -389,7 +389,7 @@ describe('single-node/fees/failures', () => { expect(receipt.executionResult).toBe(TxExecutionResult.REVERTED); expect(receipt.transactionFee).toBeGreaterThan(0n); - await t.cheatCodes.rollup.advanceToNextEpoch(); + await t.advanceToNextEpoch(); const provenTimeout = (t.context.config.aztecProofSubmissionEpochs + 1) * t.context.config.aztecEpochDuration * diff --git a/yarn-project/end-to-end/src/single-node/fees/fees_test.ts b/yarn-project/end-to-end/src/single-node/fees/fees_test.ts index 0cc1d3a38a88..f03a17092ef5 100644 --- a/yarn-project/end-to-end/src/single-node/fees/fees_test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/fees_test.ts @@ -1,4 +1,6 @@ import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall } from '@aztec/aztec.js/contracts'; +import { Fr } from '@aztec/aztec.js/fields'; import { createLogger } from '@aztec/aztec.js/log'; import type { AztecNode } from '@aztec/aztec.js/node'; import { CheatCodes, getTokenAllowedSetupFunctions } from '@aztec/aztec/testing'; @@ -17,6 +19,7 @@ import { TokenContract as BananaCoin } from '@aztec/noir-contracts.js/Token'; import { CounterContract } from '@aztec/noir-test-contracts.js/Counter'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; import { getCanonicalFeeJuice } from '@aztec/protocol-contracts/fee-juice'; +import { getContractInstanceFromInstantiationParams } from '@aztec/stdlib/contract'; import { Gas, GasSettings } from '@aztec/stdlib/gas'; import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; @@ -34,13 +37,41 @@ import { import { TestWallet } from '../../test-wallet/test_wallet.js'; import { SingleNodeTestContext, type SingleNodeTestOpts } from '../single_node_test_context.js'; +// Fixed deploy salts so BananaCoin and its BananaFPC land at deterministic addresses given the FPC +// admin. This lets the BananaFPC's fee-juice balance be seeded at genesis (see `FeesTest.setup`) +// instead of bridged from L1 during setup — the address must be known before genesis is computed. +const BANANA_COIN_SALT = new Fr(0xba4a4a); +const BANANA_FPC_SALT = new Fr(0xfacade); + +const BANANA_COIN_CONSTRUCTOR_ARGS = ['BC', 'BC', 18n] as const; + +/** + * Computes the deterministic BananaCoin and BananaFPC instances for the given admin/deployer, matching + * what {@link FeesTest.applyDeployBananaToken} and {@link FeesTest.applyFPCSetup} deploy with the fixed + * salts above. Used both to seed the BananaFPC's fee juice at genesis and to assert the deployed + * addresses match the seeded one. + */ +async function computeBananaContractAddresses(admin: AztecAddress) { + const bananaCoin = await getContractInstanceFromInstantiationParams(BananaCoin.artifact, { + salt: BANANA_COIN_SALT, + constructorArgs: [admin, ...BANANA_COIN_CONSTRUCTOR_ARGS], + deployer: admin, + }); + const bananaFPC = await getContractInstanceFromInstantiationParams(FPCContract.artifact, { + salt: BANANA_FPC_SALT, + constructorArgs: [bananaCoin.address, admin], + deployer: admin, + }); + return { bananaCoinAddress: bananaCoin.address, bananaFPCAddress: bananaFPC.address }; +} + /** * Test fixture for testing fees. Provides the following setup steps: * InitialAccounts: Initializes 3 Schnorr account contracts. * PublicDeployAccounts: Deploys the accounts publicly. * DeployFeeJuice: Deploys the Fee Juice contract. - * FPCSetup: Deploys BananaCoin and FPC contracts, and bridges gas from L1. - * SponsoredFPCSetup: Deploys Sponsored FPC contract, and bridges gas from L1. + * FPCSetup: Deploys BananaCoin and FPC contracts; the FPC's fee juice is seeded at genesis. + * SponsoredFPCSetup: Registers the Sponsored FPC contract, whose fee juice is seeded at genesis. * FundAlice: Mints private and public bananas to Alice. * SetupSubscription: Deploys a counter contract and a subscription contract, and mints Fee Juice to the subscription contract. * @@ -116,6 +147,11 @@ export class FeesTest extends SingleNodeTestContext { ...this.setupOptions, ...opts, fundSponsoredFPC: true, + // Seed the BananaFPC's fee juice at genesis instead of bridging it from L1 in applyFPCSetup. The + // FPC admin is the first account, so its address is deterministic once the accounts are generated. + computeExtraGenesisFundedAddresses: async defaultAccounts => [ + (await computeBananaContractAddresses(defaultAccounts[0].address)).bananaFPCAddress, + ], l1ContractsArgs: { ...this.setupOptions }, txPublicSetupAllowListExtend: [...(this.setupOptions.txPublicSetupAllowListExtend ?? []), ...tokenAllowList], }); @@ -131,15 +167,22 @@ export class FeesTest extends SingleNodeTestContext { } async catchUpProvenChain() { - const bn = await this.aztecNode.getBlockNumber(); - while ((await this.aztecNode.getBlockNumber('proven')) < bn) { - await sleep(1000); - } + await testSpan('wait:proven-checkpoint', async () => { + const bn = await this.aztecNode.getBlockNumber(); + while ((await this.aztecNode.getBlockNumber('proven')) < bn) { + await sleep(1000); + } + }); + } + + /** Warps L1 to the next epoch boundary so the current epoch closes and can be proven. */ + async advanceToNextEpoch() { + await testSpan('warp:proven-checkpoint-epoch', () => this.cheatCodes.rollup.advanceToNextEpoch()); } /** Advances to the next epoch and waits for the proven chain to catch up, so all prior fees are paid out. */ async waitForEpochProven() { - await this.cheatCodes.rollup.advanceToNextEpoch(); + await this.advanceToNextEpoch(); await this.catchUpProvenChain(); } @@ -240,7 +283,10 @@ export class FeesTest extends SingleNodeTestContext { this.logger.info('Applying deploy banana token setup'); const { contract: bananaCoin } = await testSpan('deploy:token', () => - BananaCoin.deploy(this.wallet, this.aliceAddress, 'BC', 'BC', 18n).send({ + BananaCoin.deploy(this.wallet, this.aliceAddress, ...BANANA_COIN_CONSTRUCTOR_ARGS, { + salt: BANANA_COIN_SALT, + deployer: this.aliceAddress, + }).send({ from: this.aliceAddress, }), ); @@ -263,15 +309,26 @@ export class FeesTest extends SingleNodeTestContext { const bananaCoin = this.bananaCoin; const { contract: bananaFPC } = await testSpan('deploy:fpc', () => - FPCContract.deploy(this.wallet, bananaCoin.address, this.fpcAdmin).send({ + FPCContract.deploy(this.wallet, bananaCoin.address, this.fpcAdmin, { + salt: BANANA_FPC_SALT, + deployer: this.aliceAddress, + }).send({ from: this.aliceAddress, }), ); this.logger.info(`BananaPay deployed at ${bananaFPC.address}`); - // bridgeFromL1ToL2 carries its own setup:bridge span. - await this.feeJuiceBridgeTestHarness.bridgeFromL1ToL2(bananaFPC.address, this.aliceAddress); + // The BananaFPC's fee juice is seeded at genesis (see FeesTest.setup) rather than bridged here. + // Assert the deploy landed at the seeded address so a params drift surfaces as a clear error rather + // than a downstream "insufficient fee payer balance". + const { bananaFPCAddress } = await computeBananaContractAddresses(this.aliceAddress); + if (!bananaFPC.address.equals(bananaFPCAddress)) { + throw new Error( + `Deployed BananaFPC address ${bananaFPC.address} does not match the genesis-funded address ` + + `${bananaFPCAddress}; the deterministic deploy params drifted from the genesis funding computation.`, + ); + } this.bananaFPC = bananaFPC; @@ -352,12 +409,24 @@ export class FeesTest extends SingleNodeTestContext { public async applyFundAliceWithBananas() { this.logger.info('Applying fund Alice with bananas setup'); - await this.mintPrivateBananas(this.ALICE_INITIAL_BANANAS, this.aliceAddress); + // Both mints are alice sends on BananaCoin touching disjoint balances, so they ride a single + // BatchCall tx (one proof, one slot) rather than two concurrent txs. The PXE serializes proving, + // so two concurrent sends would still be proven back-to-back and land in consecutive slots. + const { result: privateBalanceBefore } = await this.bananaCoin.methods + .balance_of_private(this.aliceAddress) + .simulate({ from: this.aliceAddress }); + await testSpan('tx:mint', () => - this.bananaCoin.methods.mint_to_public(this.aliceAddress, this.ALICE_INITIAL_BANANAS).send({ - from: this.aliceAddress, - }), + new BatchCall(this.wallet, [ + this.bananaCoin.methods.mint_to_private(this.aliceAddress, this.ALICE_INITIAL_BANANAS), + this.bananaCoin.methods.mint_to_public(this.aliceAddress, this.ALICE_INITIAL_BANANAS), + ]).send({ from: this.aliceAddress }), ); + + const { result: privateBalanceAfter } = await this.bananaCoin.methods + .balance_of_private(this.aliceAddress) + .simulate({ from: this.aliceAddress }); + expect(privateBalanceAfter).toEqual(privateBalanceBefore + this.ALICE_INITIAL_BANANAS); } public async applyFundAliceWithPrivateBananas() { diff --git a/yarn-project/end-to-end/src/single-node/fees/gas_estimation.parallel.test.ts b/yarn-project/end-to-end/src/single-node/fees/gas_estimation.parallel.test.ts index 395bb286069d..c4d3d67f04e8 100644 --- a/yarn-project/end-to-end/src/single-node/fees/gas_estimation.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/gas_estimation.parallel.test.ts @@ -46,8 +46,9 @@ describe('single-node/fees/gas_estimation', () => { beforeAll(async () => { await t.setup({ ...PIPELINING_SETUP_OPTS }); - await t.applyFPCSetup(); - await t.applyFundAliceWithBananas(); + // Alice's banana mints and the BananaFPC deploy each depend only on the BananaCoin deployed + // during setup, so they run concurrently and share slots. + await Promise.all([t.applyFPCSetup(), t.applyFundAliceWithBananas()]); ({ wallet, aliceAddress, bobAddress, bananaCoin, bananaFPC, gasSettings, logger, aztecNode } = t); }); diff --git a/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts b/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts index ad155ec4a467..d0ef1c912059 100644 --- a/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/fees/private_payments.parallel.test.ts @@ -46,11 +46,10 @@ describe('single-node/fees/private_payments', () => { // cycle: the prover-node submits a proof as soon as the epoch is complete, so ~8x shorter // epochs ≈ ~8x faster proof cadence per cycle. Setup itself stays slot-bound. await t.setup({ ...PIPELINING_SETUP_OPTS, aztecProofSubmissionEpochs: 640, aztecEpochDuration: 4 }); - await t.applyFPCSetup(); - // Register the SponsoredFPC (funded at genesis via FeesTest's fundSponsoredFPC) so the folded - // sponsored-payment it can use it; this is a PXE registration, not an L2 tx. - await t.applySponsoredFPCSetup(); - await t.applyFundAliceWithBananas(); + // The BananaFPC deploy, the SponsoredFPC registration (funded at genesis via FeesTest's + // fundSponsoredFPC; a PXE registration, not an L2 tx), and Alice's banana mints each depend only + // on the BananaCoin deployed during setup, so they run concurrently and share slots. + await Promise.all([t.applyFPCSetup(), t.applySponsoredFPCSetup(), t.applyFundAliceWithBananas()]); ({ wallet, aliceAddress, @@ -140,7 +139,7 @@ describe('single-node/fees/private_payments', () => { const provenCheckpointBefore = await t.rollupContract.getProvenCheckpointNumber(); const receipt = await localTx.send({ timeout: 300, interval: 10 }); - await t.cheatCodes.rollup.advanceToNextEpoch(); + await t.advanceToNextEpoch(); await waitForProven(aztecNode, receipt, { provenTimeout: 300 }); diff --git a/yarn-project/end-to-end/src/single-node/l1-reorgs/setup.ts b/yarn-project/end-to-end/src/single-node/l1-reorgs/setup.ts index c8f0737a6a8f..57a9ea36a398 100644 --- a/yarn-project/end-to-end/src/single-node/l1-reorgs/setup.ts +++ b/yarn-project/end-to-end/src/single-node/l1-reorgs/setup.ts @@ -24,7 +24,7 @@ export const TX_COUNT = 8; /** * The single-node + prover-node fixture shared by the L1-reorg suites (`blocks`, `messages`). Stands * up a {@link SingleNodeTestContext} on the {@link FAST_REORG_TIMING} cadence (ethSlot=4s, - * aztecSlot=36s, block=8s, epoch=4, 32 slots/epoch) with L1 speed-ups disabled so prover/sequencer txs + * aztecSlot=24s, block=5s, epoch=4, 32 slots/epoch) with L1 speed-ups disabled so prover/sequencer txs * can be held back and reorged, registers a {@link TestContract}, and exposes the per-test handles plus * a `sendTransactions` helper that pre-proves and fires `count` lightweight txs to drive multi-block * checkpoints. Reorgs themselves are driven by `EthCheatCodes.reorg`/`reorgWithReplacement` at the call @@ -47,14 +47,14 @@ export class L1ReorgsTest { public async setup(): Promise { this.test = await setupWithProver({ - ...FAST_REORG_TIMING, // ethSlot=4s, aztecSlot=36s, block=8s, epoch=4, 32 slots/epoch (mainnet) + ...FAST_REORG_TIMING, // ethSlot=4s, aztecSlot=24s, block=5s, epoch=4, 32 slots/epoch (mainnet) numberOfAccounts: 1, maxSpeedUpAttempts: 0, // Do not speed up l1 txs, we dont want them to land cancelTxOnTimeout: false, minTxsPerBlock: 0, maxTxsPerBlock: 1, aztecProofSubmissionEpochs: 1, - // Pipelining + multi-blocks-per-slot: 8s blocks fit ~4 blocks per 36s slot, and TX_COUNT=8 + // Pipelining + multi-blocks-per-slot: 5s blocks fit ~3 blocks per 24s slot, and TX_COUNT=8 // ensures multiple checkpoints have multiple blocks }); ({ diff --git a/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts b/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts index 8a719cd3556c..a3983288f7c1 100644 --- a/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts +++ b/yarn-project/end-to-end/src/single-node/proving/optimistic.parallel.test.ts @@ -24,9 +24,9 @@ jest.setTimeout(1000 * 60 * 20); * Setup: a single sequencer/validator node from `setupWithProver` plus the context's fake prover-node (no * `mockGossipSubNetwork`, so no gossip bus), making this a `single-node` test on the production `Sequencer`. Each of the * six `describe` blocks builds a fresh context in its own `beforeEach` and tears it down in the shared `afterEach`. The - * happy-path pair uses defaults (`numberOfAccounts: 1`; ethSlot=8s local/12s CI, aztecSlot=16s/24s, epoch=6, - * proofSubEpochs=1); the five reorg describes use a faster cadence (ethSlot=4s, aztecSlot=36s, epoch=4 — or 8 for the - * with-replacement case so the replacement lands in-epoch — proofSubEpochs=NO_REORG_SUBMISSION_EPOCHS, blockDurationMs=8s, minTxsPerBlock=0, + * happy-path pair uses defaults (`numberOfAccounts: 1`; ethSlot=8s, aztecSlot=16s, epoch=6, + * proofSubEpochs=1); the five reorg describes use a faster cadence (ethSlot=4s, aztecSlot=24s, epoch=4 — or 8 for the + * with-replacement case so the replacement lands in-epoch — proofSubEpochs=NO_REORG_SUBMISSION_EPOCHS, blockDurationMs=5s, minTxsPerBlock=0, * anvilSlotsInAnEpoch=32, maxSpeedUpAttempts=0, cancelTxOnTimeout=false). The `prover-node starts mid-epoch` describe * sets `startProverNode: false` and spins up the prover via `test.createProverNode()` partway through the epoch. * diff --git a/yarn-project/end-to-end/src/single-node/single_node_test_context.ts b/yarn-project/end-to-end/src/single-node/single_node_test_context.ts index 8a119b75d856..83360a4f2d61 100644 --- a/yarn-project/end-to-end/src/single-node/single_node_test_context.ts +++ b/yarn-project/end-to-end/src/single-node/single_node_test_context.ts @@ -60,7 +60,14 @@ import type { TestWallet } from '../test-wallet/test_wallet.js'; export const WORLD_STATE_CHECKPOINT_HISTORY = 2; export const WORLD_STATE_BLOCK_CHECK_INTERVAL = 50; export const ARCHIVER_POLL_INTERVAL = 50; -export const DEFAULT_L1_BLOCK_TIME = process.env.CI ? 12 : 8; +/** + * Default L1 (ethereum) slot duration in seconds for single-node e2e tests. Kept at 8s, the fast-profile + * boundary (`FAST_PROFILE_ETHEREUM_SLOT_DURATION`): at 8s the proposer still uses the production operational + * budgets (fast-profile clamping only kicks in strictly below 8s), so the default single-node L2 slot is + * `2 x 8 = 16s`. CI previously ran at 12s (24s L2 slots); unifying it with the local value removes a + * CI-vs-local cadence asymmetry and cuts every default-cadence single-node suite by a third. + */ +export const DEFAULT_L1_BLOCK_TIME = 8; export type SingleNodeTestOpts = Partial & { numberOfAccounts?: number; @@ -87,15 +94,18 @@ export type TrackedSequencerEvent = { export type BlockProposedEvent = { blockNumber: BlockNumber; slot: SlotNumber; buildSlot: SlotNumber }; /** - * The 36s-slot reorg cadence shared by every reorg/prune/HA test, regardless of single-node vs - * multi-validator topology: a 36s L2 slot, 8s blocks, and a 4-slot epoch. The two concrete reorg + * The 24s-slot reorg cadence shared by every reorg/prune/HA test, regardless of single-node vs + * multi-validator topology: a 24s L2 slot, 5s blocks, and a 4-slot epoch. The 5s block duration is chosen + * so the fast-profile budgets both reorg profiles run under (eth < 8s: p2p 0.5s, prepare 0.5s, init 1s) + * still fit ~3 full block sub-slots per checkpoint — `floor((24 - 1 - 5 - 2*0.5 - 0.5) / 5) = 3` — which + * the l1-reorgs suites' `assertMultipleBlocksPerSlot(2)` assertions require. The two concrete reorg * profiles ({@link FAST_REORG_TIMING}, {@link MULTI_VALIDATOR_REORG_TIMING}) extend this with their topology's L1 * slot duration and any extra knobs. Kept timing-only — `maxSpeedUpAttempts`, `cancelTxOnTimeout`, and * `aztecProofSubmissionEpochs` encode per-test scenario intent and stay explicit at the call site. */ export const REORG_TIMING_BASE = { - aztecSlotDuration: 36, - blockDurationMs: 8000, + aztecSlotDuration: 24, + blockDurationMs: 5000, aztecEpochDuration: 4, } as const; @@ -115,7 +125,7 @@ export const FAST_REORG_TIMING = { } as const; /** - * Timing-only profile naming the 36s/6s reorg-and-prune cadence copied verbatim across the + * Timing-only profile naming the 24s/6s reorg-and-prune cadence copied verbatim across the * multi-validator recovery and high-availability tests (`recovery/proposal_failure_recovery`, * `recovery/equivocation_recovery`, `high-availability/ha_sync`, * `high-availability/ha_checkpoint_handoff`). The multi-validator analogue of @@ -130,17 +140,20 @@ export const MULTI_VALIDATOR_REORG_TIMING = { } as const; /** - * Timing-only profile naming the 36s/12s multi-validator block-production cadence copied across - * `block-production/` (`simple`, `high_tps`, `first_slot`, and `proof_boundary`). Uses - * `aztecSlotDurationInL1Slots: 3` rather than an explicit `aztecSlotDuration: 36` so the L2 slot stays - * coupled to `ethereumSlotDuration` if a test overrides eth. Deliberately omits - * `attestationPropagationTime` (per-scenario: default 2, 0.5, or 1) — set it per test. Spread BEFORE - * per-test overrides. + * Timing-only profile naming the 24s/12s multi-validator block-production cadence copied across + * `block-production/` (`simple`, `first_slot`, and `proof_boundary`). Uses `aztecSlotDurationInL1Slots: 2` + * rather than an explicit `aztecSlotDuration: 24` so the L2 slot stays coupled to `ethereumSlotDuration` + * if a test overrides eth. The 4s block duration keeps enough full block sub-slots per checkpoint under + * the production budgets these eth=12 tests run with (init 1s, prepare 1s, min-block 2s, p2p = + * attestationPropagationTime): `floor((24 - 1 - 4 - 2P - 1) / 4)` = 4 blocks at P<=1, 3 blocks at P=2 + * (the default). Deliberately omits `attestationPropagationTime` (per-scenario: default 2, 0.5, or 1) — + * set it per test. `high_tps` pins the old 36s/6s cadence at its own call site because its 2-txs-x-2.5s + * per-block budget does not fit a 4s block. Spread BEFORE per-test overrides. */ export const MULTI_VALIDATOR_BLOCK_PRODUCTION_TIMING = { ethereumSlotDuration: 12, - aztecSlotDurationInL1Slots: 3, - blockDurationMs: 6000, + aztecSlotDurationInL1Slots: 2, + blockDurationMs: 4000, } as const; /** diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 033f5d0d0ad8..1b1e9cc98f7d 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -278,6 +278,7 @@ export type EnvVar = | 'SYNC_SNAPSHOTS_URL' | 'TELEMETRY' | 'TEST_ACCOUNTS' + | 'TEST_PRELOAD_STANDARD_CONTRACTS' | 'SPONSORED_FPC' | 'PREFUND_ADDRESSES' | 'TX_COLLECTION_FAST_NODES_TIMEOUT_BEFORE_REQ_RESP_MS' diff --git a/yarn-project/prover-client/src/proving_broker/broker_prover_facade.ts b/yarn-project/prover-client/src/proving_broker/broker_prover_facade.ts index 8c45fe0f55cd..ebe7815885ba 100644 --- a/yarn-project/prover-client/src/proving_broker/broker_prover_facade.ts +++ b/yarn-project/prover-client/src/proving_broker/broker_prover_facade.ts @@ -288,6 +288,8 @@ export class BrokerCircuitProverFacade implements ServerCircuitProver { } } else if (result.status === 'rejected') { return { success: false, reason: result.reason }; + } else if (result.status === 'aborted') { + return { success: false, reason: 'Aborted' }; } else { throw new Error(`Unexpected proving job status ${result.status}`); } diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts b/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts index 4652413271d4..ca5cb0be1851 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts @@ -201,7 +201,7 @@ describe.each([ await assertJobStatus(id, 'in-queue'); await broker.cancelProvingJob(id); - await assertJobStatus(id, 'rejected'); + await assertJobStatus(id, 'aborted'); }); it('cancels jobs in-progress', async () => { @@ -216,7 +216,135 @@ describe.each([ await broker.getProvingJob(); await assertJobStatus(id, 'in-progress'); await broker.cancelProvingJob(id); - await assertJobStatus(id, 'rejected'); + await assertJobStatus(id, 'aborted'); + }); + + it('revives an aborted job when its producer re-requests it', async () => { + const provingJob: ProvingJob = { + id: makeRandomProvingJobId(), + type: ProvingRequestType.PARITY_BASE, + epochNumber: EpochNumber(1), + inputsUri: makeInputsUri(), + }; + + await broker.enqueueProvingJob(provingJob); + await broker.getProvingJob(); + await assertJobStatus(provingJob.id, 'in-progress'); + + await broker.cancelProvingJob(provingJob.id); + await assertJobStatus(provingJob.id, 'aborted'); + + // Re-requesting the same job (a retry without a restart) revives it rather than returning the + // cached abort. The job stays cached through the revive, so the start-of-call status is + // 'in-queue', and it can then be completed. + await expect(broker.enqueueProvingJob(provingJob)).resolves.toEqual({ status: 'in-queue' }); + await assertJobStatus(provingJob.id, 'in-queue'); + const returnedJob = await broker.getProvingJob({ allowList: [ProvingRequestType.PARITY_BASE] }); + expect(returnedJob?.job).toEqual(provingJob); + + const retryValue = makeOutputsUri(); + await broker.reportProvingJobSuccess(provingJob.id, retryValue); + await assertJobStatus(provingJob.id, 'fulfilled'); + }); + + it('persists the aborted state across a restart and revives on re-request', async () => { + const provingJob: ProvingJob = { + id: makeRandomProvingJobId(), + type: ProvingRequestType.PARITY_BASE, + epochNumber: EpochNumber(1), + inputsUri: makeInputsUri(), + }; + + await broker.enqueueProvingJob(provingJob); + await broker.cancelProvingJob(provingJob.id); + await assertJobStatus(provingJob.id, 'aborted'); + + // A deploy restarts both the prover node and the broker. The aborted state is persisted, so it + // survives the restart rather than being lost or restored as a permanent rejection. + await broker.stop(); + broker = new ProvingBroker(database, { + proverBrokerJobTimeoutMs: jobTimeoutMs, + proverBrokerPollIntervalMs: brokerIntervalMs, + proverBrokerJobMaxRetries: maxRetries, + proverBrokerMaxEpochsToKeepResultsFor: 1, + proverBrokerDebugReplayEnabled: false, + }); + await broker.start(); + await assertJobStatus(provingJob.id, 'aborted'); + + // The prover re-requests the job after the restart, which revives it (returning the cached + // 'in-queue' status), and it can be completed. + await expect(broker.enqueueProvingJob(provingJob)).resolves.toEqual({ status: 'in-queue' }); + await assertJobStatus(provingJob.id, 'in-queue'); + const returnedJob = await broker.getProvingJob({ allowList: [ProvingRequestType.PARITY_BASE] }); + expect(returnedJob?.job).toEqual(provingJob); + + const value = makeOutputsUri(); + await broker.reportProvingJobSuccess(provingJob.id, value); + await assertJobStatus(provingJob.id, 'fulfilled'); + }); + + it('persists the revived (non-aborted) state, so a restart mid-revival stays revived', async () => { + const provingJob: ProvingJob = { + id: makeRandomProvingJobId(), + type: ProvingRequestType.PARITY_BASE, + epochNumber: EpochNumber(1), + inputsUri: makeInputsUri(), + }; + + await broker.enqueueProvingJob(provingJob); + await broker.cancelProvingJob(provingJob.id); + await assertJobStatus(provingJob.id, 'aborted'); + + // Revive the job but do not complete it before the broker restarts. + await broker.enqueueProvingJob(provingJob); + await assertJobStatus(provingJob.id, 'in-queue'); + + await broker.stop(); + broker = new ProvingBroker(database, { + proverBrokerJobTimeoutMs: jobTimeoutMs, + proverBrokerPollIntervalMs: brokerIntervalMs, + proverBrokerJobMaxRetries: maxRetries, + proverBrokerMaxEpochsToKeepResultsFor: 1, + proverBrokerDebugReplayEnabled: false, + }); + await broker.start(); + + // Reviving cleared the persisted aborted state, so the job comes back pending rather than + // aborted and keeps being proven without needing another re-request. + await assertJobStatus(provingJob.id, 'in-queue'); + }); + + it('revives once when the producer re-requests an aborted job concurrently', async () => { + const provingJob: ProvingJob = { + id: makeRandomProvingJobId(), + type: ProvingRequestType.PARITY_BASE, + epochNumber: EpochNumber(1), + inputsUri: makeInputsUri(), + }; + + await broker.enqueueProvingJob(provingJob); + await broker.cancelProvingJob(provingJob.id); + await assertJobStatus(provingJob.id, 'aborted'); + + // Two overlapping re-requests race through the revive. jobsCache stays populated as the enqueue + // lock throughout, so exactly one of them re-enqueues the job and both observe it as in-queue + // rather than the stale abort. The revived job is then delivered once and completes normally. + const [first, second] = await Promise.all([ + broker.enqueueProvingJob(provingJob), + broker.enqueueProvingJob(provingJob), + ]); + expect(first).toEqual({ status: 'in-queue' }); + expect(second).toEqual({ status: 'in-queue' }); + await assertJobStatus(provingJob.id, 'in-queue'); + + const returnedJob = await broker.getProvingJob({ allowList: [ProvingRequestType.PARITY_BASE] }); + expect(returnedJob?.job).toEqual(provingJob); + await assertJobStatus(provingJob.id, 'in-progress'); + await expect(broker.getProvingJob({ allowList: [ProvingRequestType.PARITY_BASE] })).resolves.toBeUndefined(); + + await broker.reportProvingJobSuccess(provingJob.id, makeOutputsUri()); + await assertJobStatus(provingJob.id, 'fulfilled'); }); it('returns job result if successful', async () => { @@ -523,7 +651,7 @@ describe.each([ await broker.getProvingJob(); await assertJobStatus(id, 'in-progress'); await broker.cancelProvingJob(id); - await assertJobStatus(id, 'rejected'); + await assertJobStatus(id, 'aborted'); const id2 = makeRandomProvingJobId(); await broker.enqueueProvingJob({ diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker.ts b/yarn-project/prover-client/src/proving_broker/proving_broker.ts index 27364938d5e1..d1ab4342cf8c 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker.ts @@ -272,15 +272,40 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr async #enqueueProvingJob(job: ProvingJob): Promise { // We return the job status at the start of this call - const jobStatus = this.#getProvingJobStatus(job.id); + let jobStatus = this.#getProvingJobStatus(job.id); if (this.jobsCache.has(job.id)) { const existing = this.jobsCache.get(job.id); assert.deepStrictEqual(job, existing, 'Duplicate proving job ID'); - this.logger.warn(`Cached proving job id=${job.id} epochNumber=${job.epochNumber}. Not enqueuing again`, { - provingJobId: job.id, - }); - this.instrumentation.incCachedJobs(job.type); - return jobStatus; + + if (this.resultsCache.get(job.id)?.status === 'aborted') { + // The producer is re-requesting a job it previously cancelled: revive it rather than + // returning the cached abort, clearing the aborted state in memory and in the database so the + // revival survives a restart. + // + // Concurrency model: `jobsCache` is the enqueue lock. Every path that puts a job on the queue + // populates `jobsCache` *synchronously, before its first await* (see the "New proving job" + // block below), so a second concurrent enqueue of the same id observes the entry at the top + // of this method and takes a cached, no-op branch instead of enqueuing a duplicate. The revive + // must keep holding that lock: we tear down the settled state and re-set `jobsCache` in a + // single synchronous span (no await in between), and only then await the database. Because a + // concurrent re-request can only interleave at that await — by which point `jobsCache` is + // populated again and the aborted result is gone — it falls into the cached branch and no-ops, + // so the job is enqueued exactly once. (`cleanUpProvingJobState` also drops the promise, which + // was resolved with the abort, so `enqueueJobInternal` below mints a fresh one for the retry.) + this.logger.info(`Reviving aborted proving job id=${job.id} epochNumber=${job.epochNumber}`, { + provingJobId: job.id, + }); + this.cleanUpProvingJobState([job.id]); + this.jobsCache.set(job.id, job); + await this.database.deleteProvingJobResult(job.id); + jobStatus = this.#getProvingJobStatus(job.id); + } else { + this.logger.warn(`Cached proving job id=${job.id} epochNumber=${job.epochNumber}. Not enqueuing again`, { + provingJobId: job.id, + }); + this.instrumentation.incCachedJobs(job.type); + return jobStatus; + } } if (this.isJobStale(job)) { @@ -306,15 +331,35 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr } async #cancelProvingJob(id: ProvingJobId): Promise { - if (!this.jobsCache.has(id)) { + const job = this.jobsCache.get(id); + if (!job) { this.logger.warn(`Can't cancel a job that doesn't exist id=${id}`, { provingJobId: id }); return; } - // notify listeners of the cancellation - if (!this.resultsCache.has(id)) { - this.logger.info(`Cancelling job id=${id}`, { provingJobId: id }); - await this.#reportProvingJobError(id, 'Aborted', false, undefined, true); + // Leave jobs that have already settled (completed or failed) alone: those results are terminal. + if (this.resultsCache.has(id)) { + return; + } + + this.logger.info(`Cancelling job id=${id}`, { provingJobId: id }); + this.inProgress.delete(id); + + // Record the cancellation as its own settled state and persist it, so it survives a restart and + // notifies the current waiter. Unlike a completion or failure this is not terminal: re-enqueuing + // the same job id revives it (see #enqueueProvingJob), so the abort never permanently blocks the + // proof. + const result: ProvingJobSettledResult = { status: 'aborted' }; + this.resultsCache.set(id, result); + this.promises.get(id)?.resolve(result); + this.completedJobNotifications.push(id); + this.instrumentation.incAbortedJobs(job.type); + + try { + await this.database.setProvingJobAborted(id); + } catch (saveErr) { + this.logger.error(`Failed to save proving job aborted status id=${id}`, saveErr, { provingJobId: id }); + throw saveErr; } } @@ -401,7 +446,6 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr err: string, retry = false, filter?: ProvingJobFilter, - aborted = false, ): Promise { const info = this.inProgress.get(id); const item = this.jobsCache.get(id); @@ -462,11 +506,7 @@ export class ProvingBroker implements ProvingJobProducer, ProvingJobConsumer, Pr this.promises.get(id)!.resolve(result); this.completedJobNotifications.push(id); - if (aborted) { - this.instrumentation.incAbortedJobs(item.type); - } else { - this.instrumentation.incRejectedJobs(item.type); - } + this.instrumentation.incRejectedJobs(item.type); if (info) { const duration = this.msTimeSource() - info.startedAt; this.instrumentation.recordJobDuration(item.type, duration); diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker_database.ts b/yarn-project/prover-client/src/proving_broker/proving_broker_database.ts index a2e0fa070077..c91112df9eb2 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker_database.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker_database.ts @@ -38,6 +38,22 @@ export interface ProvingBrokerDatabase { */ setProvingJobError(id: ProvingJobId, err: string): Promise; + /** + * Records that a proof request was cancelled. Unlike a result or error this is not terminal: + * re-enqueuing the same job id revives it, so the aborted state can survive a restart without + * permanently blocking the proof. + * @param id - The ID of the cancelled proof request + */ + setProvingJobAborted(id: ProvingJobId): Promise; + + /** + * Clears any stored result for a proof request, returning it to the pending state while keeping + * the job itself. Used when reviving an aborted job so the revival is persisted and a restart + * cannot resurrect the stale aborted state. + * @param id - The ID of the proof request whose result should be cleared + */ + deleteProvingJobResult(id: ProvingJobId): Promise; + /** * Closes the database */ diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts b/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts index 2f921e75090a..3cd951fcc6db 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts @@ -132,6 +132,28 @@ describe('ProvingBrokerPersistedDatabase', () => { } }); + it('keeps a queued result-delete ordered after the writes it follows', async () => { + const id = makeRandomProvingJobId(EpochNumber(42)); + const job: ProvingJob = { + id, + epochNumber: EpochNumber(42), + type: ProvingRequestType.PARITY_BASE, + inputsUri: makeInputsUri(), + }; + await db.addProvingJob(job); + + // Abort the job and then delete its result without awaiting the abort first — mimicking a revive + // that re-requests a job whose aborted write may not have flushed yet. Because the delete rides + // the same write queue, it stays ordered after the abort and the result ends up cleared. If it + // were applied out of order the abort would resurrect on reload. + const abortWrite = db.setProvingJobAborted(id); + const deleteWrite = db.deleteProvingJobResult(id); + await Promise.all([abortWrite, deleteWrite]); + + const allJobs = await toArray(db.allProvingJobs()); + expect(allJobs).toEqual([[job, undefined]]); + }); + it('can add items over multiple epochs', async () => { const numJobs = 5; const startEpoch = 12; diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker_database/memory.ts b/yarn-project/prover-client/src/proving_broker/proving_broker_database/memory.ts index 317798180cac..0456023b16b1 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker_database/memory.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker_database/memory.ts @@ -36,6 +36,16 @@ export class InMemoryBrokerDatabase implements ProvingBrokerDatabase { return Promise.resolve(); } + setProvingJobAborted(id: ProvingJobId): Promise { + this.results.set(id, { status: 'aborted' }); + return Promise.resolve(); + } + + deleteProvingJobResult(id: ProvingJobId): Promise { + this.results.delete(id); + return Promise.resolve(); + } + deleteProvingJobs(ids: ProvingJobId[]): Promise { for (const id of ids) { this.jobs.delete(id); diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts b/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts index 021a1e64c84c..3dd2686583ed 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts @@ -41,7 +41,11 @@ class SingleEpochDatabase { return this.store.estimateSize(); } - async batchWrite(jobs: ProvingJob[], results: Array<[ProvingJobId, ProvingJobSettledResult]>) { + async batchWrite( + jobs: ProvingJob[], + results: Array<[ProvingJobId, ProvingJobSettledResult]>, + deletedResults: ProvingJobId[] = [], + ) { await this.store.transactionAsync(async () => { for (const job of jobs) { await this.jobs.set(job.id, jsonStringify(job)); @@ -49,6 +53,11 @@ class SingleEpochDatabase { for (const [id, result] of results) { await this.jobResults.set(id, jsonStringify(result)); } + // Deletes are applied after sets so that, if a set and a delete for the same id land in one + // batch (e.g. an abort immediately followed by a revive's result-delete), the delete wins. + for (const id of deletedResults) { + await this.jobResults.delete(id); + } }); } @@ -66,6 +75,11 @@ class SingleEpochDatabase { await this.jobResults.set(id, jsonStringify(result)); } + async setProvingJobAborted(id: ProvingJobId): Promise { + const result: ProvingJobSettledResult = { status: 'aborted' }; + await this.jobResults.set(id, jsonStringify(result)); + } + async setProvingJobResult(id: ProvingJobId, value: ProofUri): Promise { const result: ProvingJobSettledResult = { status: 'fulfilled', value }; await this.jobResults.set(id, jsonStringify(result)); @@ -80,10 +94,19 @@ class SingleEpochDatabase { } } +/** + * An item queued for a batched write. A {@link ProvingJob} adds/updates a job; a `[id, result]` + * tuple sets a job's settled result; a `[id, undefined]` tuple deletes a job's result. Deletes ride + * the same queue as writes so they stay FIFO-ordered against them: a revive's result-delete must + * land after the abort that preceded it, otherwise a still-queued abort could flush later and + * resurrect the cancelled state on disk. + */ +type BrokerWrite = ProvingJob | [ProvingJobId, ProvingJobSettledResult | undefined]; + export class KVBrokerDatabase implements ProvingBrokerDatabase { private metrics: LmdbMetrics; - private batchQueue: BatchQueue; + private batchQueue: BatchQueue; public readonly tracer: Tracer; @@ -112,12 +135,16 @@ export class KVBrokerDatabase implements ProvingBrokerDatabase { } // exposed for testing - public async commitWrites(items: Array, epochNumber: number) { + public async commitWrites(items: Array, epochNumber: number) { const jobsToAdd = items.filter((item): item is ProvingJob => 'id' in item); - const resultsToAdd = items.filter((item): item is [ProvingJobId, ProvingJobSettledResult] => Array.isArray(item)); + const resultOps = items.filter((item): item is [ProvingJobId, ProvingJobSettledResult | undefined] => + Array.isArray(item), + ); + const resultsToSet = resultOps.filter((op): op is [ProvingJobId, ProvingJobSettledResult] => op[1] !== undefined); + const resultsToDelete = resultOps.filter(op => op[1] === undefined).map(([id]) => id); const db = await this.getEpochDatabase(EpochNumber(epochNumber)); - await db.batchWrite(jobsToAdd, resultsToAdd); + await db.batchWrite(jobsToAdd, resultsToSet, resultsToDelete); } private async estimateSize() { @@ -207,6 +234,14 @@ export class KVBrokerDatabase implements ProvingBrokerDatabase { return this.batchQueue.put([id, { status: 'rejected', reason }], getEpochFromProvingJobId(id)); } + setProvingJobAborted(id: ProvingJobId): Promise { + return this.batchQueue.put([id, { status: 'aborted' }], getEpochFromProvingJobId(id)); + } + + deleteProvingJobResult(id: ProvingJobId): Promise { + return this.batchQueue.put([id, undefined], getEpochFromProvingJobId(id)); + } + setProvingJobResult(id: ProvingJobId, value: ProofUri): Promise { return this.batchQueue.put([id, { status: 'fulfilled', value }], getEpochFromProvingJobId(id)); } diff --git a/yarn-project/standard-contracts/src/index.ts b/yarn-project/standard-contracts/src/index.ts index 294dc5b249d6..8bb35a0370f7 100644 --- a/yarn-project/standard-contracts/src/index.ts +++ b/yarn-project/standard-contracts/src/index.ts @@ -2,3 +2,4 @@ export * from './auth-registry/index.js'; export * from './handshake-registry/index.js'; export * from './multi-call-entrypoint/index.js'; export * from './public-checks/index.js'; +export * from './publishable_standard_contracts.js'; diff --git a/yarn-project/standard-contracts/src/publishable_standard_contracts.ts b/yarn-project/standard-contracts/src/publishable_standard_contracts.ts new file mode 100644 index 000000000000..e91c30b0a462 --- /dev/null +++ b/yarn-project/standard-contracts/src/publishable_standard_contracts.ts @@ -0,0 +1,22 @@ +import { getStandardAuthRegistry } from './auth-registry/index.js'; +import { getStandardHandshakeRegistry } from './handshake-registry/index.js'; +import { getStandardPublicChecks } from './public-checks/index.js'; +import type { StandardContract } from './standard_contract.js'; + +/** + * Returns the standard contracts that are published on-chain (class registration + instance + * publication) before their public functions can be called: AuthRegistry, PublicChecks, and + * HandshakeRegistry. These are exactly the contracts guarded by the `ensure*Published` e2e setup + * helpers. + * + * MultiCallEntrypoint is deliberately excluded: it is a client-side entrypoint used to encode + * batched private calls and is never published for public execution. + * + * This is the single source of truth for the set of standard contracts that test environments seed + * at genesis (registration/deployment nullifiers) and preload into the archiver contract store, so + * the two stay consistent — preloading a class whose nullifier is not seeded would recreate the + * publish-collision bug that genesis seeding avoids. + */ +export function getPublishableStandardContracts(): Promise { + return Promise.all([getStandardAuthRegistry(), getStandardPublicChecks(), getStandardHandshakeRegistry()]); +} diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index aec489278b8f..362da31e3a88 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -68,6 +68,13 @@ export type ArchiverSpecificConfig = { /** Skip pruning orphan proposed blocks that have no matching proposed checkpoint. */ skipOrphanProposedBlockPruning?: boolean; + + /** + * Preload the standard contracts (AuthRegistry, PublicChecks, HandshakeRegistry) into the contract store at block 0. + * For test environments only: it must only be set when genesis also seeds the matching registration/deployment + * nullifiers, otherwise a later on-chain publish of a preloaded class would collide with the block-0 preload. + */ + testPreloadStandardContracts?: boolean; }; export const ArchiverSpecificConfigSchema = z.object({ @@ -82,6 +89,7 @@ export const ArchiverSpecificConfigSchema = z.object({ skipPromoteProposedCheckpointDuringL1Sync: z.boolean().optional(), orphanPruneNoProposalTolerance: schemas.Integer.optional(), skipOrphanProposedBlockPruning: z.boolean().optional(), + testPreloadStandardContracts: z.boolean().optional(), }); export type ArchiverApi = Omit< diff --git a/yarn-project/stdlib/src/interfaces/proving-job.ts b/yarn-project/stdlib/src/interfaces/proving-job.ts index 514770b3d8da..a51c720d64ec 100644 --- a/yarn-project/stdlib/src/interfaces/proving-job.ts +++ b/yarn-project/stdlib/src/interfaces/proving-job.ts @@ -441,9 +441,20 @@ export const ProvingJobRejectedResult = z.object({ }); export type ProvingJobRejectedResult = z.infer; +/** + * The result of a job that was cancelled by its producer. Unlike a fulfilled or rejected result it + * is not truly terminal: re-enqueuing the same job id revives it, so an abort never permanently + * blocks a proof from being produced. + */ +export const ProvingJobAbortedResult = z.object({ + status: z.literal('aborted'), +}); +export type ProvingJobAbortedResult = z.infer; + export const ProvingJobSettledResult = z.discriminatedUnion('status', [ ProvingJobFulfilledResult, ProvingJobRejectedResult, + ProvingJobAbortedResult, ]); export type ProvingJobSettledResult = z.infer; @@ -453,5 +464,6 @@ export const ProvingJobStatus = z.discriminatedUnion('status', [ z.object({ status: z.literal('not-found') }), ProvingJobFulfilledResult, ProvingJobRejectedResult, + ProvingJobAbortedResult, ]); export type ProvingJobStatus = z.infer; diff --git a/yarn-project/stdlib/src/world-state/genesis_data.ts b/yarn-project/stdlib/src/world-state/genesis_data.ts index 2efc5c900296..fddeddb0d3ab 100644 --- a/yarn-project/stdlib/src/world-state/genesis_data.ts +++ b/yarn-project/stdlib/src/world-state/genesis_data.ts @@ -1,9 +1,18 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; + import type { PublicDataTreeLeaf } from '../trees/index.js'; /** Data used to initialize the genesis block, including prefilled public state and an optional timestamp. */ export type GenesisData = { /** Public data tree leaves to pre-populate in the genesis state (e.g. fee juice balances). */ prefilledPublicData: PublicDataTreeLeaf[]; + /** + * Nullifiers to pre-insert into the genesis nullifier tree. Optional; defaults to an empty list, which leaves the + * nullifier tree at its canonical empty-genesis state so that production genesis roots are unchanged. When non-empty, + * the leaves must be unique and strictly increasing in field value (the native world state enforces this before + * construction). Test networks pass a non-empty list to seed e.g. standard-contract registration nullifiers. + */ + prefilledNullifiers?: Fr[]; /** Timestamp for the genesis block header. Defaults to 0 (canonical empty genesis) in production. */ genesisTimestamp: bigint; }; @@ -11,6 +20,7 @@ export type GenesisData = { /** An empty genesis data with no prefilled state and a zero timestamp. */ export const EMPTY_GENESIS_DATA: GenesisData = { prefilledPublicData: [], + prefilledNullifiers: [], genesisTimestamp: 0n, }; diff --git a/yarn-project/world-state/src/native/native_world_state_instance.ts b/yarn-project/world-state/src/native/native_world_state_instance.ts index 22394ae387ab..e17481bb1e87 100644 --- a/yarn-project/world-state/src/native/native_world_state_instance.ts +++ b/yarn-project/world-state/src/native/native_world_state_instance.ts @@ -71,6 +71,18 @@ export class NativeWorldState implements NativeWorldStateInstance { d.slot.toBuffer(), d.value.toBuffer(), ]); + // Nullifiers to pre-insert into the genesis nullifier tree (empty by default, so production genesis roots are + // unchanged). The native indexed nullifier tree requires its prefilled leaves to be unique and strictly + // increasing, so we enforce that here before handing them over rather than failing deep inside the C++ tree + // construction. + const prefilledNullifiers = genesis.prefilledNullifiers ?? []; + for (let i = 1; i < prefilledNullifiers.length; i++) { + assert( + prefilledNullifiers[i].toBigInt() > prefilledNullifiers[i - 1].toBigInt(), + 'Prefilled genesis nullifiers must be unique and strictly increasing', + ); + } + const prefilledNullifiersBufferArray = prefilledNullifiers.map(n => n.toBuffer()); const ws = new BaseNativeWorldState( dataDir, { @@ -85,6 +97,7 @@ export class NativeWorldState implements NativeWorldStateInstance { [MerkleTreeId.PUBLIC_DATA_TREE]: 2 * MAX_TOTAL_PUBLIC_DATA_UPDATE_REQUESTS_PER_TX, }, prefilledPublicDataBufferArray, + prefilledNullifiersBufferArray, DomainSeparator.BLOCK_HEADER_HASH, Number(genesis.genesisTimestamp), { diff --git a/yarn-project/world-state/src/testing.test.ts b/yarn-project/world-state/src/testing.test.ts index d6d98d463ebc..a602c09e5785 100644 --- a/yarn-project/world-state/src/testing.test.ts +++ b/yarn-project/world-state/src/testing.test.ts @@ -1,13 +1,18 @@ +import { GENESIS_ARCHIVE_ROOT } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import { MerkleTreeId, PublicDataTreeLeaf } from '@aztec/stdlib/trees'; -import type { GenesisData } from '@aztec/stdlib/world-state'; +import { EMPTY_GENESIS_DATA, type GenesisData } from '@aztec/stdlib/world-state'; import { jest } from '@jest/globals'; import { NativeWorldStateService } from './native/index.js'; +import { getGenesisValues } from './testing.js'; jest.setTimeout(60_000); +const archiveRoot = async (ws: NativeWorldStateService) => + new Fr((await ws.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root); + describe('generateGenesisValues world state backend equivalence', () => { // A genesis with both non-empty prefilled public data and a non-zero timestamp, so the // fast-return branch in generateGenesisValues is not taken and the archive root is computed @@ -20,9 +25,6 @@ describe('generateGenesisValues world state backend equivalence', () => { genesisTimestamp: 1234567890n, }; - const archiveRoot = async (ws: NativeWorldStateService) => - new Fr((await ws.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root); - // The consensus-critical guarantee behind computing genesis values on the fsync-off ephemeral // backend instead of tmp: both backends must derive the exact same on-chain genesis archive root. it('ephemeral and tmp produce identical genesis archive roots', async () => { @@ -38,3 +40,96 @@ describe('generateGenesisValues world state backend equivalence', () => { } }); }); + +describe('genesis prefilled nullifiers', () => { + // (a) With no public data, no timestamp and no prefilled nullifiers, generateGenesisValues takes its fast path and + // returns the pinned GENESIS_ARCHIVE_ROOT constant without spinning up a world state. + it('empty genesis returns the canonical GENESIS_ARCHIVE_ROOT via the fast path', async () => { + const { genesisArchiveRoot } = await getGenesisValues([]); + expect(genesisArchiveRoot).toEqual(new Fr(GENESIS_ARCHIVE_ROOT)); + }); + + // (a) The fast-path constant must equal the root actually computed from an empty-nullifier genesis, i.e. the + // GENESIS_ARCHIVE_ROOT constant is correct for the default (empty) prefilled-nullifiers list on this branch. + it('the empty-genesis archive root matches a freshly computed empty world state', async () => { + const ws = await NativeWorldStateService.ephemeral(EMPTY_GENESIS_DATA); + try { + expect(await archiveRoot(ws)).toEqual(new Fr(GENESIS_ARCHIVE_ROOT)); + } finally { + await ws.close(); + } + }); + + // (b) An explicit empty prefilled-nullifiers list must be behaviour-neutral: threading it through the non-fast path + // (public data present) yields exactly the same root as a genesis that omits the field entirely. + it('an explicit empty prefilledNullifiers produces the same root as omitting the field', async () => { + const publicData = [new PublicDataTreeLeaf(new Fr(1000), new Fr(2000))]; + const withoutField: GenesisData = { prefilledPublicData: publicData, genesisTimestamp: 42n }; + const withEmpty: GenesisData = { prefilledPublicData: publicData, genesisTimestamp: 42n, prefilledNullifiers: [] }; + const wsWithout = await NativeWorldStateService.ephemeral(withoutField); + const wsEmpty = await NativeWorldStateService.ephemeral(withEmpty); + try { + expect(await archiveRoot(wsEmpty)).toEqual(await archiveRoot(wsWithout)); + } finally { + await wsWithout.close(); + await wsEmpty.close(); + } + }); + + // (c) A non-empty, sorted, unique nullifier list produces a deterministic root that differs from the empty genesis. + it('a non-empty prefilledNullifiers list yields a deterministic root that differs from the empty genesis', async () => { + // Values must exceed the padding leaves that fill the initial prefill region and be strictly increasing. + const nullifiers = [new Fr(1000n), new Fr(2000n), new Fr(3000n)]; + const genesisWith: GenesisData = { prefilledPublicData: [], genesisTimestamp: 0n, prefilledNullifiers: nullifiers }; + const genesisEmpty: GenesisData = { prefilledPublicData: [], genesisTimestamp: 0n, prefilledNullifiers: [] }; + const wsWith1 = await NativeWorldStateService.ephemeral(genesisWith); + const wsWith2 = await NativeWorldStateService.ephemeral(genesisWith); + const wsEmpty = await NativeWorldStateService.ephemeral(genesisEmpty); + try { + const rootWith1 = await archiveRoot(wsWith1); + const rootWith2 = await archiveRoot(wsWith2); + const rootEmpty = await archiveRoot(wsEmpty); + expect(rootWith1).toEqual(rootWith2); + expect(rootWith1).not.toEqual(rootEmpty); + } finally { + await wsWith1.close(); + await wsWith2.close(); + await wsEmpty.close(); + } + }); + + // (c) getGenesisValues sorts an unsorted list ascending and produces a genesis whose root matches direct construction. + it('getGenesisValues sorts prefilled nullifiers and seeds them into the genesis', async () => { + const unsorted = [new Fr(3000n), new Fr(1000n), new Fr(2000n)]; + const { genesis, genesisArchiveRoot } = await getGenesisValues([], undefined, [], 0n, unsorted); + expect(genesis.prefilledNullifiers).toEqual([new Fr(1000n), new Fr(2000n), new Fr(3000n)]); + const ws = await NativeWorldStateService.ephemeral(genesis); + try { + expect(await archiveRoot(ws)).toEqual(genesisArchiveRoot); + } finally { + await ws.close(); + } + }); + + // (d) The defensive TS-side check rejects prefilled nullifiers that are not unique and strictly increasing before + // handing them to the native tree. + it('rejects prefilled nullifiers that are not strictly increasing', async () => { + const descending: GenesisData = { + prefilledPublicData: [], + genesisTimestamp: 0n, + prefilledNullifiers: [new Fr(3000n), new Fr(1000n)], + }; + await expect(NativeWorldStateService.ephemeral(descending)).rejects.toThrow( + 'Prefilled genesis nullifiers must be unique and strictly increasing', + ); + + const duplicate: GenesisData = { + prefilledPublicData: [], + genesisTimestamp: 0n, + prefilledNullifiers: [new Fr(1000n), new Fr(1000n)], + }; + await expect(NativeWorldStateService.ephemeral(duplicate)).rejects.toThrow( + 'Prefilled genesis nullifiers must be unique and strictly increasing', + ); + }); +}); diff --git a/yarn-project/world-state/src/testing.ts b/yarn-project/world-state/src/testing.ts index 5f30262bae2d..130658644663 100644 --- a/yarn-project/world-state/src/testing.ts +++ b/yarn-project/world-state/src/testing.ts @@ -8,16 +8,18 @@ import type { GenesisData } from '@aztec/stdlib/world-state'; import { NativeWorldStateService } from './native/index.js'; async function generateGenesisValues(genesis: GenesisData) { - if (!genesis.prefilledPublicData.length && genesis.genesisTimestamp === 0n) { + // The GENESIS_ARCHIVE_ROOT constant reflects the canonical empty genesis (no public data, no prefilled nullifiers, + // timestamp 0), so we can only short-circuit when this genesis adds none of those on top. + if (!genesis.prefilledPublicData.length && genesis.genesisTimestamp === 0n && !genesis.prefilledNullifiers?.length) { return { genesisArchiveRoot: new Fr(GENESIS_ARCHIVE_ROOT), }; } - // Compute the genesis values on a throwaway world state. The archive root derives only from the - // prefilled public data and the genesis timestamp, so the fsync-off ephemeral store (no version - // manager, no crash-recoverability) produces an identical root while skipping the fsync overhead - // that `tmp` pays. close() removes the tmpdir. + // Compute the genesis values on a throwaway world state. The archive root derives deterministically from the + // prefilled public data, the prefilled nullifiers, and the genesis timestamp, so the fsync-off ephemeral store (no + // version manager, no crash-recoverability) produces an identical root while skipping the fsync overhead that `tmp` + // pays. close() removes the tmpdir. const ws = await NativeWorldStateService.ephemeral(genesis); const genesisArchiveRoot = new Fr((await ws.getCommitted().getTreeInfo(MerkleTreeId.ARCHIVE)).root); await ws.close(); @@ -34,6 +36,7 @@ export async function getGenesisValues( initialAccountFeeJuice = defaultInitialAccountFeeJuice, genesisPublicData: PublicDataTreeLeaf[] = [], genesisTimestamp: bigint = 0n, + prefilledNullifiers: Fr[] = [], ) { // Top up the accounts with fee juice. let prefilledPublicData = await Promise.all( @@ -47,7 +50,11 @@ export async function getGenesisValues( prefilledPublicData.sort((a, b) => (b.slot.lt(a.slot) ? 1 : -1)); - const genesis: GenesisData = { prefilledPublicData, genesisTimestamp }; + // The indexed nullifier tree requires its prefilled leaves to be unique and strictly increasing, so sort ascending + // here (a copy, to avoid mutating the caller's array) rather than relying on the caller's ordering. + const sortedNullifiers = [...prefilledNullifiers].sort((a, b) => (a.toBigInt() < b.toBigInt() ? -1 : 1)); + + const genesis: GenesisData = { prefilledPublicData, prefilledNullifiers: sortedNullifiers, genesisTimestamp }; const { genesisArchiveRoot } = await generateGenesisValues(genesis); return { diff --git a/yarn-project/yarn.lock b/yarn-project/yarn.lock index e29c09aba20b..ff07b196ee2f 100644 --- a/yarn-project/yarn.lock +++ b/yarn-project/yarn.lock @@ -727,6 +727,7 @@ __metadata: "@aztec/l1-artifacts": "workspace:^" "@aztec/noir-protocol-circuits-types": "workspace:^" "@aztec/protocol-contracts": "workspace:^" + "@aztec/standard-contracts": "workspace:^" "@aztec/stdlib": "workspace:^" "@aztec/telemetry-client": "workspace:^" "@jest/globals": "npm:^30.0.0"