diff --git a/barretenberg/cpp/src/barretenberg/circuit_checker/ultra_circuit_builder_lookup.test.cpp b/barretenberg/cpp/src/barretenberg/circuit_checker/ultra_circuit_builder_lookup.test.cpp index e3c9fbf4d02d..d8ff67432bb5 100644 --- a/barretenberg/cpp/src/barretenberg/circuit_checker/ultra_circuit_builder_lookup.test.cpp +++ b/barretenberg/cpp/src/barretenberg/circuit_checker/ultra_circuit_builder_lookup.test.cpp @@ -4,6 +4,7 @@ #include #include +#include using namespace bb; @@ -121,6 +122,47 @@ TEST_F(UltraCircuitBuilderLookup, DifferentTablesGetUniqueIndices) EXPECT_EQ(builder.get_num_lookup_tables(), 3UL); } +// Every basic table reachable through any MultiTable must receive a unique, positive circuit-local +// table_index. The LogDeriv lookup relation identifies a table solely by table_index (not BasicTableId), +// so a generator that stores anything other than the builder-assigned index silently collapses distinct tables to one +// identity. This sweeps the whole table space so any such generator is caught. +TEST_F(UltraCircuitBuilderLookup, AllMultiTableBasicTablesGetUniquePositiveIndices) +{ + Builder builder; + for (size_t mt = 0; mt < static_cast(plookup::MultiTableId::NUM_MULTI_TABLES); ++mt) { + const auto& multitable = plookup::get_multitable(static_cast(mt)); + for (const auto id : multitable.basic_table_ids) { + builder.get_table(id); + } + } + + std::unordered_set seen; + for (const auto& table : builder.get_lookup_tables()) { + EXPECT_GT(table.table_index, 0UL) << "non-positive index for basic table id " << static_cast(table.id); + EXPECT_TRUE(seen.insert(table.table_index).second) + << "duplicate table_index " << table.table_index << " for basic table id " << static_cast(table.id); + } + EXPECT_NO_THROW(builder.finalize_circuit()); +} + +TEST_F(UltraCircuitBuilderLookup, FinalizationRejectsDuplicateTableIndices) +{ + Builder builder; + builder.get_table(plookup::BasicTableId::UINT_XOR_SLICE_6_ROTATE_0); + builder.get_table(plookup::BasicTableId::UINT_AND_SLICE_6_ROTATE_0); + builder.get_lookup_tables()[1].table_index = builder.get_lookup_tables()[0].table_index; + + EXPECT_THROW_OR_ABORT(builder.finalize_circuit(), "Lookup table indices must be unique within a circuit"); +} + +TEST_F(UltraCircuitBuilderLookup, FinalizationRejectsZeroTableIndex) +{ + Builder builder; + builder.get_table(plookup::BasicTableId::UINT_XOR_SLICE_6_ROTATE_0).table_index = 0; + + EXPECT_THROW_OR_ABORT(builder.finalize_circuit(), "Lookup table indices must be positive"); +} + // Verifies correct behavior when key_b_index is not provided (2-to-1 lookup without second index) TEST_F(UltraCircuitBuilderLookup, NoKeyBIndex) { diff --git a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/plookup_tables.cpp b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/plookup_tables.cpp index e1aa37a4f99d..937972d6263f 100644 --- a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/plookup_tables.cpp +++ b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/plookup_tables.cpp @@ -285,22 +285,22 @@ BasicTable create_basic_table(const BasicTableId id, const size_t index) if (id_var >= static_cast(SECP256R1_FIXED_BASE_XLO_0) && id_var < static_cast(SECP256R1_FIXED_BASE_XHI_0)) { return secp256r1_fixed_base::table::generate_basic_table_runtime( - id, id_var - static_cast(SECP256R1_FIXED_BASE_XLO_0)); + id, id_var - static_cast(SECP256R1_FIXED_BASE_XLO_0), index); } if (id_var >= static_cast(SECP256R1_FIXED_BASE_XHI_0) && id_var < static_cast(SECP256R1_FIXED_BASE_YLO_0)) { return secp256r1_fixed_base::table::generate_basic_table_runtime( - id, id_var - static_cast(SECP256R1_FIXED_BASE_XHI_0)); + id, id_var - static_cast(SECP256R1_FIXED_BASE_XHI_0), index); } if (id_var >= static_cast(SECP256R1_FIXED_BASE_YLO_0) && id_var < static_cast(SECP256R1_FIXED_BASE_YHI_0)) { return secp256r1_fixed_base::table::generate_basic_table_runtime( - id, id_var - static_cast(SECP256R1_FIXED_BASE_YLO_0)); + id, id_var - static_cast(SECP256R1_FIXED_BASE_YLO_0), index); } if (id_var >= static_cast(SECP256R1_FIXED_BASE_YHI_0) && id_var < static_cast(SECP256R1_FIXED_BASE_END)) { return secp256r1_fixed_base::table::generate_basic_table_runtime( - id, id_var - static_cast(SECP256R1_FIXED_BASE_YHI_0)); + id, id_var - static_cast(SECP256R1_FIXED_BASE_YHI_0), index); } switch (id) { case AES_SPARSE_MAP: { diff --git a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.cpp b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.cpp index e75f816a3f23..1819e4eaa941 100644 --- a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.cpp +++ b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.cpp @@ -143,17 +143,18 @@ template generate_fn_ptr generate_fn_for_window(size_t w } } // namespace -template BasicTable table::generate_basic_table_runtime(BasicTableId id, size_t window_idx) +template +BasicTable table::generate_basic_table_runtime(BasicTableId id, size_t window_idx, size_t table_index) { BB_ASSERT_LT(window_idx, NUM_WINDOWS); - return generate_fn_for_window(window_idx)(id, window_idx); + return generate_fn_for_window(window_idx)(id, table_index); } // Explicit instantiations for the four axes. -template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t); -template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t); -template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t); -template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t); +template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t, size_t); +template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t, size_t); +template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t, size_t); +template BasicTable table::generate_basic_table_runtime(BasicTableId, size_t, size_t); namespace { // Returns `&table::get_values` for a runtime (axis, window_idx). The per-axis 32-entry diff --git a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.hpp b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.hpp index 9631739650f9..c1b329bbc4b8 100644 --- a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.hpp +++ b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/plookup_tables/secp256r1_fixed_base.hpp @@ -116,10 +116,11 @@ class table : public Secp256r1FixedBaseParams { static BasicTable generate_basic_table(BasicTableId id, size_t table_index); /** - * @brief Runtime dispatch helper used by plookup_tables.cpp::create_basic_table. Given an axis and a - * runtime window_idx ∈ [0, NUM_WINDOWS), instantiate the corresponding generator. + * @brief Runtime dispatch helper used by plookup_tables.cpp::create_basic_table. Selects table contents using + * window_idx and assigns the independently supplied circuit-local table_index. */ - template static BasicTable generate_basic_table_runtime(BasicTableId id, size_t table_index); + template + static BasicTable generate_basic_table_runtime(BasicTableId id, size_t window_idx, size_t table_index); /** * @brief Construct one of the 10 MultiTables described in the file-header docstring. diff --git a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/ultra_circuit_builder.cpp b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/ultra_circuit_builder.cpp index 07a89eb4e699..5a9ca0c023df 100644 --- a/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/ultra_circuit_builder.cpp +++ b/barretenberg/cpp/src/barretenberg/stdlib_circuit_builders/ultra_circuit_builder.cpp @@ -51,6 +51,13 @@ template void UltraCircuitBuilder_::fi * our circuit is finalized, and we must not to execute these functions again. */ if (!this->circuit_finalized) { + std::unordered_set table_indices; + for (const auto& table : lookup_tables) { + BB_ASSERT_GT(table.table_index, 0U, "Lookup table indices must be positive"); + BB_ASSERT(table_indices.insert(table.table_index).second, + "Lookup table indices must be unique within a circuit"); + } + process_non_native_field_multiplications(); #ifndef ULTRA_FUZZ this->rom_ram_logic.process_ROM_arrays(this); diff --git a/docs/docs-developers/docs/aztec-js/how_to_create_account.md b/docs/docs-developers/docs/aztec-js/how_to_create_account.md index 5f9fcfe0a43e..4b69c16aff01 100644 --- a/docs/docs-developers/docs/aztec-js/how_to_create_account.md +++ b/docs/docs-developers/docs/aztec-js/how_to_create_account.md @@ -30,9 +30,29 @@ The secret is used to derive the account's encryption keys, and the salt ensures Save the `secret` and `salt` values securely. You need both to recover access to your account. If you lose them, you will permanently lose access to the account and any assets it holds. ::: +## Create an initializerless account + +Alternatively, create an [initializerless account](../foundational-topics/accounts/deployment.md), which needs no deployment transaction at all: + +```typescript +const secret = Fr.random(); +const salt = Fr.random(); +const account = await wallet.createSchnorrInitializerlessAccount(secret, salt); +console.log("Account address:", account.address.toString()); +``` + +An initializerless account commits its signing public key into the address itself (through the instance's `immutables_hash`), so there is no onchain state to initialize. Creating the account registers it locally in the PXE, and it is ready to use immediately: skip the deployment section below entirely. Fees are only needed for the account's first real transaction, paid with any of the usual [payment methods](./how_to_pay_fees.md). + +Two things to keep in mind: + +- The signing key cannot be changed later. A new key means a new address. +- Calling `getDeployMethod()` on an initializerless account throws, since there is nothing to deploy. + +See [account deployment](../foundational-topics/accounts/deployment.md) for how this works and how to choose between the two account types. + ## Deploy the account -New accounts must be deployed before they can send transactions. Deployment requires paying fees. +Accounts created with `createSchnorrAccount` must be deployed before they can send transactions (initializerless accounts skip this step). Deployment requires paying fees. ### Using the Sponsored FPC @@ -70,5 +90,5 @@ Confirm the account was deployed successfully. Substitute the account variable f - [Deploy contracts](./how_to_deploy_contract.md) with your new account - [Send transactions](./how_to_send_transaction.md) from an account -- Learn about [account abstraction](../foundational-topics/accounts/index.md) +- Learn about [account abstraction](../foundational-topics/accounts/index.md) and [account deployment](../foundational-topics/accounts/deployment.md) - Implement [authentication witnesses](./how_to_use_authwit.md) diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/immutables.md b/docs/docs-developers/docs/aztec-nr/framework-description/immutables.md index 0e93cfda9fbe..629d66a9c3c0 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/immutables.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/immutables.md @@ -31,3 +31,5 @@ Initialization cost is completely eliminated (no constructor transaction). The p ## Getting started For installation instructions, usage examples, and a reference implementation of an initializerless Schnorr account contract, see the [aztec-immutables-macro README](https://github.com/defi-wonderland/aztec-immutables-macro/tree/dev). + +The protocol ships a built-in account contract using the same pattern: see [account deployment](../../foundational-topics/accounts/deployment.md) for how initializerless accounts work and [creating accounts](../../aztec-js/how_to_create_account.md#create-an-initializerless-account) for using them from Aztec.js. diff --git a/docs/docs-developers/docs/foundational-topics/accounts/deployment.md b/docs/docs-developers/docs/foundational-topics/accounts/deployment.md new file mode 100644 index 000000000000..d189520dfa81 --- /dev/null +++ b/docs/docs-developers/docs/foundational-topics/accounts/deployment.md @@ -0,0 +1,61 @@ +--- +title: Account Deployment +tags: [accounts] +description: How Aztec accounts come into existence, from the standard initializer plus deployment flow to initializerless accounts that need no onchain transaction at all. +references: + [ + "noir-projects/noir-contracts/contracts/account/schnorr_initializerless_account_contract/src/main.nr", + "noir-projects/noir-contracts/contracts/account/schnorr_account_contract/src/main.nr", + ] +--- + +## How accounts come into existence + +Every account in Aztec is a [contract instance](../contract_creation.md), and its address is computed deterministically from its instantiation parameters. There are two ways to make an account usable: + +1. **The standard flow**: create the account locally, then send a deployment transaction that runs its initializer. +2. **The initializerless flow**: create the account locally, and that is it. No deployment transaction is ever sent. + +This page explains both flows and when to choose each. + +## The standard flow: initialize and deploy + +A regular account contract (for example the default Schnorr account) stores its signing public key in private state. Writing that state requires running the contract's constructor, a [private initializer function](../../aztec-nr/framework-description/functions/attributes.md#initializer-functions-initializer), which in turn requires sending a transaction to the network and paying [fees](../fees.md) for it. + +The address commits to that pending initialization: the constructor call's selector and arguments are hashed into the instance's `initialization_hash`, which is folded into the address derivation. This is why the address can be computed, shared, and even receive funds before the deployment transaction is sent, and why the account only becomes able to send transactions after it. + +## Initializerless accounts + +An initializerless account removes the deployment transaction entirely. Instead of storing the signing key in private state through an initializer, the key is committed directly into the address: the instance's `immutables_hash` field is set to the hash of the signing public key, and `immutables_hash` participates in address derivation just like `initialization_hash` does. + +Since the address itself is the commitment to the signing key, there is no onchain state to create: + +- The account contract has no initializer. Its `constructor` is an unconstrained utility function that runs only in the PXE: it checks that the provided public key hashes to the instance's `immutables_hash` and stores the key in the PXE's local store. +- When the account later authorizes a transaction, its entrypoint loads the key from the local store and proves in the private kernel that the key's hash matches the `immutables_hash` committed in the address. Tampering with the local key is not possible without changing the address. + +Creating an initializerless account is therefore a purely local operation: the wallet computes the address, registers the contract instance in the PXE, and runs the utility constructor through a simulation. No transaction is sent, no fee is paid, and the account can start transacting as soon as it has a way to pay for its first real transaction (for example a [Sponsored FPC](../fees.md#payment-methods) or a Fee Juice balance at its address). + +The genesis-funded test accounts in the local network are initializerless Schnorr accounts: their addresses receive Fee Juice at genesis, and wallets materialize them locally without any deployment. + +## Trade-offs + +Initializerless accounts are a good default when the account's authorization logic only depends on parameters that never change: + +- **No deployment cost or delay.** The account is usable the moment it is created, which makes onboarding flows and receive-only addresses cheap. +- **The signing key is fixed forever.** The key is baked into the address, so there is no way to rotate it. A new key means a new address. The default Schnorr account offers no key rotation either, but account contracts that keep keys in state can be designed to support it. +- **Local setup is still required per PXE.** A fresh PXE does not know about the account until the wallet registers the instance and runs the utility constructor again. This is a purely offline step with no fee, but it must happen in every new environment before the account can be used. +- **There is no deployment method.** Calling `getDeployMethod()` on an initializerless account throws, since there is nothing to deploy. + +Use the standard flow when the account contract needs an initializer: for example when it takes arbitrary constructor arguments or stores its authorization material in private notes. + +## Using initializerless accounts + +- In Aztec.js, call `createSchnorrInitializerlessAccount(secret, salt)` on your wallet. See [creating accounts](../../aztec-js/how_to_create_account.md#create-an-initializerless-account). +- In the CLI, pass the account type: `aztec-wallet create-account -t schnorr_initializerless`. The command registers the account locally and returns immediately, with no deployment transaction. +- In Aztec.nr, the same address-immutables pattern can be applied to your own contracts. See [immutables](../../aztec-nr/framework-description/immutables.md). + +## Next steps + +- [Create an account in Aztec.js](../../aztec-js/how_to_create_account.md) +- [Understand fees and payment methods](../fees.md) +- [Contract creation and address derivation](../contract_creation.md) diff --git a/docs/docs-developers/docs/foundational-topics/contract_creation.md b/docs/docs-developers/docs/foundational-topics/contract_creation.md index 899ece79d9cd..a8bfa6801bdf 100644 --- a/docs/docs-developers/docs/foundational-topics/contract_creation.md +++ b/docs/docs-developers/docs/foundational-topics/contract_creation.md @@ -55,7 +55,7 @@ A contract instance includes: - `deployer`: Optional address of the contract deployer. Zero for universal deployment - `original_contract_class_id`: Identifier of the contract class the instance was deployed with. Updating the instance to a new class via the ContractInstanceRegistry does not change this value, since it is part of the address preimage - `initialization_hash`: Hash of the selector and arguments to the constructor -- `immutables_hash`: Hash of the contract's compile-time immutable state +- `immutables_hash`: Hash of the contract's compile-time immutable state. [Initializerless accounts](./accounts/deployment.md) use it to commit the signing key into the address - `public_keys`: Public keys participating in address derivation (nullifier, incoming viewing, outgoing viewing, tagging, message-signing, and fallback keys). Only the incoming viewing key is held as an elliptic curve point; the other five are held as their `hash_public_key` digests. ### Instance Address diff --git a/docs/docs-developers/docs/foundational-topics/fees.md b/docs/docs-developers/docs/foundational-topics/fees.md index 8ff84586cdbc..adfbcaf88c81 100644 --- a/docs/docs-developers/docs/foundational-topics/fees.md +++ b/docs/docs-developers/docs/foundational-topics/fees.md @@ -103,7 +103,7 @@ In `aztec.js`, the `L1FeeJuicePortalManager` class handles the L1 side (token ap ### Payment methods -An account with Fee Juice can pay for its transactions directly. A new account can even pay for its own deployment transaction, provided Fee Juice was bridged to its address before deployment. +An account with Fee Juice can pay for its transactions directly. A new account can even pay for its own deployment transaction, provided Fee Juice was bridged to its address before deployment. [Initializerless accounts](./accounts/deployment.md) skip the deployment transaction entirely, so they only need fees once they send their first real transaction. Alternatively, accounts can use [fee-paying contracts (FPCs)](../aztec-js/how_to_pay_fees.md#use-fee-payment-contracts) to pay for transactions. An FPC holds its own Fee Juice balance to pay the protocol, and can accept other tokens from users in exchange. diff --git a/docs/docs-developers/docs/foundational-topics/wallets.md b/docs/docs-developers/docs/foundational-topics/wallets.md index 895570670b5c..c299f7405c2b 100644 --- a/docs/docs-developers/docs/foundational-topics/wallets.md +++ b/docs/docs-developers/docs/foundational-topics/wallets.md @@ -21,6 +21,8 @@ A wallet must support at least one specific account contract implementation, whi Note that users must be able to receive funds in Aztec before deploying their account. A wallet should let a user generate a [deterministic complete address](./accounts/keys.md#address-derivation) without having to interact with the network, so they can share it with others to receive funds. This requires that the wallet pins a specific contract implementation, its initialization arguments, a deployment salt, and the user's keys. These values yield a deterministic address, so when the account contract is actually deployed, it is available at the precalculated address. Once the account contract is deployed, the user can start sending transactions using it as the transaction origin. +Some account contracts avoid deployment altogether: [initializerless accounts](./accounts/deployment.md) commit their signing key into the address itself, so the wallet only needs to register them locally before they can start transacting. + ## Transaction lifecycle Every transaction in Aztec is broadcast to the network as a zero-knowledge proof of correct execution, in order to preserve privacy. This means that transaction proofs are generated on the wallet and not on a remote node. This is one of the biggest differences with regard to EVM chain wallets. diff --git a/l1-contracts/scripts/forge_broadcast.js b/l1-contracts/scripts/forge_broadcast.js index fb7b809e4a5a..1d61effab83e 100755 --- a/l1-contracts/scripts/forge_broadcast.js +++ b/l1-contracts/scripts/forge_broadcast.js @@ -59,11 +59,11 @@ const timeoutMs = (isAnvil ? 120_000 : 1_200_000); const proc = spawn( - "forge", + process.env.FORGE_BIN || "forge", ["script", ...args, "--broadcast", "--batch-size", batchSize], { stdio: ["ignore", "pipe", "inherit"], - }, + } ); const stdout = []; @@ -85,14 +85,14 @@ const exitCode = await new Promise((resolve) => { }); proc.on("close", (code) => { clearTimeout(timeout); - resolve(timedOut ? 1 : (code ?? 1)); + resolve(timedOut ? 1 : code ?? 1); }); }); log( exitCode === 0 ? "Broadcast succeeded." - : `Broadcast failed (exit ${exitCode}).`, + : `Broadcast failed (exit ${exitCode}).` ); const data = Buffer.concat(stdout); if (data.length > 0) writeSync(1, data); diff --git a/noir-projects/aztec-nr/CLAUDE.md b/noir-projects/aztec-nr/CLAUDE.md index a16864f2677e..975f709899d1 100644 --- a/noir-projects/aztec-nr/CLAUDE.md +++ b/noir-projects/aztec-nr/CLAUDE.md @@ -34,6 +34,36 @@ Follow the Rust stdlib style roughly. See `PublicImmutable` in `state_vars/publi - **Show practical patterns in examples.** Don't just show the API call — show it in context (e.g. inside a `#[external("public")]` function with realistic variable names). - **Document cost for methods.** When relevant, note which AVM opcodes are invoked and how many times (e.g. "`SLOAD` is invoked a number of times equal to `T`'s packed length"). +### Brevity and attitude + +Write for a caller using the API, not for an implementor or an auditor. State what the item does and any restriction a caller must respect, plus the alternative to reach for — then stop. Leave out the internal mechanism and the security rationale behind a restriction: those belong in code comments messages or design docs, not the rustdoc. A reader who hits a restriction needs to know it exists and where to go next, not the argument for why it holds. + +- **State the restriction, not the reason it's sound.** Say "this can only be used for X", not the multi-sentence explanation of what goes wrong otherwise. +- **Don't narrate the mechanism.** Key siloing, tree layouts, hash preimages, and similar internals are implementation detail; mention them only when a caller cannot use the API correctly without knowing (and then put them under `## Implementation Details`). +- **Point to the alternative instead of enumerating trade-offs.** A single "use `[other_fn]` for the other case" beats a paragraph weighing options. + +Example — documenting a helper that only works on the executing contract's own notes: + +```rust +// Too much: explains the key-siloing mechanism and the failure the guard prevents. +/// The note's nullifier is recomputed using the executing contract's app-siloed nullifier key +/// and then siloed with `confirmed_note.contract_address`. These only agree for the executing +/// contract's own notes; otherwise the non-inclusion proof passes unconditionally and wrongly +/// reports a nullified note as not nullified, so this asserts that +/// `confirmed_note.contract_address == context.this_address()`. + +// Right: states the restriction and the alternative, nothing more. +/// ## Local notes only +/// +/// This can only be used for notes of the executing contract. Use [`assert_note_existed_by`] to +/// prove existence of another contract's notes. +``` + +## Testing + +- **No messages on assertions inside tests.** Write `assert_eq(actual, expected)` and `assert(cond)`, not `assert_eq(actual, expected, "values should match")`. The test's name and body already make clear what is being checked, so an assertion message is redundant noise. (Library code is the opposite: an `assert`/`panic` there must carry a message, since it explains a runtime failure to a contract developer.) +- `#[test(should_fail_with = "...")]` is not an assertion message and is encouraged. The string matches a substring of the expected failure, pinning down *which* error the failing case must produce so the test can't pass for the wrong reason. + ## Logging - **Always use the prefixed logging functions** from `crate::logging` (e.g. `logging::aztecnr_debug_log!`, `logging::aztecnr_debug_log_format!`). These automatically prepend `[aztec-nr] ` to all messages at compile time. diff --git a/noir-projects/aztec-nr/aztec/src/keys/ephemeral.nr b/noir-projects/aztec-nr/aztec/src/keys/ephemeral.nr index d0947c61d281..3afaced71e51 100644 --- a/noir-projects/aztec-nr/aztec/src/keys/ephemeral.nr +++ b/noir-projects/aztec-nr/aztec/src/keys/ephemeral.nr @@ -37,6 +37,9 @@ pub fn generate_positive_ephemeral_key_pair() -> (Scalar, EmbeddedCurvePoint) { let eph_sk = unsafe { generate_secret_key_for_positive_public_key() }; let eph_pk = fixed_base_scalar_mul(eph_sk); + // The point at infinity has x = 0, which is not a valid x-coordinate on the curve, so the recipient could + // never reconstruct the key from it and the message would be undecryptable. + assert(!eph_pk.is_infinite(), "Ephemeral public key is the point at infinity"); assert(get_sign_of_point(eph_pk), "Got an ephemeral public key with a negative y coordinate"); (eph_sk, eph_pk) @@ -63,6 +66,7 @@ unconstrained fn generate_secret_key_for_positive_public_key() -> EmbeddedCurveS mod test { use crate::utils::point::get_sign_of_point; use super::generate_positive_ephemeral_key_pair; + use std::test::OracleMock; #[test] fn generate_positive_ephemeral_key_pair_produces_positive_keys() { @@ -73,4 +77,11 @@ mod test { assert(get_sign_of_point(pk)); } } + + #[test(should_fail_with = "point at infinity")] + unconstrained fn generate_positive_ephemeral_key_pair_rejects_zero_randomness() { + // Making the randomness oracle return 0 emulates a malicious sender substituting eph_sk = 0. + let _ = OracleMock::mock("aztec_misc_getRandomField").returns(0); + let _ = generate_positive_ephemeral_key_pair(); + } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_response.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_response.nr index fda2f8f99684..1ecf16379afa 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_response.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/log_retrieval_response.nr @@ -1,5 +1,8 @@ use crate::messages::logs::note::MAX_NOTE_PACKED_LEN; -use crate::protocol::{constants::{MAX_NOTE_HASHES_PER_TX, PRIVATE_LOG_CIPHERTEXT_LEN}, traits::Deserialize}; +use crate::protocol::{ + constants::{MAX_NOTE_HASHES_PER_TX, PRIVATE_LOG_CIPHERTEXT_LEN}, + traits::{Deserialize, Serialize}, +}; pub global MAX_PUBLIC_LOG_LEN_FOR_NOTE_COMPLETION: u32 = MAX_NOTE_PACKED_LEN; @@ -11,7 +14,7 @@ pub(crate) global MAX_LOG_CONTENT_LEN: u32 = std::cmp::max( /// A response to a `LogRetrievalRequest`, containing the payload of a log (i.e. the content minus the tag, called /// plaintext for public logs and ciphertext for private), plus contextual information about the transaction in which /// the log was emitted. This is the data required in order to discover notes that are being delivered in a log. -#[derive(Deserialize, Eq)] +#[derive(Deserialize, Eq, Serialize)] pub struct LogRetrievalResponse { pub log_payload: BoundedVec, pub tx_hash: Field, diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index 4b4b71564f4e..fb2823eb600d 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -95,6 +95,13 @@ pub unconstrained fn enqueue_note_for_validation( )) } +/// The note validation requests enqueued so far in the current context via [`enqueue_note_for_validation`], before +/// [`validate_and_store_enqueued_notes_and_events`] drains them. Exists so tests can inspect the pending queue; +/// production code should not read requests back once enqueued. +pub(crate) unconstrained fn get_enqueued_note_validation_requests() -> EphemeralArray { + EphemeralArray::at(NOTE_VALIDATION_REQUESTS_ARRAY_BASE_SLOT) +} + /// Enqueues an event for validation and storage by PXE. /// /// This is the primary way for custom message handlers (registered via diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/mod.nr index 5bd712009923..5308ac9a830e 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/mod.nr @@ -7,7 +7,7 @@ use crate::{ }; mod reception; -use reception::OffchainReception; +use reception::{MAX_ANCHOR_FUTURE_SKEW, OffchainReception}; /// Maximum number of offchain messages accepted by `offchain_receive` in a single call. pub global MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL: u32 = 16; @@ -50,6 +50,19 @@ pub unconstrained fn receive( // Offchain reception facts are scoped to the recipient. Note that because offchain messages have no integrity or // authenticity checks it is not possible to verify that this is indeed the intended recipient. In this case we're // assuming a cooperative environment, which is on par with other offchain delivery expectations. + // + // The sender-supplied anchor timestamp is the one field we bound: a message claiming to originate implausibly far + // in our future (see MAX_ANCHOR_FUTURE_SKEW) cannot be genuine, and accepting it would let a malicious anchor evade + // the TTL-based inbox eviction. We reject such a batch by panicking instead of silently dropping the message, so + // the calling wallet can react to it (e.g. surface the error or distrust the sender). + let now = UtilityContext::new().timestamp(); + messages.for_each(|msg| { + assert( + msg.anchor_block_timestamp <= now + MAX_ANCHOR_FUTURE_SKEW, + "offchain message anchor timestamp is implausibly far in the future", + ); + }); + messages.for_each(|msg| OffchainReception::init(contract_address, msg)); // Clear cache for message recipients so the next sync runs. @@ -97,10 +110,12 @@ pub struct OffchainMessage { mod test { use crate::{ - oracle::random::random, protocol::address::AztecAddress, test::helpers::test_environment::TestEnvironment, + oracle::random::random, + protocol::{address::AztecAddress, constants::MAX_TX_LIFETIME}, + test::helpers::test_environment::TestEnvironment, }; use super::{MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL, OffchainMessage, receive, sync_inbox}; - use super::reception::{MAX_MSG_TTL, OffchainReception}; + use super::reception::{MAX_ANCHOR_FUTURE_SKEW, MAX_MSG_TTL, OffchainReception}; unconstrained fn setup() -> (TestEnvironment, AztecAddress) { let mut env = TestEnvironment::new(); @@ -177,6 +192,42 @@ mod test { }); } + #[test] + unconstrained fn accepts_messages_within_the_tolerated_future_skew() { + let (env, scope) = setup(); + let now = advance_by(env, 10); + + // A present-dated anchor and a 24h-future one both fall within the tolerated skew. The latter models a genuine + // message whose sender anchored ahead of our lagging PXE, and must still be accepted. + let present = make_msg(scope, Option::some(random()), now); + let lagged = make_msg(scope, Option::some(random()), now + MAX_TX_LIFETIME); + + env.utility_context(|context| { + let address = context.this_address(); + receive(address, BoundedVec::from_array([present, lagged])); + + assert(OffchainReception::is_active(address, scope, present)); + assert(OffchainReception::is_active(address, scope, lagged)); + assert_eq(OffchainReception::load_all(address, scope).len(), 2); + }); + } + + #[test(should_fail_with = "offchain message anchor timestamp is implausibly far in the future")] + unconstrained fn rejects_batch_with_implausible_future_anchor() { + let (env, scope) = setup(); + let now = advance_by(env, 10); + + // Beyond `now + MAX_ANCHOR_FUTURE_SKEW`: our view would trail the tip by more than a tx lifetime, so this + // cannot be genuine. Reception panics so the wallet can react rather than accept it. + let too_future = make_msg( + scope, + Option::some(random()), + now + MAX_ANCHOR_FUTURE_SKEW + 7200, + ); + + env.utility_context(|context| { receive(context.this_address(), BoundedVec::from_array([too_future])); }); + } + // -- Idempotent re-delivery (first-write-wins fact recording) --------- #[test] diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/reception.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/reception.nr index 36bafe540a1e..99c0e15a9ba3 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/reception.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain/reception.nr @@ -117,6 +117,18 @@ use super::OffchainMessage; /// (7200 == 2 hours) pub(crate) global MAX_MSG_TTL: u64 = MAX_TX_LIFETIME + 7200; +/// Maximum amount by which a received message's `anchor_block_timestamp` may exceed our current timestamp. +/// +/// A message's anchor is the timestamp of the block the sender anchored its originating tx to, so for a genuine +/// message it is at or before our own anchor timestamp. Our PXE may lag the chain tip though, which can make a +/// legitimate anchor appear to be in our future, so we tolerate a forward skew of `MAX_TX_LIFETIME` (the longest a tx +/// can wait to be mined after its anchor) plus a 2h margin. A larger skew would mean our view trails the tip by more +/// than a tx lifetime -- so far behind that we could not even build a tx that wouldn't be immediately expired -- so +/// such a message cannot be genuine and is rejected on reception. Bounding the anchor this way also keeps accepted +/// values near the present, which prevents the `anchor_block_timestamp + MAX_MSG_TTL` eviction check from overflowing. +/// (7200 == 2 hours) +pub(crate) global MAX_ANCHOR_FUTURE_SKEW: u64 = MAX_TX_LIFETIME + 7200; + /// Fact type id of the fact that stores the offchain message body inside a reception collection. global OFFCHAIN_MESSAGE_RECEIVED: Field = sha256_to_field("AZTEC_NR::OFFCHAIN_MESSAGE_RECEIVED".as_bytes()); diff --git a/noir-projects/aztec-nr/aztec/src/partial_notes/fsm.nr b/noir-projects/aztec-nr/aztec/src/partial_notes/fsm.nr index f926b1b25fcc..fdb734c3a5cb 100644 --- a/noir-projects/aztec-nr/aztec/src/partial_notes/fsm.nr +++ b/noir-projects/aztec-nr/aztec/src/partial_notes/fsm.nr @@ -66,9 +66,10 @@ use crate::facts::{ delete_fact_collection, Fact, FactCollection, get_fact_collections_by_type, OriginBlock, record_retractable_fact, RetractableFactOrigin, }; -use crate::logging::aztecnr_debug_log_format; +use crate::logging::{aztecnr_debug_log_format, aztecnr_warn_log_format}; use crate::messages::{ discovery::{ComputeNoteHash, ComputeNoteNullifier, nonce_discovery::attempt_note_nonce_discovery}, + logs::note::MAX_NOTE_PACKED_LEN, processing::{enqueue_note_for_validation, log_retrieval_response::{LogRetrievalResponse, MAX_LOG_CONTENT_LEN}}, }; use crate::protocol::{address::AztecAddress, hash::{poseidon2_hash, sha256_to_field}, traits::{Deserialize, Serialize}}; @@ -213,7 +214,17 @@ impl PartialNoteFsm { let completion_logs = maybe_completion_logs.unwrap(); let num_logs = completion_logs.len(); if num_logs != 0 { - assert(num_logs == 1, f"Expected at most 1 completion log per partial note, got {num_logs}"); + // The tag is not unique, so a pending partial note can resolve to more than one completion log (e.g. + // the same partial note completed twice). We complete with the first and ignore the rest rather than + // fail. + if num_logs > 1 { + let tag = self.read_pending_partial_note().note_completion_log_tag; + aztecnr_warn_log_format!( + "Found {0} completion logs for partial note with tag {1}, completing with the first", + )( + [num_logs as Field, tag], + ); + } self.complete_with_log(completion_logs.get(0), compute_note_hash, compute_note_nullifier); } } @@ -232,6 +243,10 @@ impl PartialNoteFsm { /// Combines the delivered private content with the log's public content, discovers the resulting note(s), enqueues /// them for validation, and transitions the FSM to `completed` state conditional to the completion log's block. /// + /// A completion log that cannot yield a note (an empty payload, content too large to pack into a note, or one + /// matching no real note) is skipped without failing and the FSM still advances, so one bad message cannot break + /// sync. + /// /// Because the completion fact is retractable, a reorg of the completion log's block rewinds the FSM back to /// `pending` state (which signals that completion log search should restart). unconstrained fn complete_with_log( @@ -242,53 +257,76 @@ impl PartialNoteFsm { ) { let pending = self.read_pending_partial_note(); - // The first field in the completion log payload is the storage slot, followed by the public note content. - let storage_slot = log.log_payload.get(0); - let public_note_content: BoundedVec = array::subbvec(log.log_payload, 1); - - // Public fields are placed at the end of the packed representation, so we combine the private and public - // packed content to get the complete packed note. - let complete_packed_note = array::append(pending.packed_private_note_content, public_note_content); + // The completion log payload is the storage slot followed by the public note content, so an empty payload + // (e.g. an attacker's tag-only log) has no storage slot and no content. + let payload_len = log.log_payload.len(); + let public_content_len = if payload_len == 0 { 0 } else { payload_len - 1 }; - let discovered_notes = attempt_note_nonce_discovery( - log.unique_note_hashes_in_tx, - log.first_nullifier_in_tx, - compute_note_hash, - compute_note_nullifier, - self.collection.contract_address, - pending.owner, - storage_slot, - pending.randomness, - pending.note_type_id, - complete_packed_note, - ); + let combined_len = pending.packed_private_note_content.len() + public_content_len; - // TODO(#11627): is there anything reasonable we can do if we get a log but it doesn't result in a note being - // found? - if discovered_notes.len() == 0 { - panic( - f"A partial note's completion log did not result in any notes being found - this should never happen", + if payload_len == 0 { + aztecnr_warn_log_format!( + "Partial note with tag {0} has an empty completion log, ignoring", + )( + [pending.note_completion_log_tag], ); - } - - aztecnr_debug_log_format!("Discovered {0} notes for partial note with tag {1}")([ - discovered_notes.len() as Field, - pending.note_completion_log_tag, - ]); - - discovered_notes.for_each(|discovered_note| { - enqueue_note_for_validation( + } else if combined_len > MAX_NOTE_PACKED_LEN { + // A valid note packs into at most MAX_NOTE_PACKED_LEN fields, so this content cannot form one and would + // overflow the append below. + aztecnr_warn_log_format!( + "Partial note with tag {0} has over-length content ({1} > {2} fields), ignoring", + )( + [pending.note_completion_log_tag, combined_len as Field, MAX_NOTE_PACKED_LEN as Field], + ); + } else { + let storage_slot = log.log_payload.get(0); + let public_note_content: BoundedVec = array::subbvec(log.log_payload, 1); + + // Public fields are placed at the end of the packed representation, so we combine the private and public + // packed content to get the complete packed note. + let complete_packed_note = array::append(pending.packed_private_note_content, public_note_content); + + let discovered_notes = attempt_note_nonce_discovery( + log.unique_note_hashes_in_tx, + log.first_nullifier_in_tx, + compute_note_hash, + compute_note_nullifier, self.collection.contract_address, pending.owner, storage_slot, pending.randomness, - discovered_note.note_nonce, + pending.note_type_id, complete_packed_note, - discovered_note.note_hash, - discovered_note.inner_nullifier, - log.tx_hash, ); - }); + + // Content delivered over an unconstrained channel need not correspond to a real note, so discovery can + // come up empty. + if discovered_notes.len() == 0 { + aztecnr_warn_log_format!( + "Partial note with tag {0} completed with no matching note discovered, ignoring", + )( + [pending.note_completion_log_tag], + ); + } else { + aztecnr_debug_log_format!("Discovered {0} notes for partial note with tag {1}")( + [discovered_notes.len() as Field, pending.note_completion_log_tag], + ); + } + + discovered_notes.for_each(|discovered_note| { + enqueue_note_for_validation( + self.collection.contract_address, + pending.owner, + storage_slot, + pending.randomness, + discovered_note.note_nonce, + complete_packed_note, + discovered_note.note_hash, + discovered_note.inner_nullifier, + log.tx_hash, + ); + }); + } self.mark_completed(OriginBlock { block_number: log.block_number, block_hash: log.block_hash }); } @@ -308,9 +346,13 @@ mod test { use crate::ephemeral::EphemeralArray; use crate::facts::OriginBlock; use crate::messages::logs::note::MAX_NOTE_PACKED_LEN; - use crate::messages::processing::log_retrieval_response::LogRetrievalResponse; + use crate::messages::logs::partial_note::MAX_PARTIAL_NOTE_PRIVATE_PACKED_LEN; + use crate::messages::processing::{ + get_enqueued_note_validation_requests, log_retrieval_response::LogRetrievalResponse, + }; use crate::partial_notes::DeliveredPendingPartialNote; use crate::protocol::address::AztecAddress; + use crate::protocol::hash::{compute_note_hash_nonce, compute_siloed_note_hash, compute_unique_note_hash}; use crate::test::helpers::test_environment::TestEnvironment; use super::PartialNoteFsm; @@ -431,6 +473,156 @@ mod test { }); } + #[test] + unconstrained fn complete_with_log_advances_a_pending_reception_whose_log_yields_no_notes() { + let (env, scope) = setup(); + env.private_context(|context| { + let address = context.this_address(); + PartialNoteFsm::init(address, scope, make_pending(scope), finalized_block()); + + // A malicious sender can deliver a partial note whose private content does not match the completion log it + // points at: the note hash computes but is absent from the log's transaction, so nonce discovery yields no + // note. + PartialNoteFsm::load_all(address, scope).get(0).complete_with_log( + completion_log_with_unrelated_note_hashes(), + |_, _, _, _, _, _| Option::some(DISCOVERED_NOTE_HASH), + dummy_compute_note_nullifier, + ); + + let all = PartialNoteFsm::load_all(address, scope); + assert_eq(all.len(), 1); + assert(all.get(0).is_completed()); + assert_eq(enqueued_note_count(), 0); + }); + } + + #[test] + unconstrained fn complete_with_log_advances_when_combined_content_exceeds_note_packed_len() { + let (env, scope) = setup(); + env.private_context(|context| { + let address = context.this_address(); + + // A poisoned delivery can carry a private half as long as MAX_PARTIAL_NOTE_PRIVATE_PACKED_LEN, which equals + // the packed-note capacity. Combined with any completion log's public content it exceeds that capacity, so + // completing must advance the reception rather than overflow the packed-note append and panic. + let pending = DeliveredPendingPartialNote { + owner: scope, + randomness: 0x11, + note_completion_log_tag: 0x22, + note_type_id: 0x33, + packed_private_note_content: BoundedVec::from_array([0; MAX_PARTIAL_NOTE_PRIVATE_PACKED_LEN]), + }; + PartialNoteFsm::init(address, scope, pending, finalized_block()); + + // A completion log carrying one public field (storage slot followed by one value). + let mut log = completion_log_with_unrelated_note_hashes(); + log.log_payload = BoundedVec::from_array([0, 0]); + + PartialNoteFsm::load_all(address, scope).get(0).complete_with_log( + log, + dummy_compute_note_hash, + dummy_compute_note_nullifier, + ); + + let all = PartialNoteFsm::load_all(address, scope); + assert_eq(all.len(), 1); + assert(all.get(0).is_completed()); + assert_eq(enqueued_note_count(), 0); + }); + } + + #[test] + unconstrained fn complete_with_log_advances_when_the_log_payload_is_empty() { + let (env, scope) = setup(); + env.private_context(|context| { + let address = context.this_address(); + PartialNoteFsm::init(address, scope, make_pending(scope), finalized_block()); + + // An attacker can emit a tag-only public log, which arrives with an empty payload (no storage slot). + // Completing must advance the reception rather than read the storage slot out of bounds and panic. + let mut log = completion_log_with_unrelated_note_hashes(); + log.log_payload = BoundedVec::new(); + + PartialNoteFsm::load_all(address, scope).get(0).complete_with_log( + log, + dummy_compute_note_hash, + dummy_compute_note_nullifier, + ); + + let all = PartialNoteFsm::load_all(address, scope); + assert_eq(all.len(), 1); + assert(all.get(0).is_completed()); + assert_eq(enqueued_note_count(), 0); + }); + } + + #[test] + unconstrained fn complete_with_log_discovers_a_note_whose_combined_content_fills_note_packed_len() { + let (env, scope) = setup(); + env.private_context(|context| { + let address = context.this_address(); + + // A private half one field short of the cap plus a single public field combines to exactly + // MAX_NOTE_PACKED_LEN, the largest a valid note can be. The guard rejects only content that *exceeds* the + // cap, so this boundary case must still go through discovery and not be dropped. + let mut packed_private_note_content: BoundedVec = + BoundedVec::new(); + for _ in 0..MAX_PARTIAL_NOTE_PRIVATE_PACKED_LEN - 1 { + packed_private_note_content.push(0); + } + let pending = DeliveredPendingPartialNote { + owner: scope, + randomness: 0x11, + note_completion_log_tag: 0x22, + note_type_id: 0x33, + packed_private_note_content, + }; + PartialNoteFsm::init(address, scope, pending, finalized_block()); + + // A matching completion log carrying one public field (storage slot followed by one value). + let mut log = matching_completion_log(address, COMPLETION_LOG_FIRST_NULLIFIER); + log.log_payload = BoundedVec::from_array([0, 0]); + + PartialNoteFsm::load_all(address, scope).get(0).complete_with_log( + log, + |_, _, _, _, _, _| Option::some(DISCOVERED_NOTE_HASH), + |_, _, _, _, _, _, _| Option::some(DISCOVERED_NOTE_NULLIFIER), + ); + + let all = PartialNoteFsm::load_all(address, scope); + assert_eq(all.len(), 1); + assert(all.get(0).is_completed()); + assert_eq(enqueued_note_count(), 1); + }); + } + + #[test] + unconstrained fn step_completes_once_when_multiple_completion_logs_are_found() { + let (env, scope) = setup(); + env.private_context(|context| { + let address = context.this_address(); + PartialNoteFsm::init(address, scope, make_pending(scope), finalized_block()); + + // A single pending partial note can resolve to more than one completion log (e.g. the same partial note + // completed twice). Both logs here would independently discover a note, but step must complete only once + // (enqueuing exactly one note) rather than panic, so this cannot break sync. + let completion_logs: EphemeralArray = EphemeralArray::empty(); + completion_logs.push(matching_completion_log(address, COMPLETION_LOG_FIRST_NULLIFIER)); + completion_logs.push(matching_completion_log(address, SECOND_COMPLETION_LOG_FIRST_NULLIFIER)); + + PartialNoteFsm::load_all(address, scope).get(0).step( + Option::some(completion_logs), + |_, _, _, _, _, _| Option::some(DISCOVERED_NOTE_HASH), + |_, _, _, _, _, _, _| Option::some(DISCOVERED_NOTE_NULLIFIER), + ); + + let all = PartialNoteFsm::load_all(address, scope); + assert_eq(all.len(), 1); + assert(all.get(0).is_completed()); + assert_eq(enqueued_note_count(), 1); + }); + } + #[test] unconstrained fn step_terminates_a_completed_reception_once_its_block_is_finalized() { let (env, scope) = setup(); @@ -474,6 +666,54 @@ mod test { }); } + global COMPLETION_LOG_FIRST_NULLIFIER: Field = 0x47; + global SECOND_COMPLETION_LOG_FIRST_NULLIFIER: Field = 0x48; + global DISCOVERED_NOTE_HASH: Field = 0x99; + global DISCOVERED_NOTE_NULLIFIER: Field = 0x55; + + /// The number of notes `complete_with_log` has enqueued for validation in the current context. + unconstrained fn enqueued_note_count() -> u32 { + get_enqueued_note_validation_requests().len() + } + + /// A completion log whose transaction contains the unique note hash that `DISCOVERED_NOTE_HASH` resolves to for the + /// given `first_nullifier_in_tx`, so nonce discovery finds a note in it. Paired with compute functions that return + /// `DISCOVERED_NOTE_HASH`. + unconstrained fn matching_completion_log( + contract_address: AztecAddress, + first_nullifier_in_tx: Field, + ) -> LogRetrievalResponse { + let note_nonce = compute_note_hash_nonce(first_nullifier_in_tx, 0); + let unique_note_hash = compute_unique_note_hash( + note_nonce, + compute_siloed_note_hash(contract_address, DISCOVERED_NOTE_HASH), + ); + LogRetrievalResponse { + log_payload: BoundedVec::from_array([0]), + tx_hash: 0, + unique_note_hashes_in_tx: BoundedVec::from_array([unique_note_hash]), + first_nullifier_in_tx, + block_number: 1, + block_timestamp: 0, + block_hash: 0, + } + } + + /// A well-formed completion log tied to a block TXE reports as finalized, carrying the note hashes of some + /// unrelated transaction. A note whose hash is not among them discovers to nothing, standing in for the real log a + /// poisoned partial note's tag resolves to. + unconstrained fn completion_log_with_unrelated_note_hashes() -> LogRetrievalResponse { + LogRetrievalResponse { + log_payload: BoundedVec::from_array([0]), + tx_hash: 0, + unique_note_hashes_in_tx: BoundedVec::from_array([0x1234]), + first_nullifier_in_tx: COMPLETION_LOG_FIRST_NULLIFIER, + block_number: 1, + block_timestamp: 0, + block_hash: 0, + } + } + unconstrained fn dummy_compute_note_hash( _packed_note: BoundedVec, _owner: AztecAddress, diff --git a/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap b/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap index 5e277ac07d61..c69025b6d66c 100644 --- a/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap +++ b/noir-projects/contract-snapshots/tests/snapshots/expand/token_contract/snapshots__expanded.snap @@ -4048,6 +4048,18 @@ mod test { utils::check_private_balance(env, token_contract_address, recipient, amount); } + /// A partial note can be completed more than once (`complete` only existence-checks its validity commitment, which is + /// never consumed), so both finalizes emit a completion log under the same tag. Discovery tolerates the two logs + /// rather than failing: it completes with the first (recovering that note) and ignores the rest. + #[test] + unconstrained fn discovery_tolerates_a_partial_note_completed_twice() { + let (env, token_contract_address, owner, recipient, _): (aztec::test::helpers::test_environment::TestEnvironment, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, u128) = utils::setup_and_mint_to_public(false); + let partial_uint_note: PartialUintNote = env.call_private(owner, Token::at(token_contract_address).prepare_private_balance_increase(recipient)); + env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(1_u128, partial_uint_note)); + env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(1_u128, partial_uint_note)); + utils::check_private_balance(env, token_contract_address, recipient, 1_u128); + } + #[test(should_fail_with = "Invalid partial note or completer")] unconstrained fn transfer_to_private_transfer_not_prepared() { let (env, token_contract_address, owner, _, amount): (aztec::test::helpers::test_environment::TestEnvironment, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, aztec::protocol::address::AztecAddress, u128) = utils::setup_and_mint_to_public(false); diff --git a/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_to_private.nr b/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_to_private.nr index 386f0b68e785..e7b091d64047 100644 --- a/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_to_private.nr +++ b/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_to_private.nr @@ -82,6 +82,23 @@ unconstrained fn transfer_to_private_external_orchestration_multiple_notes() { utils::check_private_balance(env, token_contract_address, recipient, amount); } +/// A partial note can be completed more than once (`complete` only existence-checks its validity commitment, which is +/// never consumed), so both finalizes emit a completion log under the same tag. Discovery tolerates the two logs +/// rather than failing: it completes with the first (recovering that note) and ignores the rest. +#[test] +unconstrained fn discovery_tolerates_a_partial_note_completed_twice() { + let (env, token_contract_address, owner, recipient, _) = + utils::setup_and_mint_to_public(/* with_account_contracts */ false); + + let partial_uint_note = + env.call_private(owner, Token::at(token_contract_address).prepare_private_balance_increase(recipient)); + + env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(1, partial_uint_note)); + env.call_public(owner, Token::at(token_contract_address).finalize_transfer_to_private(1, partial_uint_note)); + + utils::check_private_balance(env, token_contract_address, recipient, 1); +} + #[test(should_fail_with = "Invalid partial note or completer")] unconstrained fn transfer_to_private_transfer_not_prepared() { // Setup without account contracts. We are not using authwits here, so dummy accounts are enough diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index de5b1a7bb592..6b6bb4bf127b 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -365,7 +365,7 @@ networks: ENABLE_AUTO_SHUTDOWN: false # Slasher penalties - more lenient initially SLASH_DATA_WITHHOLDING_PENALTY: 0 - SLASH_INACTIVITY_TARGET_PERCENTAGE: 0.8 + SLASH_INACTIVITY_TARGET_PERCENTAGE: 0.7 SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD: 2 SLASH_INACTIVITY_PENALTY: 2000e18 SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY: 2000e18 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 57d099e5eed7..621c76bbb333 100644 --- a/yarn-project/archiver/package.json +++ b/yarn-project/archiver/package.json @@ -75,6 +75,7 @@ "@aztec/l1-artifacts": "portal:../../l1-contracts/l1-artifacts", "@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 655dea45d14d..6ee6c14c7b3e 100644 --- a/yarn-project/archiver/tsconfig.json +++ b/yarn-project/archiver/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../protocol-contracts" }, + { + "path": "../standard-contracts" + }, { "path": "../stdlib" }, diff --git a/yarn-project/aztec.js/src/ethereum/portal_manager.ts b/yarn-project/aztec.js/src/ethereum/portal_manager.ts index 5b913a6d2b4e..5f2113902cf0 100644 --- a/yarn-project/aztec.js/src/ethereum/portal_manager.ts +++ b/yarn-project/aztec.js/src/ethereum/portal_manager.ts @@ -1,3 +1,9 @@ +import { + type L1TxConfig, + type L1TxUtils, + createL1TxUtils, + getL1TxUtilsConfigEnvVars, +} from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient, ViemContract } from '@aztec/ethereum/types'; import { extractEvent } from '@aztec/ethereum/utils'; import type { EpochNumber } from '@aztec/foundation/branded-types'; @@ -16,7 +22,7 @@ import { computeL2ToL1MessageHash, computeSecretHash } from '@aztec/stdlib/hash' import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { getL2ToL1MessageLeafId } from '@aztec/stdlib/messaging'; -import { type Hex, getContract, toFunctionSelector } from 'viem'; +import { type Hex, encodeFunctionData, getContract, toFunctionSelector } from 'viem'; /** L1 to L2 message info to claim it on L2. */ export type L2Claim = { @@ -51,10 +57,26 @@ export async function generateClaimSecret(logger?: Logger): Promise<[Fr, Fr]> { return [secret, secretHash]; } +// `Inbox.sendL2Message` (reached by every `depositToAztec*` call) inserts into a height-10 +// (`L1_TO_L2_MSG_SUBTREE_HEIGHT`) incremental frontier tree. The per-insert cost swings by up to ~10 hash levels +// (which translates to ~40k gas) depending on where the global message index lands when the tx is mined: inserts that +// complete a subtree cascade through cold SLOADs + an SSTORE vs cheap inserts that touch one slot. A point-in-time +// `eth_estimateGas` taken at a cheap index therefore under-sizes a deposit that mines at a subtree boundary. That ~40k +// swing exceeds the default 20% buffer on a ~100k deposit. That is why we raise the gas-limit buffer to 2x here. +// Note: a larger operator override via L1_GAS_LIMIT_BUFFER_PERCENTAGE still wins. +const INBOX_DEPOSIT_GAS_LIMIT_BUFFER_PERCENTAGE = 100; + +/** Per-call gas config for `depositToAztec*`: the Inbox-deposit buffer floor, or a larger operator override. */ +function inboxDepositGasConfig(): L1TxConfig { + const configuredBuffer = getL1TxUtilsConfigEnvVars().gasLimitBufferPercentage ?? 0; + return { gasLimitBufferPercentage: Math.max(configuredBuffer, INBOX_DEPOSIT_GAS_LIMIT_BUFFER_PERCENTAGE) }; +} + /** Helper for managing an ERC20 on L1. */ export class L1TokenManager { private contract: ViemContract; private handler: ViemContract | undefined; + private readonly l1TxUtils: L1TxUtils; public constructor( /** Address of the ERC20 contract. */ @@ -76,6 +98,7 @@ export class L1TokenManager { client: this.extendedClient, }); } + this.l1TxUtils = createL1TxUtils(this.extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the amount of tokens available to mint via the handler. @@ -108,8 +131,10 @@ export class L1TokenManager { const mintAmount = await this.getMintAmount(); this.logger.info(`Minting ${mintAmount} tokens for ${stringifyEthAddress(address, addressName)}`); // NOTE: the handler mints a fixed amount. - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.handler.write.mint([address]), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.handler.address, + abi: FeeAssetHandlerAbi, + data: encodeFunctionData({ abi: FeeAssetHandlerAbi, functionName: 'mint', args: [address] }), }); } @@ -121,8 +146,10 @@ export class L1TokenManager { */ public async approve(amount: bigint, address: Hex, addressName = '') { this.logger.info(`Approving ${amount} tokens for ${stringifyEthAddress(address, addressName)}`); - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.contract.write.approve([address, amount]), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.contract.address, + abi: TestERC20Abi, + data: encodeFunctionData({ abi: TestERC20Abi, functionName: 'approve', args: [address, amount] }), }); } } @@ -131,6 +158,7 @@ export class L1TokenManager { export class L1FeeJuicePortalManager { private readonly tokenManager: L1TokenManager; private readonly contract: ViemContract; + private readonly l1TxUtils: L1TxUtils; constructor( portalAddress: EthAddress, @@ -145,6 +173,7 @@ export class L1FeeJuicePortalManager { abi: FeeJuicePortalAbi, client: extendedClient, }); + this.l1TxUtils = createL1TxUtils(extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the associated token manager for the L1 ERC20. */ @@ -176,9 +205,14 @@ export class L1FeeJuicePortalManager { await this.contract.simulate.depositToAztecPublic(args); - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.contract.write.depositToAztecPublic(args), - }); + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.contract.address, + abi: FeeJuicePortalAbi, + data: encodeFunctionData({ abi: FeeJuicePortalAbi, functionName: 'depositToAztecPublic', args }), + }, + inboxDepositGasConfig(), + ); this.logger.info('Deposited to Aztec public successfully', { txReceipt }); @@ -249,6 +283,7 @@ export class L1FeeJuicePortalManager { export class L1ToL2TokenPortalManager { protected readonly portal: ViemContract; protected readonly tokenManager: L1TokenManager; + protected readonly l1TxUtils: L1TxUtils; constructor( portalAddress: EthAddress, @@ -263,6 +298,7 @@ export class L1ToL2TokenPortalManager { abi: TokenPortalAbi, client: extendedClient, }); + this.l1TxUtils = createL1TxUtils(extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the token manager for the underlying L1 token. */ @@ -280,15 +316,17 @@ export class L1ToL2TokenPortalManager { const [claimSecret, claimSecretHash] = await this.bridgeSetup(amount, mint); this.logger.info('Sending L1 tokens to L2 to be claimed publicly'); - const { request } = await this.portal.simulate.depositToAztecPublic([ - to.toString(), - amount, - claimSecretHash.toString(), - ]); - - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(request), - }); + const args = [to.toString(), amount, claimSecretHash.toString()] as const; + await this.portal.simulate.depositToAztecPublic(args); + + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'depositToAztecPublic', args }), + }, + inboxDepositGasConfig(), + ); const log = extractEvent( txReceipt.logs, @@ -334,11 +372,17 @@ export class L1ToL2TokenPortalManager { const [claimSecret, claimSecretHash] = await this.bridgeSetup(amount, mint); this.logger.info('Sending L1 tokens to L2 to be claimed privately'); - const { request } = await this.portal.simulate.depositToAztecPrivate([amount, claimSecretHash.toString()]); - - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(request), - }); + const args = [amount, claimSecretHash.toString()] as const; + await this.portal.simulate.depositToAztecPrivate(args); + + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'depositToAztecPrivate', args }), + }, + inboxDepositGasConfig(), + ); const log = extractEvent( txReceipt.logs, @@ -437,7 +481,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { } // Call function on L1 contract to consume the message - const { request: withdrawRequest } = await this.portal.simulate.withdraw([ + const withdrawArgs = [ recipient.toString(), amount, false, @@ -445,10 +489,13 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { BigInt(numCheckpointsInEpoch), messageIndex, siblingPath.toBufferArray().map((buf: Buffer): Hex => `0x${buf.toString('hex')}`), - ]); + ] as const; + await this.portal.simulate.withdraw(withdrawArgs); - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(withdrawRequest), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'withdraw', args: withdrawArgs }), }); const isConsumedAfter = await this.outbox.read.hasMessageBeenConsumedAtEpoch([BigInt(epochNumber), messageLeafId]); diff --git a/yarn-project/aztec.js/src/utils/node.test.ts b/yarn-project/aztec.js/src/utils/node.test.ts index 18ea35d88e06..deec8998aba5 100644 --- a/yarn-project/aztec.js/src/utils/node.test.ts +++ b/yarn-project/aztec.js/src/utils/node.test.ts @@ -1,4 +1,5 @@ import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { TimeoutError } from '@aztec/foundation/error'; import { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { @@ -14,7 +15,7 @@ import { import { type MockProxy, mock } from 'jest-mock-extended'; -import { waitForTx } from './node.js'; +import { waitForNode, waitForTx } from './node.js'; describe('waitForTx', () => { let node: MockProxy; @@ -137,3 +138,24 @@ describe('waitForTx', () => { }); }); }); + +describe('waitForNode', () => { + let node: MockProxy; + + beforeEach(() => { + node = mock(); + }); + + it('resolves once the node becomes reachable', async () => { + node.getNodeInfo.mockRejectedValueOnce(new Error('not up yet')).mockResolvedValueOnce({} as any); + + await expect(waitForNode(node, undefined, { timeout: 5, interval: 0.01 })).resolves.toBeUndefined(); + expect(node.getNodeInfo).toHaveBeenCalledTimes(2); + }); + + it('rejects with a TimeoutError when the node stays unreachable', async () => { + node.getNodeInfo.mockRejectedValue(new Error('unreachable')); + + await expect(waitForNode(node, undefined, { timeout: 0.05, interval: 0.01 })).rejects.toThrow(TimeoutError); + }); +}); diff --git a/yarn-project/aztec.js/src/utils/node.ts b/yarn-project/aztec.js/src/utils/node.ts index ee88b68a7159..7f92a1a77948 100644 --- a/yarn-project/aztec.js/src/utils/node.ts +++ b/yarn-project/aztec.js/src/utils/node.ts @@ -6,18 +6,31 @@ import { SortedTxStatuses, TxStatus } from '@aztec/stdlib/tx'; import { DefaultWaitOpts, type WaitOpts } from '../contract/wait_opts.js'; -export const waitForNode = async (node: AztecNode, logger?: Logger) => { - await retryUntil(async () => { - try { - logger?.verbose('Attempting to contact Aztec node...'); - await node.getNodeInfo(); - logger?.verbose('Contacted Aztec node'); - return true; - } catch { - logger?.verbose('Failed to contact Aztec Node'); - } - return undefined; - }, 'RPC Get Node Info'); +/** + * Waits for an Aztec node to become reachable, polling {@link AztecNode.getNodeInfo} until it succeeds. + * @param node - The Aztec node to contact. + * @param logger - Optional logger for polling progress. + * @param opts - Optional timeout and interval (in seconds). `timeout` defaults to {@link DefaultWaitOpts.timeout}; + * pass `timeout: 0` to wait indefinitely. + * @throws TimeoutError if the node stays unreachable past the timeout. + */ +export const waitForNode = async (node: AztecNode, logger?: Logger, opts?: Pick) => { + await retryUntil( + async () => { + try { + logger?.verbose('Attempting to contact Aztec node...'); + await node.getNodeInfo(); + logger?.verbose('Contacted Aztec node'); + return true; + } catch { + logger?.verbose('Failed to contact Aztec Node'); + } + return undefined; + }, + 'RPC Get Node Info', + opts?.timeout ?? DefaultWaitOpts.timeout, + opts?.interval ?? DefaultWaitOpts.interval, + ); }; /** Returns true if the receipt status is at least the desired status level. */ diff --git a/yarn-project/aztec/bootstrap.sh b/yarn-project/aztec/bootstrap.sh index c27fba277781..fadce4199f06 100755 --- a/yarn-project/aztec/bootstrap.sh +++ b/yarn-project/aztec/bootstrap.sh @@ -12,6 +12,8 @@ function test_cmds { # All CLI tests share test/mixed-workspace/target so they must run sequentially # in a single jest invocation (--runInBand is set by run_test.sh). echo "$hash:ISOLATE=1:NAME=aztec/cli NARGO=$NARGO BB=$BB PROFILER_PATH=$PROFILER_PATH yarn-project/scripts/run_test.sh aztec/src/cli" + # setupLocalNetwork smoke test: spins up anvil + an in-process node (network usage ⇒ ISOLATE). + echo "$hash:ISOLATE=1:NAME=aztec/testing yarn-project/scripts/run_test.sh aztec/src/testing/local-network.test.ts" } case "$cmd" in diff --git a/yarn-project/aztec/src/local-network/local-network.ts b/yarn-project/aztec/src/local-network/local-network.ts index eefbd37ed8df..9c0fb10eec23 100644 --- a/yarn-project/aztec/src/local-network/local-network.ts +++ b/yarn-project/aztec/src/local-network/local-network.ts @@ -88,6 +88,8 @@ export type LocalNetworkConfig = AztecNodeConfig & { l1Mnemonic: string; /** Whether to deploy test accounts on local network start.*/ testAccounts: boolean; + /** Override the default per-address fee juice granted at genesis to funded addresses. */ + initialAccountFeeJuice?: Fr; }; /** @@ -176,7 +178,10 @@ export async function createLocalNetwork(config: Partial = { ...(initialAccounts.length ? [bananaFPC, sponsoredFPC] : []), ...prefundAddresses, ]; - const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues(fundedAddresses); + const { genesisArchiveRoot, genesis, fundingNeeded } = await getGenesisValues( + fundedAddresses, + config.initialAccountFeeJuice, + ); const dateProvider = new TestDateProvider(); diff --git a/yarn-project/aztec/src/testing/index.ts b/yarn-project/aztec/src/testing/index.ts index 0155a48144fd..6459e351146b 100644 --- a/yarn-project/aztec/src/testing/index.ts +++ b/yarn-project/aztec/src/testing/index.ts @@ -1,4 +1,5 @@ export { EthCheatCodes, RollupCheatCodes } from '@aztec/ethereum/test'; export { CheatCodes } from './cheat_codes.js'; export { EpochTestSettler } from './epoch_test_settler.js'; +export { setupLocalNetwork, TEST_FEE_PADDING, type LocalNetwork, type LocalNetworkOptions } from './local-network.js'; export { getTokenAllowedSetupFunctions } from './token_allowed_setup.js'; diff --git a/yarn-project/aztec/src/testing/local-network.test.ts b/yarn-project/aztec/src/testing/local-network.test.ts new file mode 100644 index 000000000000..648a748f2b51 --- /dev/null +++ b/yarn-project/aztec/src/testing/local-network.test.ts @@ -0,0 +1,52 @@ +import { getInitialTestAccountsData } from '@aztec/accounts/testing'; +import { TokenContract } from '@aztec/noir-contracts.js/Token'; +import { EmbeddedWallet } from '@aztec/wallets/embedded'; + +import { TEST_FEE_PADDING, setupLocalNetwork } from './local-network.js'; + +describe('setupLocalNetwork', () => { + it('serves a live node on a random L1 port and tears down cleanly', async () => { + await using net = await setupLocalNetwork(); + const info = await net.node.getNodeInfo(); + expect(info.l1ContractAddresses.rollupAddress).toBeDefined(); + expect(await net.node.getBlockNumber()).toBeGreaterThanOrEqual(0); + expect(net.l1ChainId).toBe(31337); + // OS-assigned ephemeral port, never the fixed default 8545. + expect(net.l1RpcUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(net.l1RpcUrl).not.toContain(':8545'); + }, 300_000); + + it('runs two networks in parallel on distinct ports', async () => { + const [a, b] = await Promise.all([setupLocalNetwork(), setupLocalNetwork()]); + try { + expect(a.l1RpcUrl).not.toEqual(b.l1RpcUrl); + expect(await a.node.getBlockNumber()).toBeGreaterThanOrEqual(0); + expect(await b.node.getBlockNumber()).toBeGreaterThanOrEqual(0); + } finally { + await Promise.all([a.stop(), b.stop()]); + } + }, 300_000); + + it('pre-funds addresses at genesis so they can pay for their own txs', async () => { + const [alice] = await getInitialTestAccountsData(); + const net = await setupLocalNetwork({ fundedAddresses: [alice.address] }); + try { + const wallet = await EmbeddedWallet.create(net.node, { + ephemeral: true, + pxeConfig: { proverEnabled: false }, + }); + await wallet.createSchnorrInitializerlessAccount(alice.secret, alice.salt, alice.signingKey); + wallet.setMinFeePadding(TEST_FEE_PADDING); + + const { contract } = await TokenContract.deploy(wallet, alice.address, 'TokenName', 'TKN', 18).send({ + from: alice.address, + }); + expect(contract.address).toBeDefined(); + expect(await net.node.getBlockNumber()).toBeGreaterThan(0); + + await wallet.stop(); + } finally { + await net.stop(); + } + }, 300_000); +}); diff --git a/yarn-project/aztec/src/testing/local-network.ts b/yarn-project/aztec/src/testing/local-network.ts new file mode 100644 index 000000000000..39c3a97e0167 --- /dev/null +++ b/yarn-project/aztec/src/testing/local-network.ts @@ -0,0 +1,97 @@ +import type { AztecNodeService } from '@aztec/aztec-node'; +import type { AztecNodeConfig } from '@aztec/aztec-node/config'; +import type { Fr } from '@aztec/aztec.js/fields'; +import { startAnvil } from '@aztec/ethereum/test'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { foundry } from 'viem/chains'; + +import { createLocalNetwork } from '../local-network/local-network.js'; + +/** A running in-process local network: an inline Aztec node backed by its own anvil L1. */ +export interface LocalNetwork extends AsyncDisposable { + /** Fully-synced Aztec node, ready to serve client requests. */ + node: AztecNodeService; + /** RPC URL of the spawned anvil instance. */ + l1RpcUrl: string; + /** Chain id used on L1 (foundry's default 31337). */ + l1ChainId: number; + /** Stops every process started by the fixture: node and anvil. Also invoked by `await using`. */ + stop: () => Promise; +} + +/** Options for {@link setupLocalNetwork}. */ +export interface LocalNetworkOptions { + /** + * Addresses that should hold fee juice at genesis. Saves each of these the round-trip of bridging + * + claiming fee juice before they can pay for gas. + */ + fundedAddresses?: AztecAddress[]; + /** Override the default per-address genesis fee juice granted to {@link fundedAddresses}. */ + initialAccountFeeJuice?: Fr; + /** Node config overrides, e.g. `realProofs`, `aztecEpochDuration`, `p2pEnabled`. */ + config?: Partial; +} + +/** + * Spin up an in-process local network with the given addresses pre-funded. + * + * Each call spawns its own anvil on an OS-assigned random port and runs the Aztec node inline via + * the same {@link createLocalNetwork} codepath that backs `aztec start --local-network` (with the + * sandbox account/FPC/token setup skipped). Distinct ports let independent suites run in parallel. + * The caller must `await result.stop()` in its teardown (or hold the result with `await using`). + * + * Requires a Foundry toolchain (`anvil`/`forge`), installed via `aztec-up` or `foundryup`. Binaries + * are located in the standard install directories or on `PATH`; set `$ANVIL_BIN` / `$FORGE_BIN` to + * pin specific ones. + */ +export async function setupLocalNetwork(opts: LocalNetworkOptions = {}): Promise { + // `--port 0` → anvil binds an ephemeral port that `startAnvil` reads back, so parallel suites + // never collide on a fixed port. + const { rpcUrl, stop: stopAnvil } = await startAnvil({ port: 0 }); + + try { + const { node, stop: stopNode } = await createLocalNetwork( + { + ...opts.config, + l1RpcUrls: [rpcUrl], + testAccounts: false, + prefundAddresses: (opts.fundedAddresses ?? []).map(a => a.toString()), + initialAccountFeeJuice: opts.initialAccountFeeJuice, + }, + () => {}, + ); + + // Stop the node before anvil (its teardown still talks to L1); the finally guarantees anvil is + // reaped even if node shutdown throws. + const stop = async () => { + try { + await stopNode(); + } finally { + await stopAnvil(); + } + }; + + return { + node, + l1RpcUrl: rpcUrl, + l1ChainId: foundry.id, + stop, + [Symbol.asyncDispose]: stop, + }; + } catch (err) { + await stopAnvil(); + throw err; + } +} + +/** + * Min-fee padding multiplier for test wallets whose txs may mine well after their fee estimate. + * The automine sequencer builds one block per tx and advances L1 time in big jumps, and proposer + * pipelining evolves the fee-asset price across the build/publish gap (~20x observed in CI), so the + * network's congestion base fee can swing sharply between the wallet's fee estimate and the block + * the tx actually lands in. The default wallet padding isn't enough and trips + * `maxFeesPerGas.feePerL2Gas must be >= gasFees.feePerL2Gas`. Apply via + * `wallet.setMinFeePadding(TEST_FEE_PADDING)` on every test wallet that sends txs. + */ +export const TEST_FEE_PADDING = 30; diff --git a/yarn-project/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 d720ccdb83a2..19eac4f95e0b 100644 --- a/yarn-project/bot/src/factory.ts +++ b/yarn-project/bot/src/factory.ts @@ -44,6 +44,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, @@ -86,23 +91,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 }; @@ -140,25 +157,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(); @@ -182,10 +206,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); } @@ -289,34 +311,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, @@ -324,22 +349,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 @@ -348,12 +357,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, @@ -378,36 +381,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}`, ); } @@ -590,19 +590,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/cli-wallet/src/utils/wallet.ts b/yarn-project/cli-wallet/src/utils/wallet.ts index 865705f7038d..605939978ab1 100644 --- a/yarn-project/cli-wallet/src/utils/wallet.ts +++ b/yarn-project/cli-wallet/src/utils/wallet.ts @@ -145,7 +145,10 @@ export class CLIWallet extends BaseWallet { increasedFee: InteractionFeeOptions, ): Promise { const cancellationTxRequest = await this.createCancellationTxExecutionRequest(from, txNonce, increasedFee); - return await this.pxe.proveTx(cancellationTxRequest, { scopes: this.scopesFrom(from), senderForTags: from }); + return await this.pxe.proveTx(cancellationTxRequest, { + scopes: this.scopesFrom(from, [], undefined), + senderForTags: from, + }); } override async getAccountFromAddress(address: AztecAddress) { @@ -292,7 +295,7 @@ export class CLIWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const feeExecutionPayload = await feeOptions.walletFeePaymentMethod?.getExecutionPayload(); const finalExecutionPayload = feeExecutionPayload ? mergeExecutionPayloads([feeExecutionPayload, executionPayload]) diff --git a/yarn-project/cli/src/cmds/validator_keys/index.ts b/yarn-project/cli/src/cmds/validator_keys/index.ts index 8588f40c55a7..0c6088a6cbf7 100644 --- a/yarn-project/cli/src/cmds/validator_keys/index.ts +++ b/yarn-project/cli/src/cmds/validator_keys/index.ts @@ -33,8 +33,10 @@ export function injectCommands(program: Command, log: LogFn) { 'Coinbase ETH address to use when proposing. Defaults to attester address.', parseEthereumAddress, ) - // TODO: add funding account back in when implemented - // .option('--funding-account ', 'ETH private key (or address for remote signer setup) to fund publishers') + .option( + '--funding-account ', + 'ETH funding account used to top up publisher EOAs. Provide a private key, or an address together with --remote-signer.', + ) .option('--remote-signer ', 'Default remote signer URL for accounts in this file') .option('--ikm ', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32)) .option('--bls-path ', `EIP-2334 path (default ${defaultBlsPath})`) @@ -99,8 +101,6 @@ export function injectCommands(program: Command, log: LogFn) { 'Coinbase ETH address to use when proposing. Defaults to attester address.', parseEthereumAddress, ) - // TODO: add funding account back in when implemented - // .option('--funding-account ', 'ETH private key (or address for remote signer setup) to fund publishers') .option('--remote-signer ', 'Default remote signer URL for accounts in this file') .option('--ikm ', 'Initial keying material for BLS (alternative to mnemonic)', value => parseHex(value, 32)) .option('--bls-path ', `EIP-2334 path (default ${defaultBlsPath})`) @@ -117,6 +117,32 @@ export function injectCommands(program: Command, log: LogFn) { await addValidatorKeys(existing, options, log); }); + group + .command('set-funding-account') + .summary('Set the funding account of an existing keystore') + .description( + 'Sets the keystore-level ETH funding account used to top up publisher EOAs, replacing any existing one', + ) + .argument('', 'Path to existing keystore JSON') + .argument( + '', + 'Funding account: a private key, or an address (needs --remote-signer unless the keystore already defines one)', + ) + .option( + '--remote-signer ', + 'Remote signer URL for the funding account (required with an address unless the keystore already defines one)', + ) + .option( + '--password ', + 'Password for writing the funding key as an encrypted ETH JSON V3 file. Empty string allowed', + ) + .option('--encrypted-keystore-dir ', 'Output directory for the encrypted funding key file') + .option('--json', 'Echo resulting JSON to stdout') + .action(async (existing: string, fundingAccount: string, options) => { + const { setFundingAccount } = await import('./set_funding_account.js'); + await setFundingAccount(existing, fundingAccount, options, log); + }); + group .command('staker') .summary('Generate staking JSON from keystore') diff --git a/yarn-project/cli/src/cmds/validator_keys/new.ts b/yarn-project/cli/src/cmds/validator_keys/new.ts index 208ebc7a8748..bc8ebc9826bd 100644 --- a/yarn-project/cli/src/cmds/validator_keys/new.ts +++ b/yarn-project/cli/src/cmds/validator_keys/new.ts @@ -9,12 +9,14 @@ import { wordlist } from '@scure/bip39/wordlists/english.js'; import { readFile, writeFile } from 'fs/promises'; import { basename, dirname, join } from 'path'; import { createPublicClient, fallback, http } from 'viem'; -import { generateMnemonic, mnemonicToAccount } from 'viem/accounts'; +import { generateMnemonic, mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'; import { buildValidatorEntries, + encryptFundingAccountToFile, logValidatorSummaries, maybePrintJson, + resolveFundingAccount, resolveKeystoreOutputPath, writeBlsBn254ToFile, writeEthJsonV3ToFile, @@ -23,6 +25,7 @@ import { import { processAttesterAccounts } from './staker.js'; import { validateBlsPathOptions, + validateFundingAccountOptions, validatePublisherOptions, validateRemoteSignerOptions, validateStakerOutputOptions, @@ -51,6 +54,7 @@ export type NewValidatorKeystoreOptions = { json?: boolean; feeRecipient: AztecAddress; coinbase?: EthAddress; + fundingAccount?: string; remoteSigner?: string; stakerOutput?: boolean; gseAddress?: EthAddress; @@ -147,6 +151,8 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, validatePublisherOptions(options); // validate remote signer options validateRemoteSignerOptions(options); + // validate funding account option + validateFundingAccountOptions(options); const { dataDir, @@ -156,6 +162,7 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, publishers, json, coinbase, + fundingAccount, accountIndex = 0, addressIndex = 0, feeRecipient, @@ -213,17 +220,26 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, remoteSigner, }); + let resolvedFundingAccount = fundingAccount ? resolveFundingAccount(fundingAccount, remoteSigner) : undefined; + // If password provided, write ETH JSON V3 and BLS BN254 keystores and replace plaintext if (shouldEncryptKeystores) { const encryptedKeystoreOutDir = encryptedKeystoreDir && encryptedKeystoreDir.length > 0 ? encryptedKeystoreDir : keystoreOutDir; await writeEthJsonV3ToFile(validators, { outDir: encryptedKeystoreOutDir, password: ethPassword }); await writeBlsBn254ToFile(validators, { outDir: encryptedKeystoreOutDir, password: blsPassword }); + if (resolvedFundingAccount) { + resolvedFundingAccount = await encryptFundingAccountToFile(resolvedFundingAccount, { + outDir: encryptedKeystoreOutDir, + password: ethPassword, + }); + } } const keystore = { schemaVersion: 1, validators, + ...(resolvedFundingAccount ? { fundingAccount: resolvedFundingAccount } : {}), }; await writeKeystoreFile(outputPath, keystore); @@ -285,6 +301,11 @@ export async function newValidatorKeystore(options: NewValidatorKeystoreOptions, // print a concise summary of public keys (addresses and BLS pubkeys) if no --json options was selected if (!json) { logValidatorSummaries(log, summaries); + if (fundingAccount) { + const funderAddress = + fundingAccount.length === 66 ? privateKeyToAccount(fundingAccount as `0x${string}`).address : fundingAccount; + log(`funding account: ${funderAddress}`); + } } if (mnemonic && remoteSigner && !json) { diff --git a/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts b/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts new file mode 100644 index 000000000000..21258abb56b1 --- /dev/null +++ b/yarn-project/cli/src/cmds/validator_keys/set_funding_account.ts @@ -0,0 +1,55 @@ +import type { LogFn } from '@aztec/foundation/log'; +import { loadKeystoreFile } from '@aztec/node-keystore/loader'; +import type { KeyStore } from '@aztec/node-keystore/types'; + +import { dirname } from 'path'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { encryptFundingAccountToFile, maybePrintJson, resolveFundingAccount, writeKeystoreFile } from './shared.js'; +import { validateFundingAccountOptions } from './utils.js'; + +export type SetFundingAccountOptions = { + remoteSigner?: string; + password?: string; + encryptedKeystoreDir?: string; + json?: boolean; +}; + +/** + * Sets the top-level funding account of an existing keystore, replacing any previous one. The + * account may be a private key, or an address paired with a remote signer URL. With a password, + * a plaintext key is encrypted to an ETH JSON V3 file and stored as a { path, password } reference. + */ +export async function setFundingAccount( + existing: string, + fundingAccount: string, + options: SetFundingAccountOptions, + log: LogFn, +) { + const { remoteSigner, password, encryptedKeystoreDir, json } = options; + + const keystore: KeyStore = loadKeystoreFile(existing); + + const validated = { fundingAccount, remoteSigner }; + validateFundingAccountOptions(validated, !!keystore.remoteSigner); + + let resolved = resolveFundingAccount(validated.fundingAccount!, remoteSigner); + if (password !== undefined) { + const outDir = encryptedKeystoreDir && encryptedKeystoreDir.length > 0 ? encryptedKeystoreDir : dirname(existing); + resolved = await encryptFundingAccountToFile(resolved, { outDir, password }); + } + + if (keystore.fundingAccount) { + log('Replacing existing funding account in keystore'); + } + keystore.fundingAccount = resolved; + + await writeKeystoreFile(existing, keystore); + + if (!json) { + const value = validated.fundingAccount!; + const funderAddress = value.length === 66 ? privateKeyToAccount(value as `0x${string}`).address : value; + log(`Set funding account ${funderAddress} in ${existing}`); + } + maybePrintJson(log, !!json, keystore as unknown as Record); +} diff --git a/yarn-project/cli/src/cmds/validator_keys/shared.ts b/yarn-project/cli/src/cmds/validator_keys/shared.ts index 981acc155e1b..64d162454505 100644 --- a/yarn-project/cli/src/cmds/validator_keys/shared.ts +++ b/yarn-project/cli/src/cmds/validator_keys/shared.ts @@ -3,7 +3,7 @@ import { asyncPool } from '@aztec/foundation/async-pool'; import { deriveBlsPrivateKey } from '@aztec/foundation/crypto/bls'; import { createBn254Keystore } from '@aztec/foundation/crypto/bls/bn254_keystore'; import { computeBn254G1PublicKeyCompressed } from '@aztec/foundation/crypto/bn254'; -import type { EthAddress } from '@aztec/foundation/eth-address'; +import { EthAddress } from '@aztec/foundation/eth-address'; import type { LogFn } from '@aztec/foundation/log'; import type { EthAccount, EthPrivateKey, ValidatorKeyStore } from '@aztec/node-keystore/types'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -125,6 +125,21 @@ export function deriveEthAttester( : (('0x' + Buffer.from(acct.getHdKey().privateKey!).toString('hex')) as EthPrivateKey); } +/** + * Resolve a `--funding-account` value into a keystore `EthAccount`. A 66-char value is a private key + * (used verbatim). A 42-char value is an address: with an explicit `remoteSigner` URL it becomes an + * `{ address, remoteSignerUrl }` pair; without one it is stored as a bare address that falls back to + * the keystore-level remote signer at runtime. Callers must validate the value first (see + * `validateFundingAccountOptions`). + */ +export function resolveFundingAccount(fundingAccount: string, remoteSigner?: string): EthAccount { + if (fundingAccount.length === 66) { + return fundingAccount as EthPrivateKey; + } + const address = EthAddress.fromString(fundingAccount); + return remoteSigner ? ({ address, remoteSignerUrl: remoteSigner } as EthAccount) : address; +} + export async function buildValidatorEntries(input: BuildValidatorsInput) { const { validatorCount, @@ -330,6 +345,27 @@ export async function writeEthJsonV3Keystore( return outPath; } +/** + * If `account` is a plaintext ETH private key, encrypt it to a JSON V3 file and return a + * { path, password } reference; otherwise return it unchanged. + */ +async function maybeEncryptEthAccount(account: any, label: string, options: { outDir: string; password: string }) { + if (typeof account === 'string' && account.startsWith('0x') && account.length === 66) { + const fileBase = `${label}_${account.slice(2, 10)}`; + const p = await writeEthJsonV3Keystore(options.outDir, fileBase, options.password, account); + return { path: p, password: options.password }; + } + return account; +} + +/** Encrypt a plaintext funding-account key to a JSON V3 file, replacing it with a { path, password } reference. */ +export async function encryptFundingAccountToFile( + account: EthAccount, + options: { outDir: string; password: string }, +): Promise { + return (await maybeEncryptEthAccount(account, 'funding', options)) as EthAccount; +} + /** Replace plaintext ETH keys in validators with { path, password } pointing to JSON V3 files. */ export async function writeEthJsonV3ToFile( validators: ValidatorKeyStore[], diff --git a/yarn-project/cli/src/cmds/validator_keys/utils.ts b/yarn-project/cli/src/cmds/validator_keys/utils.ts index 8b8dca71f191..1e83594f9b6a 100644 --- a/yarn-project/cli/src/cmds/validator_keys/utils.ts +++ b/yarn-project/cli/src/cmds/validator_keys/utils.ts @@ -1,4 +1,4 @@ -import type { EthAddress } from '@aztec/foundation/eth-address'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { ethPrivateKeySchema } from '@aztec/node-keystore/schemas'; import type { EthPrivateKey } from '@aztec/node-keystore/types'; @@ -79,3 +79,46 @@ export function validatePublisherOptions(options: { publishers?: string[]; publi options.publishers = normalizedKeys as EthPrivateKey[]; } } + +/** + * Validates and normalizes the `--funding-account` option in place. The value may be a private key + * (used as a local signer) or an ETH address. An address needs a remote signer to sign funding txs: + * either `--remote-signer`, or a keystore that already defines one (pass `hasKeystoreRemoteSigner`), + * which a bare address inherits at runtime. + */ +export function validateFundingAccountOptions( + options: { fundingAccount?: string; remoteSigner?: string }, + hasKeystoreRemoteSigner = false, +) { + if (!options.fundingAccount) { + return; + } + + let value = options.fundingAccount.trim(); + if (!value.startsWith('0x')) { + value = '0x' + value; + } + + if (value.length === 66) { + try { + ethPrivateKeySchema.parse(value); + } catch (error) { + throw new Error(`Invalid funding account private key: ${error instanceof Error ? error.message : String(error)}`); + } + } else if (value.length === 42) { + try { + EthAddress.fromString(value); + } catch (error) { + throw new Error(`Invalid funding account address: ${error instanceof Error ? error.message : String(error)}`); + } + if (!options.remoteSigner && !hasKeystoreRemoteSigner) { + throw new Error( + '--funding-account as an address requires --remote-signer, or a keystore that already defines a remote signer', + ); + } + } else { + throw new Error('Invalid funding account: expected a 32-byte private key or a 20-byte address'); + } + + options.fundingAccount = value; +} diff --git a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts index 24abb2c06169..e647115f7597 100644 --- a/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts +++ b/yarn-project/cli/src/cmds/validator_keys/valkeys.test.ts @@ -13,6 +13,7 @@ import { mnemonicToAccount } from 'viem/accounts'; import { addValidatorKeys } from './add.js'; import { generateBlsKeypair } from './generate_bls_keypair.js'; import { newValidatorKeystore } from './new.js'; +import { setFundingAccount } from './set_funding_account.js'; import { buildValidatorEntries, computeBlsPublicKeyCompressed, @@ -24,7 +25,7 @@ import { writeEthJsonV3ToFile, writeKeystoreFile, } from './shared.js'; -import { validatePublisherOptions } from './utils.js'; +import { validateFundingAccountOptions, validatePublisherOptions } from './utils.js'; const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; @@ -402,6 +403,44 @@ describe('validator keys utilities', () => { }); }); + describe('validateFundingAccountOptions', () => { + const validPrivateKey = '0x' + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const validAddress = '0x' + '01'.repeat(20); + + it('accepts a private key and leaves it normalized', () => { + const options = { fundingAccount: validPrivateKey }; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + expect(options.fundingAccount).toBe(validPrivateKey); + }); + + it('adds a missing 0x prefix', () => { + const options = { fundingAccount: validPrivateKey.slice(2) }; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + expect(options.fundingAccount).toBe(validPrivateKey); + }); + + it('accepts an address when a remote signer is set', () => { + const options = { fundingAccount: validAddress, remoteSigner: 'http://localhost:9000' }; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + expect(options.fundingAccount).toBe(validAddress); + }); + + it('throws for an address without a remote signer', () => { + const options = { fundingAccount: validAddress }; + expect(() => validateFundingAccountOptions(options)).toThrow(/requires --remote-signer/); + }); + + it('throws for a malformed value', () => { + const options = { fundingAccount: '0x1234' }; + expect(() => validateFundingAccountOptions(options)).toThrow(/Invalid funding account/); + }); + + it('is a no-op when unset', () => { + const options = {}; + expect(() => validateFundingAccountOptions(options)).not.toThrow(); + }); + }); + describe('newValidatorKeystore', () => { it('creates a keystore file and logs a summary', async () => { const path = join(tmp, 'created.json'); @@ -946,6 +985,99 @@ describe('validator keys utilities', () => { expect(Array.isArray(validator.publisher)).toBe(true); expect(validator.publisher).toEqual([publisherKey1, publisherKey2]); }); + + it('writes a top-level funding account from a private key', async () => { + const path = join(tmp, 'with-funding-key.json'); + const fundingKey = '0x' + 'ab'.repeat(32); + await newValidatorKeystore( + { + dataDir: tmp, + file: 'with-funding-key.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: fundingKey, + feeRecipient: ('0x' + '11'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ); + const keystore: KeyStore = loadKeystoreFile(path); + expect(keystore.fundingAccount).toBe(fundingKey); + }); + + it('writes a remote-signer funding account from an address', async () => { + const path = join(tmp, 'with-funding-address.json'); + const fundingAddress = '0x' + '02'.repeat(20); + const remoteSigner = 'http://localhost:9000'; + await newValidatorKeystore( + { + dataDir: tmp, + file: 'with-funding-address.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: fundingAddress, + remoteSigner, + feeRecipient: ('0x' + '12'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ); + const keystore: KeyStore = loadKeystoreFile(path); + const funder = keystore.fundingAccount as any; + expect(funder.remoteSignerUrl).toBe(remoteSigner); + expect(funder.address.toString().toLowerCase()).toBe(fundingAddress); + }); + + it('rejects a funding-account address without a remote signer', async () => { + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'funding-address-no-signer.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: '0x' + '03'.repeat(20), + feeRecipient: ('0x' + '13'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ), + ).rejects.toThrow(/requires --remote-signer/); + }); + + it('rejects a malformed funding account', async () => { + await expect( + newValidatorKeystore( + { + dataDir: tmp, + file: 'funding-malformed.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: '0xdead', + feeRecipient: ('0x' + '14'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ), + ).rejects.toThrow(/Invalid funding account/); + }); + + it('encrypts a plaintext funding account when a password is provided', async () => { + const path = join(tmp, 'with-funding-encrypted.json'); + await newValidatorKeystore( + { + dataDir: tmp, + file: 'with-funding-encrypted.json', + count: 1, + mnemonic: TEST_MNEMONIC, + fundingAccount: '0x' + 'cd'.repeat(32), + password: 'funding-test-pw', + encryptedKeystoreDir: tmp, + feeRecipient: ('0x' + '15'.repeat(32)) as unknown as AztecAddress, + }, + s => s, + ); + const keystore: KeyStore = loadKeystoreFile(path); + const funder = keystore.fundingAccount as any; + expect(typeof funder.path).toBe('string'); + expect(existsSync(funder.path)).toBe(true); + }); }); describe('materialization helpers (invoked directly)', () => { @@ -1021,6 +1153,100 @@ describe('validator keys utilities', () => { }); }); + describe('setFundingAccount', () => { + const writeBaseKeystore = (path: string, extra: Record = {}) => { + const baseKeystore = { + schemaVersion: 1, + validators: [{ attester: '0x' + '0a'.repeat(32), feeRecipient: ('0x' + '06'.repeat(32)) as unknown as string }], + ...extra, + }; + writeFileSync(path, JSON.stringify(baseKeystore, null, 2), 'utf-8'); + }; + + it('sets a funding account on a keystore that has none', async () => { + const existing = join(tmp, 'set-funding.json'); + writeBaseKeystore(existing); + const fundingKey = '0x' + 'ab'.repeat(32); + + const logs: string[] = []; + await setFundingAccount(existing, fundingKey, {}, s => logs.push(s)); + + const updated: KeyStore = loadKeystoreFile(existing); + expect(updated.fundingAccount).toBe(fundingKey); + expect(logs.some(l => l.includes('Replacing existing funding account'))).toBe(false); + expect(logs.some(l => l.includes('Set funding account'))).toBe(true); + }); + + it('replaces an existing funding account and warns', async () => { + const existing = join(tmp, 'replace-funding.json'); + writeBaseKeystore(existing, { fundingAccount: '0x' + 'aa'.repeat(32) }); + const newFundingKey = '0x' + 'bb'.repeat(32); + + const logs: string[] = []; + await setFundingAccount(existing, newFundingKey, {}, s => logs.push(s)); + + const updated: KeyStore = loadKeystoreFile(existing); + expect(updated.fundingAccount).toBe(newFundingKey); + expect(logs.some(l => l.includes('Replacing existing funding account'))).toBe(true); + }); + + it('sets a remote-signer funding account from an address', async () => { + const existing = join(tmp, 'set-funding-address.json'); + writeBaseKeystore(existing); + const fundingAddress = '0x' + '02'.repeat(20); + const remoteSigner = 'http://localhost:9000'; + + await setFundingAccount(existing, fundingAddress, { remoteSigner }, s => s); + + const updated: KeyStore = loadKeystoreFile(existing); + const funder = updated.fundingAccount as any; + expect(funder.remoteSignerUrl).toBe(remoteSigner); + expect(funder.address.toString().toLowerCase()).toBe(fundingAddress); + }); + + it('inherits the keystore remote signer for an address when --remote-signer is omitted', async () => { + const existing = join(tmp, 'set-funding-inherit-signer.json'); + writeBaseKeystore(existing, { remoteSigner: 'http://localhost:9000' }); + const fundingAddress = '0x' + '02'.repeat(20); + + await setFundingAccount(existing, fundingAddress, {}, s => s); + + const updated: KeyStore = loadKeystoreFile(existing); + // Stored as a bare address (no inline remoteSignerUrl); resolved via the keystore-level signer at runtime. + const funder = updated.fundingAccount as any; + expect(funder.remoteSignerUrl).toBeUndefined(); + expect(funder.toString().toLowerCase()).toBe(fundingAddress); + }); + + it('rejects an address without a remote signer', async () => { + const existing = join(tmp, 'set-funding-no-signer.json'); + writeBaseKeystore(existing); + + await expect(setFundingAccount(existing, '0x' + '03'.repeat(20), {}, s => s)).rejects.toThrow( + /requires --remote-signer/, + ); + }); + + it('rejects a malformed funding account', async () => { + const existing = join(tmp, 'set-funding-malformed.json'); + writeBaseKeystore(existing); + + await expect(setFundingAccount(existing, '0xdead', {}, s => s)).rejects.toThrow(/Invalid funding account/); + }); + + it('encrypts a plaintext funding key when a password is provided', async () => { + const existing = join(tmp, 'set-funding-encrypted.json'); + writeBaseKeystore(existing); + + await setFundingAccount(existing, '0x' + 'cd'.repeat(32), { password: '', encryptedKeystoreDir: tmp }, s => s); + + const updated: KeyStore = loadKeystoreFile(existing); + const funder = updated.fundingAccount as any; + expect(typeof funder.path).toBe('string'); + expect(existsSync(funder.path)).toBe(true); + }); + }); + describe('generateBlsKeypair', () => { it('writes to file and logs a write message when out is provided', async () => { const out = join(tmp, 'bls.json'); 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/automine/delivery/interactive_handshake_responder.ts b/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts new file mode 100644 index 000000000000..e46d0fa2b4d5 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/interactive_handshake_responder.ts @@ -0,0 +1,125 @@ +import { AztecAddress, type CompleteAddress } from '@aztec/aztec.js/addresses'; +import { DomainSeparator } from '@aztec/constants'; +import { poseidon2HashWithSeparator } from '@aztec/foundation/crypto/poseidon'; +import { Schnorr, type SchnorrSignature } from '@aztec/foundation/crypto/schnorr'; +import { Fq, Fr } from '@aztec/foundation/curves/bn254'; +import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import type { CustomRequest } from '@aztec/pxe/config'; +import { + INTERACTIVE_HANDSHAKE_REQUEST_KIND, + STANDARD_HANDSHAKE_REGISTRY_ADDRESS, +} from '@aztec/standard-contracts/handshake-registry/constants'; +import { type PublicKeys, derivePublicKeyFromSecretKey } from '@aztec/stdlib/keys'; + +/** + * The decoded payload of the registry's interactive-handshake signature request. Note it never carries the sender, + * so the recipient authorizes the handshake without learning who initiated it. + */ +export type InteractiveHandshakeRequest = { + /** The account whose authorization is being requested. */ + recipient: AztecAddress; + chainId: Fr; + version: Fr; + /** The x-coordinate of the handshake's ephemeral public key (its y-coordinate is positive by construction). */ + ephPkX: Fr; +}; + +/** + * The recipient's signed authorization for an interactive handshake. Mirrors the registry contract's + * `RecipientSignature` struct field for field. + */ +export type RecipientSignature = { + /** The recipient's master public keys, bound in-circuit to the recipient's address via `partialAddress`. */ + publicKeys: PublicKeys; + partialAddress: Fr; + /** The x-coordinate of the recipient's master message-signing public key. */ + mspkX: Fr; + /** Whether that key's y-coordinate is positive, so the circuit can reconstruct the point from `mspkX`. */ + mspkYIsPositive: boolean; + /** The schnorr signature over the handshake message. */ + signature: SchnorrSignature; +}; + +/** + * Parses and validates the registry's interactive-handshake signature request. + * + * @throws If the request kind is not {@link INTERACTIVE_HANDSHAKE_REQUEST_KIND}, the issuing contract is not the + * standard HandshakeRegistry, or the payload does not have the expected shape. + */ +export function parseInteractiveHandshakeRequest(request: CustomRequest): InteractiveHandshakeRequest { + if (!request.kind.equals(INTERACTIVE_HANDSHAKE_REQUEST_KIND)) { + throw new Error(`Not an interactive-handshake signature request: unexpected kind ${request.kind}`); + } + if (!request.contractAddress.equals(STANDARD_HANDSHAKE_REGISTRY_ADDRESS)) { + throw new Error( + `Interactive-handshake signature request issued by ${request.contractAddress}, expected the standard HandshakeRegistry at ${STANDARD_HANDSHAKE_REGISTRY_ADDRESS}`, + ); + } + if (request.payload.length !== 4) { + throw new Error(`Interactive-handshake signature request payload has ${request.payload.length} fields, expected 4`); + } + + const [recipient, chainId, version, ephPkX] = request.payload; + return { recipient: new AztecAddress(recipient), chainId, version, ephPkX }; +} + +/** + * Produces the recipient's signed authorization for an interactive handshake, signing with the master + * message-signing secret key. + */ +export async function signInteractiveHandshake( + request: InteractiveHandshakeRequest, + completeAddress: CompleteAddress, + masterMessageSigningSecretKey: GrumpkinScalar, +): Promise { + const mspk = await derivePublicKeyFromSecretKey(masterMessageSigningSecretKey); + const [mspkX, mspkYIsPositive] = mspk.toXAndSign(); + + const message = await computeInteractiveHandshakeSignatureMessage({ + chainId: request.chainId, + version: request.version, + registry: STANDARD_HANDSHAKE_REGISTRY_ADDRESS, + ephPkX: request.ephPkX, + }); + const signature = await new Schnorr().constructSignature(message, masterMessageSigningSecretKey); + + return { + publicKeys: completeAddress.publicKeys, + partialAddress: completeAddress.partialAddress, + mspkX, + mspkYIsPositive, + signature, + }; +} + +/** Serializes a {@link RecipientSignature} to the field layout the registry's in-circuit deserialization expects. */ +export function recipientSignatureToFields(recipientSignature: RecipientSignature): Fr[] { + const s = Fq.fromBuffer(recipientSignature.signature.s); + const e = Fq.fromBuffer(recipientSignature.signature.e); + return [ + ...recipientSignature.publicKeys.toFields(), + recipientSignature.partialAddress, + recipientSignature.mspkX, + new Fr(recipientSignature.mspkYIsPositive), + s.lo, + s.hi, + e.lo, + e.hi, + ]; +} + +/** + * The message an interactive-handshake authorization signs: the handshake's ephemeral key and chain context under + * `DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE`, exactly as the registry recomputes it in-circuit. + */ +function computeInteractiveHandshakeSignatureMessage(args: { + chainId: Fr; + version: Fr; + registry: AztecAddress; + ephPkX: Fr; +}): Promise { + return poseidon2HashWithSeparator( + [args.chainId, args.version, args.registry, args.ephPkX], + DomainSeparator.INTERACTIVE_HANDSHAKE_SIGNATURE, + ); +} diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts index cdab97abcf31..0e0dfaeb5039 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -1,5 +1,15 @@ +import type { InitialAccountData } from '@aztec/accounts/testing'; +import type { CompleteAddress } from '@aztec/aztec.js/addresses'; import { Point } from '@aztec/foundation/curves/grumpkin'; +import type { ResolveCustomRequest } from '@aztec/pxe/config'; +import { deriveKeys } from '@aztec/stdlib/keys'; +import type { TestWallet } from '../../test-wallet/test_wallet.js'; +import { + parseInteractiveHandshakeRequest, + recipientSignatureToFields, + signInteractiveHandshake, +} from './interactive_handshake_responder.js'; import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; describe('onchain delivery', () => { @@ -34,8 +44,8 @@ describe('onchain delivery', () => { }, }); - // With the recipient registering the sender, - // the recipient PXE reconstructs the address-derived tag and discovers the delivery. + // With the recipient registering the sender, the recipient PXE reconstructs the address-derived tag + // and discovers the delivery. buildMessageDeliveryTest({ strategy: 'address-derived', mode: 'unconstrained', @@ -44,4 +54,47 @@ describe('onchain delivery', () => { await recipientWallet.registerSender(senderAddress); }, }); + + buildMessageDeliveryTest({ + strategy: 'interactive handshake', + mode: 'constrained', + senderHook: () => Promise.resolve({ type: 'interactive-handshake' }), + customRequestResponder: interactiveHandshakeResponder, + }); + + buildMessageDeliveryTest({ + strategy: 'interactive handshake', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'interactive-handshake' }), + customRequestResponder: interactiveHandshakeResponder, + }); + + // Serves the registry's interactive-handshake signature request for the recipient: registers the handshake on the + // recipient PXE, then answers with the signed response. + function interactiveHandshakeResponder( + recipientWallet: TestWallet, + recipientAccount: InitialAccountData, + recipientCompleteAddress: CompleteAddress, + ): ResolveCustomRequest { + return async request => { + const parsed = parseInteractiveHandshakeRequest(request); + + // Register before signing. + await recipientWallet.registerTaggingSecretSource({ + kind: 'handshake', + recipient: parsed.recipient, + ephPk: parsed.ephPkX, + }); + + // The master message-signing secret key is deliberately never held by PXE or the key store; the wallet + // derives it client-side from the account secret. + const { masterMessageSigningSecretKey } = await deriveKeys(recipientAccount.secret); + const recipientSignature = await signInteractiveHandshake( + parsed, + recipientCompleteAddress, + masterMessageSigningSecretKey, + ); + return recipientSignatureToFields(recipientSignature); + }; + } }); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts index d10ae9196aea..6e00fca5969e 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -1,11 +1,12 @@ import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; import type { FieldLike } from '@aztec/aztec.js/abi'; import { NO_FROM } from '@aztec/aztec.js/account'; -import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { AztecAddress, CompleteAddress } from '@aztec/aztec.js/addresses'; import type { AztecNode } from '@aztec/aztec.js/node'; +import type { AccountManager } from '@aztec/aztec.js/wallet'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; -import type { PXECreationOptions } from '@aztec/pxe/server'; +import type { CustomRequest, ResolveCustomRequest, ResolveTaggingSecretStrategy } from '@aztec/pxe/config'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; import { jest } from '@jest/globals'; @@ -14,9 +15,13 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js'; import { TestWallet } from '../../test-wallet/test_wallet.js'; -// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options -// rather than importing the hook type, which `@aztec/pxe/server` does not re-export. -export type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; +// Builds the hook serving custom requests issued during the sender's simulations. Installed on the sender PXE at +// creation but built only once the recipient exists, since serving typically needs the recipient's wallet and keys. +export type CustomRequestResponder = ( + recipient: TestWallet, + recipientAccount: InitialAccountData, + recipientCompleteAddress: CompleteAddress, +) => ResolveCustomRequest; export type Mode = 'constrained' | 'unconstrained'; @@ -40,19 +45,22 @@ export function buildMessageDeliveryTest(opts: { strategy: string; mode: DeliveryMode; // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). - senderHook: SenderHook; + senderHook: ResolveTaggingSecretStrategy; // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. recipientRegistration?: ( recipient: TestWallet, recipientAddress: AztecAddress, sender: AztecAddress, ) => Promise; + // Serves the custom requests issued during the sender's simulations (e.g. the registry's interactive-handshake + // signature request). + customRequestResponder?: CustomRequestResponder; // Extra `it()`s to register in this cell's suite, e.g. assertions against state a custom `senderHook` recorded. // Called inside the same `describe`, after the two baseline assertions below, so it shares their `beforeAll` // instead of depending on Jest's cross-`describe` execution order. additionalTests?: () => void; }) { - const { strategy, mode, senderHook, recipientRegistration, additionalTests } = opts; + const { strategy, mode, senderHook, recipientRegistration, customRequestResponder, additionalTests } = opts; const description = `${strategy} x ${formatMode(mode)}`; describe(description, () => { @@ -84,11 +92,14 @@ export function buildMessageDeliveryTest(opts: { ? contractSender.methods.emit_note(recipient, value) : contractSender.methods.emit_note_unconstrained(recipient, value); + let additionallyFundedAccounts: InitialAccountData[]; + let recipientAccount: AccountManager | undefined; + let customRequestCount = 0; + beforeAll(async () => { // The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded // at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state // from other cells. - let additionallyFundedAccounts: InitialAccountData[]; ({ aztecNode, additionallyFundedAccounts, @@ -98,7 +109,26 @@ export function buildMessageDeliveryTest(opts: { } = await setup(1, { ...AUTOMINE_E2E_OPTS, additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: senderHook, + resolveCustomRequest: async (request: CustomRequest) => { + if (!customRequestResponder) { + throw new Error('A custom request arrived but this test cell has no customRequestResponder configured'); + } + if (!recipientAccount) { + throw new Error('A custom request arrived before the recipient wallet was created'); + } + customRequestCount++; + const respond = customRequestResponder( + walletRecipient, + additionallyFundedAccounts[0], + await recipientAccount.getCompleteAddress(), + ); + return respond(request); + }, + }, + }, })); ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( @@ -108,7 +138,7 @@ export function buildMessageDeliveryTest(opts: { undefined, 'pxe-recipient', )); - const recipientAccount = await walletRecipient.createSchnorrAccount( + recipientAccount = await walletRecipient.createSchnorrAccount( additionallyFundedAccounts[0].secret, additionallyFundedAccounts[0].salt, additionallyFundedAccounts[0].signingKey, @@ -172,6 +202,12 @@ export function buildMessageDeliveryTest(opts: { expect(readNotes).toEqual(noteValues); }); + if (customRequestResponder) { + it('the custom request hook fires exactly once, on the send that bootstraps the tagging secret', () => { + expect(customRequestCount).toBe(1); + }); + } + additionalTests?.(); }); } 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 638b2b8fd623..77de58f7796b 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; @@ -229,6 +230,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/fixtures.ts b/yarn-project/end-to-end/src/fixtures/fixtures.ts index 586c6d3ad474..217bc1ccf645 100644 --- a/yarn-project/end-to-end/src/fixtures/fixtures.ts +++ b/yarn-project/end-to-end/src/fixtures/fixtures.ts @@ -1,4 +1,5 @@ import type { AztecNode } from '@aztec/aztec.js/node'; +import { TEST_FEE_PADDING } from '@aztec/aztec/testing'; import type { GasFees } from '@aztec/stdlib/gas'; export const METRICS_PORT = 4318; @@ -17,9 +18,10 @@ export const LARGE_MIN_FEE_PADDING = 15; * price modifier evolves faster across the build/publish gap, so client-set maxFeesPerGas (sized * for the default 5x padding) was getting bumped past by the time the tx mined a few slots later. * Observed worst case in CI: fee evolved ~20x between PXE snapshot and inclusion, exceeding even - * LARGE_MIN_FEE_PADDING (15x). + * LARGE_MIN_FEE_PADDING (15x). Same multiplier and same class of problem as the published + * {@link TEST_FEE_PADDING}, re-exported under a name that captures the pipelining rationale. */ -export const PIPELINED_FEE_PADDING = 30; +export const PIPELINED_FEE_PADDING = TEST_FEE_PADDING; /** * Setup option preset that opts a test into proposer pipelining. Use with `setup()`: @@ -77,7 +79,7 @@ export const AUTOMINE_E2E_OPTS = { minTxsPerBlock: 0, aztecSlotDuration: 12, ethereumSlotDuration: 4, - walletMinFeePadding: PIPELINED_FEE_PADDING, + walletMinFeePadding: TEST_FEE_PADDING, } as const; /** Returns worst-case predicted min fees with padding applied, mirroring the BaseWallet pattern. */ diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 3dc87c409dd2..797fbac1c83a 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -37,7 +37,7 @@ import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vk-tree'; import type { P2PClientDeps } from '@aztec/p2p'; import { MockGossipSubNetwork, getMockPubSubP2PServiceFactory } from '@aztec/p2p/test-helpers'; import { protocolContractsHash } from '@aztec/protocol-contracts'; -import type { ProverNodeConfig } from '@aztec/prover-node'; +import type { ProverNodeConfig, ProverNodeDeps } from '@aztec/prover-node'; import { type PXEConfig, type PXECreationOptions, getPXEConfig } from '@aztec/pxe/server'; import type { SequencerClient } from '@aztec/sequencer-client'; import { AuthRegistryArtifact, getStandardAuthRegistry } from '@aztec/standard-contracts/auth-registry'; @@ -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'); @@ -849,6 +872,7 @@ export function createAndSyncProverNode( telemetry?: TelemetryClient; dateProvider: DateProvider; p2pClientDeps?: P2PClientDeps; + proverNodeDeps?: Partial; }, options: { genesis?: GenesisData; dontStart?: boolean }, ): Promise<{ proverNode: AztecNodeService }> { 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..6588864fcbd0 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', () => { @@ -58,9 +58,10 @@ describe('multi-node/block-production/proof_boundary', () => { const minTxsPerBlock = validatorOverrides.minTxsPerBlock ?? 0; const maxTxsPerBlock = validatorOverrides.maxTxsPerBlock ?? 1; - logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`); + logger.warn(`Initial setup complete. Creating ${NODE_COUNT} validator nodes with sequencers paused.`); + // Ensure every validator can handle the first proposal before any sequencer begins its polling loop. nodes = await asyncMap(validators, ({ privateKey }) => - test.createValidatorNode([privateKey], { minTxsPerBlock, maxTxsPerBlock }), + test.createValidatorNode([privateKey], { dontStartSequencer: true, minTxsPerBlock, maxTxsPerBlock }), ); proverNode = await test.createProverNode({ @@ -73,6 +74,11 @@ describe('multi-node/block-production/proof_boundary', () => { logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); }; + const startSequencers = async () => { + await test.startSequencers(nodes); + logger.warn(`Started ${NODE_COUNT} validator sequencers.`); + }; + const collectSequencerEvents = (sequencers: SequencerClient[]) => { const published: PublishedEvent[] = []; const preparing: PreparingEvent[] = []; @@ -216,6 +222,7 @@ describe('multi-node/block-production/proof_boundary', () => { const sequencers = nodes.map(node => node.getSequencer()!); const events = collectSequencerEvents(sequencers); + await startSequencers(); const { boundarySlot, boundaryTs } = await computeBoundarySlot(); @@ -264,6 +271,7 @@ describe('multi-node/block-production/proof_boundary', () => { const sequencers = nodes.map(node => node.getSequencer()!); const events = collectSequencerEvents(sequencers); + await startSequencers(); const { boundarySlot, boundaryTs } = await computeBoundarySlot(); @@ -298,6 +306,7 @@ describe('multi-node/block-production/proof_boundary', () => { const sequencers = nodes.map(node => node.getSequencer()!); const events = collectSequencerEvents(sequencers); + await startSequencers(); const { boundarySlot, boundaryEpoch } = await computeBoundarySlot(); @@ -335,6 +344,7 @@ describe('multi-node/block-production/proof_boundary', () => { const sequencers = nodes.map(node => node.getSequencer()!); const events = collectSequencerEvents(sequencers); + await startSequencers(); const { boundarySlot } = await computeBoundarySlot(); const slotN = SlotNumber(Number(boundarySlot) - 1); @@ -374,6 +384,7 @@ describe('multi-node/block-production/proof_boundary', () => { const sequencers = nodes.map(node => node.getSequencer()!); const events = collectSequencerEvents(sequencers); + await startSequencers(); const { boundarySlot } = await computeBoundarySlot(); const slotN = SlotNumber(Number(boundarySlot) - 1); 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 b7085a1773ca..55d2e6372c5a 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..2ef57fd67fc3 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_restart.parallel` (a clean prover-node shutdown leaves its in-flight broker jobs untouched, and a restart against the same shared broker resumes proving from them rather than aborting and re-proving — one case gates the top tree and withholds the checkpoint-root proofs so those top-tree jobs are the in-flight, revived ones, the other starves agents from the start so real transaction base-rollup proofs are the in-flight, revived jobs). | +| `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..2d3f2861f274 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. * @@ -338,22 +338,20 @@ describe('single-node/proving/optimistic', () => { timeout: 30, }); - // Verify the prover-node observes the prune. `markPruned()` fires reactively when - // the L2BlockStream emits the prune; the SlotWatcher then reaps the (now pruned) - // prover on its next tick (default 1s), so checking strictly for `isPruned()` would - // race against the reap. Identify the original by `(checkpointNumber, slot)` — - // checkpoint numbers refill sequentially after a reorg, so the replacement reuses - // the same number but lives at a different slot. Accept either state for the - // original: still in the store and pruned, or already reaped. + // Verify the prover-node observes the prune. The prune reactively cancels and removes the + // orphaned prover from the store when the L2BlockStream emits it, so the original should drop + // out of the store (or, if observed mid-race, be cancelled). Identify the original by + // `(checkpointNumber, slot)` — checkpoint numbers refill sequentially after a reorg, so the + // replacement reuses the same number but lives at a different slot. await retryUntil( () => { const prover = proverNode .getCheckpointStore() .listAll() .find(p => p.checkpoint.number === checkpointBeforeReorg && p.slotNumber === originalSlot); - return Promise.resolve(!prover || prover.isPruned()); + return Promise.resolve(!prover || prover.isCancelled()); }, - `prover marks original checkpoint ${checkpointBeforeReorg} (slot ${originalSlot}) as pruned (or reaps it)`, + `prover cancels and removes original checkpoint ${checkpointBeforeReorg} (slot ${originalSlot})`, 30, 0.2, ); @@ -383,7 +381,7 @@ describe('single-node/proving/optimistic', () => { proverNode .getCheckpointStore() .listAll() - .some(p => p.checkpoint.number === replacementCheckpoint && !p.isPruned()), + .some(p => p.checkpoint.number === replacementCheckpoint), ), `prover re-creates sub-tree for replacement checkpoint ${replacementCheckpoint}`, 30, @@ -875,7 +873,7 @@ describe('single-node/proving/optimistic', () => { // The session manager constructs a full session over the canonical content for the // anchored epoch when it completes, then proves it; the store retains the provers // until expiry. - const epochCheckpointsInStore = await proverNode.getCheckpointStore().listCanonicalForEpoch(epoch); + const epochCheckpointsInStore = await proverNode.getCheckpointStore().listForEpoch(epoch); const storedNumbers = new Set(epochCheckpointsInStore.map(p => p.checkpoint.number)); for (const n of preSpawnCheckpointNumbers) { expect(storedNumbers.has(n)).toBe(true); diff --git a/yarn-project/end-to-end/src/single-node/proving/prover_restart.parallel.test.ts b/yarn-project/end-to-end/src/single-node/proving/prover_restart.parallel.test.ts new file mode 100644 index 000000000000..319ffee9e94f --- /dev/null +++ b/yarn-project/end-to-end/src/single-node/proving/prover_restart.parallel.test.ts @@ -0,0 +1,424 @@ +import type { Logger } from '@aztec/aztec.js/log'; +import type { RollupContract } from '@aztec/ethereum/contracts'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { retryUntil } from '@aztec/foundation/retry'; +import { type ProvingBroker, createAndStartProvingBroker } from '@aztec/prover-client/broker'; +import type { TestProverNode } from '@aztec/prover-node/test'; +import { EthAddress } from '@aztec/stdlib/block'; +import { getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; +import type { + AztecNode, + GetProvingJobResponse, + ProofUri, + ProvingJob, + ProvingJobBroker, + ProvingJobFilter, + ProvingJobId, + ProvingJobStatus, +} from '@aztec/stdlib/interfaces/server'; +import { ProvingRequestType } from '@aztec/stdlib/proofs'; +import { getTelemetryClient } from '@aztec/telemetry-client'; + +import { expect, jest } from '@jest/globals'; + +import type { EndToEndContext } from '../../fixtures/utils.js'; +import { proveInteraction } from '../../test-wallet/utils.js'; +import { NO_REORG_SUBMISSION_EPOCHS, setupWithProver } from '../setup.js'; +import { FAST_REORG_TIMING, SingleNodeTestContext } from '../single_node_test_context.js'; + +jest.setTimeout(1000 * 60 * 20); + +const ALL_PROVING_TYPES = Object.values(ProvingRequestType).filter( + (t): t is ProvingRequestType => typeof t === 'number', +); + +const isParity = (type: ProvingRequestType) => + type === ProvingRequestType.PARITY_BASE || type === ProvingRequestType.PARITY_ROOT; + +const isTxBaseRollup = (type: ProvingRequestType) => + type === ProvingRequestType.PRIVATE_TX_BASE_ROLLUP || type === ProvingRequestType.PUBLIC_TX_BASE_ROLLUP; + +// Checkpoint-root proofs are enqueued by the top-tree orchestrator (unlike parity, which is a +// block/sub-tree proof). A single-block checkpoint uses the SINGLE_BLOCK variant. Both are the +// "non-parity top-tree" jobs we withhold from agents to catch them in flight. +const CHECKPOINT_ROOT_TYPES = [ + ProvingRequestType.CHECKPOINT_ROOT_ROLLUP, + ProvingRequestType.CHECKPOINT_ROOT_SINGLE_BLOCK_ROLLUP, +]; +const isCheckpointRoot = (type: ProvingRequestType) => CHECKPOINT_ROOT_TYPES.includes(type); + +/** + * A thin proxy over a real {@link ProvingBroker} that (a) records every job enqueue and cancel so the + * test can assert what the prover node did, and (b) can starve agents on demand so proving jobs pile + * up unproven at the broker. It deliberately has no `stop()` method, so the prover node's shutdown + * (`tryStop` on its job producer) does not stop the underlying broker — the broker outlives the node + * and carries the in-flight jobs across a restart, exactly like a production external broker service. + */ +class RecordingBrokerProxy implements ProvingJobBroker { + /** When true, agents get no work (getProvingJob returns undefined) and the piggybacked next job on a report is withheld. */ + public agentsPaused = false; + /** + * Job types withheld from agents: their dependencies still prove, but these jobs are never handed + * out, so they pile up `in-queue` — letting the test catch a specific layer (e.g. checkpoint-root + * proofs) in flight without stalling everything else. + */ + public captureTypes = new Set(); + /** Every job passed to enqueueProvingJob, with the status the broker returned at the start of that call. */ + public readonly enqueues: { job: ProvingJob; returnedStatus: ProvingJobStatus['status'] }[] = []; + /** Every job id passed to cancelProvingJob. On a clean shutdown this must stay empty. */ + public readonly cancels: ProvingJobId[] = []; + + constructor(private readonly inner: ProvingBroker) {} + + // Narrow the agent's allow-list to exclude the captured types, so the broker never hands those jobs + // to an agent (via getProvingJob or the piggybacked next-job on a report). + private withCapture(filter?: ProvingJobFilter): ProvingJobFilter | undefined { + if (this.captureTypes.size === 0) { + return filter; + } + const base = filter?.allowList?.length ? filter.allowList : ALL_PROVING_TYPES; + return { allowList: base.filter(t => !this.captureTypes.has(t)) }; + } + + async enqueueProvingJob(job: ProvingJob): Promise { + const status = await this.inner.enqueueProvingJob(job); + this.enqueues.push({ job, returnedStatus: status.status }); + return status; + } + + cancelProvingJob(id: ProvingJobId): Promise { + this.cancels.push(id); + return this.inner.cancelProvingJob(id); + } + + getProvingJobStatus(id: ProvingJobId): Promise { + return this.inner.getProvingJobStatus(id); + } + + getCompletedJobs(ids: ProvingJobId[]): Promise { + return this.inner.getCompletedJobs(ids); + } + + getProvingJob(filter?: ProvingJobFilter): Promise { + return this.agentsPaused ? Promise.resolve(undefined) : this.inner.getProvingJob(this.withCapture(filter)); + } + + async reportProvingJobSuccess( + id: ProvingJobId, + result: ProofUri, + filter?: ProvingJobFilter, + ): Promise { + // Always settle the reported job (its result must be cached for reuse), but while paused withhold + // the next job the broker hands back so no new work starts. + const next = await this.inner.reportProvingJobSuccess(id, result, this.withCapture(filter)); + return this.agentsPaused ? undefined : next; + } + + async reportProvingJobError( + id: ProvingJobId, + err: string, + retry?: boolean, + filter?: ProvingJobFilter, + ): Promise { + const next = await this.inner.reportProvingJobError(id, err, retry, this.withCapture(filter)); + return this.agentsPaused ? undefined : next; + } + + reportProvingJobProgress( + id: ProvingJobId, + startedAt: number, + filter?: ProvingJobFilter, + ): Promise { + return this.inner.reportProvingJobProgress(id, startedAt, filter); + } +} + +/** + * E2E tests for a clean prover-node restart with jobs in flight at a shared broker. + * + * A clean prover-node shutdown must NOT abort its in-flight broker jobs — sessions are cancelled with + * `abortJobs: false` and checkpoint sub-trees with `cancelJobsOnStop: false` — so a restarted node + * re-requests them and the broker returns the existing jobs rather than a fresh `not-found`. The + * end-state alone ("epoch proven after restart") does not prove this: aborted jobs are revivable at + * the broker, so proving would recover even if the jobs were wrongly aborted. The discriminating + * assertions are that the shutdown issued zero `cancelProvingJob` calls and that the in-flight jobs + * remain `in-queue` (not `aborted`) across it. + * + * The broker is a test-owned object shared across both prover-node incarnations (production's external + * broker topology), so the in-flight jobs survive the restart in memory. + * + * Two scenarios, covering different layers of the proof tree in flight at shutdown: + * - top-tree checkpoint-root proofs: gate the top tree, then withhold only the checkpoint-root jobs + * from agents so everything below proves and the checkpoint roots sit in flight. These are enqueued + * by the top-tree orchestrator, so this exercises the `abortJobs: false` top-tree cancel path. + * - sub-tree transaction proofs: starve agents from the start so the transaction base rollups sit in + * flight, preserved by the checkpoint sub-tree's `cancelJobsOnStop: false`. + */ +describe('single-node/proving/prover_restart', () => { + let test: SingleNodeTestContext; + let context: EndToEndContext; + let node: AztecNode; + let rollup: RollupContract; + let logger: Logger; + let L2_SLOT_DURATION_IN_S: number; + + let realBroker: ProvingBroker; + let broker: RecordingBrokerProxy; + + const PINNED_PROVER_ID = EthAddress.fromNumber(1); + + // A shared in-memory broker (no dataDirectory) that outlives each prover node. + const createSharedBroker = async () => { + realBroker = await createAndStartProvingBroker( + { ...context.config, dataDirectory: undefined }, + getTelemetryClient(), + ); + broker = new RecordingBrokerProxy(realBroker); + }; + + afterEach(async () => { + await test?.teardown(); + await realBroker?.stop(); + }); + + describe('in-flight checkpoint-root proofs', () => { + beforeEach(async () => { + test = await setupWithProver({ + ...FAST_REORG_TIMING, + // We own prover-node creation so we can inject the shared broker and drive the stop/restart. + startProverNode: false, + maxSpeedUpAttempts: 0, + cancelTxOnTimeout: false, + minTxsPerBlock: 0, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, + // Recover promptly from any job left in-progress by a withheld piggyback, and never fail a job + // for retrying while agents are paused. + proverBrokerJobTimeoutMs: 2_000, + proverBrokerPollIntervalMs: 500, + proverBrokerJobMaxRetries: 1_000, + }); + ({ rollup, logger, context } = test); + ({ L2_SLOT_DURATION_IN_S } = test); + node = context.aztecNode; + await createSharedBroker(); + }); + + it('preserves and revives in-flight checkpoint-root proofs across a clean prover-node restart', async () => { + // Prover node #1, wired to the shared broker and a pinned prover id (so a restart re-requests the + // exact same content-addressed job ids). + const node1 = await test.createProverNode({ proverNodeDeps: { broker }, proverId: PINNED_PROVER_ID }); + const proverNode1 = node1.getProverNode() as TestProverNode; + + // Gate top-tree proving of the first full session so it blocks until we release it, giving us a + // deterministic point at which the top tree begins enqueuing its jobs. + const { promise: provingGate, resolve: releaseProvingGate } = promiseWithResolvers(); + let gatedSession: ReturnType[number] | undefined; + proverNode1.setSessionHooks({ + beforeTopTreeProve: async () => { + // EpochSession flips to `awaiting-root` before awaiting this hook, so the gating session is + // the live full session in that state. First one to arrive is the one we gate; later ones + // sail through once the gate is released. + const session = proverNode1.sessionManager + .allSessions() + .find(s => s.getKind() === 'full' && s.getState() === 'awaiting-root'); + if (!session) { + return; + } + gatedSession ??= session; + logger.warn(`Top-tree proving gated for epoch ${session.getEpochNumber()} — waiting for test to release`); + await provingGate; + logger.warn(`Proving gate released for epoch ${session.getEpochNumber()}`); + }, + }); + + // Wait for a full session to complete its checkpoints and block at the top-tree gate. + const inFlightSession = await retryUntil( + () => Promise.resolve(gatedSession), + 'full session blocks at the top-tree proving gate', + L2_SLOT_DURATION_IN_S * 12, + 0.5, + ); + const gatedEpoch = inFlightSession.getEpochNumber(); + const checkpoints = inFlightSession.getCheckpoints(); + const epochEndCheckpoint = checkpoints[checkpoints.length - 1].checkpoint.number; + logger.info(`Epoch ${gatedEpoch} is gated at top-tree proving (ends at checkpoint ${epochEndCheckpoint})`); + + // Stop block production so the system goes quiescent (no new sub-tree work), then withhold the + // checkpoint-root proofs from agents. Everything below (tx/block/parity sub-tree work) still + // proves, so the top tree reaches and enqueues its checkpoint-root jobs — which then sit + // `in-queue`, leaving the top tree mid-proof at shutdown. This is the fix's real target: a + // top-tree job (not a leaf parity job) in flight. + await context.aztecNodeAdmin!.setConfig({ skipPublishingCheckpointsPercent: 100 }); + broker.captureTypes = new Set(CHECKPOINT_ROOT_TYPES); + releaseProvingGate(); + + // Wait until at least one checkpoint-root job for the gated epoch is pending at the broker. + const pendingCheckpointRootIds = await retryUntil( + async () => { + const candidates = broker.enqueues + .filter(e => e.job.epochNumber === gatedEpoch && isCheckpointRoot(e.job.type)) + .map(e => e.job.id); + const inQueue: ProvingJobId[] = []; + for (const id of new Set(candidates)) { + if ((await broker.getProvingJobStatus(id)).status === 'in-queue') { + inQueue.push(id); + } + } + return inQueue.length > 0 ? inQueue : undefined; + }, + 'checkpoint-root jobs are enqueued and pending at the broker', + 60, + 0.5, + ); + logger.info( + `${pendingCheckpointRootIds.length} checkpoint-root job(s) pending at the broker for epoch ${gatedEpoch}`, + ); + + // Clean shutdown: this drives SessionManager.stop() -> session.cancel({ abortJobs: false }) -> + // TopTreeJob.cancel(false), which must NOT abort the in-flight checkpoint-root jobs. + const cancelsBeforeStop = broker.cancels.length; + await node1.stop(); + + // The fix under test: a clean shutdown leaves the in-flight jobs untouched. Pre-fix, the shutdown + // would abort them — a non-empty `cancels` and an `aborted` status. + expect(broker.cancels.length).toBe(cancelsBeforeStop); + for (const id of pendingCheckpointRootIds) { + expect((await broker.getProvingJobStatus(id)).status).toBe('in-queue'); + } + logger.info('Clean shutdown preserved the in-flight checkpoint-root jobs at the broker'); + + // Restart: a fresh prover node against the SAME broker and the same prover id. It resyncs from L1 + // and re-drives the epoch; re-requesting the preserved jobs reuses them rather than re-proving. + const enqueuesBeforeRestart = broker.enqueues.length; + const node2 = await test.createProverNode({ proverNodeDeps: { broker }, proverId: PINNED_PROVER_ID }); + expect((node2.getProverNode() as TestProverNode).getProverId()).toEqual(PINNED_PROVER_ID); + broker.captureTypes = new Set(); + + // Proving resumes automatically and the epoch lands on L1. + await test.waitUntilProvenCheckpointNumber(epochEndCheckpoint, 240); + expect(await rollup.getProvenCheckpointNumber()).toBeGreaterThanOrEqual(epochEndCheckpoint); + logger.info(`Epoch ${gatedEpoch} proven on L1 up to checkpoint ${epochEndCheckpoint} after restart`); + + // Reuse: the checkpoint-root proofs that were pending before the stop are re-requested and reused + // (returned with an existing status, never a fresh `not-found`, and never `aborted`). These are + // top-tree, non-parity proofs. + const reRequests = broker.enqueues.slice(enqueuesBeforeRestart); + const revivedCheckpointRoots = reRequests.filter( + e => pendingCheckpointRootIds.includes(e.job.id) && e.returnedStatus !== 'not-found', + ); + expect(revivedCheckpointRoots.length).toBeGreaterThan(0); + expect(revivedCheckpointRoots.every(e => isCheckpointRoot(e.job.type) && !isParity(e.job.type))).toBe(true); + expect(reRequests.every(e => e.returnedStatus !== 'aborted')).toBe(true); + }); + }); + + describe('in-flight transaction proofs', () => { + beforeEach(async () => { + test = await setupWithProver({ + ...FAST_REORG_TIMING, + numberOfAccounts: 1, + startProverNode: false, + maxSpeedUpAttempts: 0, + cancelTxOnTimeout: false, + minTxsPerBlock: 0, + aztecProofSubmissionEpochs: NO_REORG_SUBMISSION_EPOCHS, + // Keep the frozen epoch's jobs around while block production advances during the freeze. + proverBrokerMaxEpochsToKeepResultsFor: 10, + }); + ({ rollup, logger, context } = test); + node = context.aztecNode; + await createSharedBroker(); + }); + + it('preserves and revives in-flight checkpoint prover jobs across a clean prover-node restart', async () => { + // Starve agents before the prover node exists: it will enqueue jobs at the broker but prove none. + broker.agentsPaused = true; + + const node1 = await test.createProverNode({ proverNodeDeps: { broker }, proverId: PINNED_PROVER_ID }); + expect((node1.getProverNode() as TestProverNode).getProverId()).toEqual(PINNED_PROVER_ID); + + // Anchor on a fresh epoch, then land a couple of real txs in it so the broker gets actual + // transaction base-rollup jobs (not just the empty-block parity/root jobs). + await test.waitUntilNextEpochStarts(); + const contract = await test.registerTestContract(context.wallet); + const receipts = []; + for (let i = 0; i < 2; i++) { + const provenTx = await proveInteraction(context.wallet, contract.methods.emit_nullifier(new Fr(i + 1)), { + from: context.accounts[0], + }); + receipts.push(await provenTx.send()); + } + const txCheckpoint = (await node.getBlock(receipts[receipts.length - 1].blockNumber!))!.checkpointNumber; + const txCp = await retryUntil( + async () => (await node.getCheckpoints(txCheckpoint, 1))[0], + `archiver indexes checkpoint ${txCheckpoint}`, + 30, + 0.2, + ); + const txEpoch = getEpochAtSlot(txCp.header.slotNumber, test.constants); + logger.info(`Landed 2 txs in checkpoint ${txCheckpoint} (epoch ${txEpoch})`); + + // Wait until the transaction base-rollup jobs are enqueued and pending (in-queue) at the broker — + // these are the non-parity proofs we want to see revived. Agents are starved, so they cannot + // complete. + const pendingTxProofIds = await retryUntil( + async () => { + const ids = [...new Set(broker.enqueues.filter(e => isTxBaseRollup(e.job.type)).map(e => e.job.id))]; + const inQueue: ProvingJobId[] = []; + for (const id of ids) { + if ((await broker.getProvingJobStatus(id)).status === 'in-queue') { + inQueue.push(id); + } + } + return inQueue.length > 0 ? inQueue : undefined; + }, + 'transaction base-rollup jobs are enqueued and pending at the broker', + 60, + 0.5, + ); + logger.info( + `${pendingTxProofIds.length} transaction base-rollup job(s) pending at the broker for epoch ${txEpoch}`, + ); + + // Complete the epoch on L1 (so a restart can prove it), then stop producing so no further epochs + // pile up jobs while the prover is down. + await test.warpToEpochStart(txEpoch + 1); + await context.aztecNodeAdmin!.setConfig({ skipPublishingCheckpointsPercent: 100 }); + const epochEndCheckpoint = (await test.monitor.run(true)).checkpointNumber; + expect(epochEndCheckpoint).toBeGreaterThanOrEqual(txCheckpoint); + + // Clean shutdown: this must not abort any broker jobs. + const cancelsBeforeStop = broker.cancels.length; + const enqueuesBeforeRestart = broker.enqueues.length; + await node1.stop(); + + expect(broker.cancels.length).toBe(cancelsBeforeStop); + for (const id of pendingTxProofIds) { + expect((await broker.getProvingJobStatus(id)).status).toBe('in-queue'); + } + logger.info('Clean shutdown preserved the in-flight transaction proofs at the broker'); + + // Restart against the same broker with the same prover id and let agents run. + const node2 = await test.createProverNode({ proverNodeDeps: { broker }, proverId: PINNED_PROVER_ID }); + expect((node2.getProverNode() as TestProverNode).getProverId()).toEqual(PINNED_PROVER_ID); + broker.agentsPaused = false; + + // Proving resumes automatically and the epoch lands on L1. + await test.waitUntilProvenCheckpointNumber(epochEndCheckpoint, 240); + expect(await rollup.getProvenCheckpointNumber()).toBeGreaterThanOrEqual(epochEndCheckpoint); + logger.info(`Epoch ${txEpoch} proven on L1 up to checkpoint ${epochEndCheckpoint} after restart`); + + // The transaction proofs that were pending before the stop are re-requested and reused (returned + // with an existing status, never a fresh `not-found`, and never `aborted`). + const reRequests = broker.enqueues.slice(enqueuesBeforeRestart); + const revivedTxProofs = reRequests.filter( + e => pendingTxProofIds.includes(e.job.id) && e.returnedStatus !== 'not-found', + ); + expect(revivedTxProofs.length).toBeGreaterThan(0); + // The revived work is genuinely non-parity (the leaf parity jobs are not what we're asserting on). + expect(revivedTxProofs.every(e => !isParity(e.job.type))).toBe(true); + expect(reRequests.every(e => e.returnedStatus !== 'aborted')).toBe(true); + }); + }); +}); 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..2e73c4fee273 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 @@ -27,7 +27,7 @@ import { executeTimeout } from '@aztec/foundation/timer'; import { SpamContract } from '@aztec/noir-test-contracts.js/Spam'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; import { getMockPubSubP2PServiceFactory } from '@aztec/p2p/test-helpers'; -import type { ProverNodeConfig } from '@aztec/prover-node'; +import type { ProverNodeConfig, ProverNodeDeps } from '@aztec/prover-node'; import type { PXEConfig } from '@aztec/pxe/config'; import { type Sequencer, type SequencerClient, type SequencerEvents, SequencerState } from '@aztec/sequencer-client'; import { type BlockParameter, EthAddress } from '@aztec/stdlib/block'; @@ -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; /** @@ -358,8 +371,11 @@ export class SingleNodeTestContext { return accountManager.address; } - public async createProverNode(opts: { dontStart?: boolean } & Partial = {}) { + public async createProverNode( + opts: { dontStart?: boolean; proverNodeDeps?: Partial } & Partial = {}, + ) { this.logger.warn('Creating and syncing a simulated prover node...'); + const { proverNodeDeps, ...configOverrides } = opts; const proverNodePrivateKey = this.getNextPrivateKey(); const proverIndex = this.proverNodes.length + 1; const { mockGossipSubNetwork } = this.context; @@ -372,7 +388,7 @@ export class SingleNodeTestContext { p2pEnabled: this.context.config.p2pEnabled || mockGossipSubNetwork !== undefined, proverId: EthAddress.fromNumber(proverIndex), dontStart: opts.dontStart, - ...opts, + ...configOverrides, }, { dataDirectory: join(this.context.config.dataDirectory!, randomBytes(8).toString('hex')), @@ -385,6 +401,7 @@ export class SingleNodeTestContext { : undefined, rpcTxProviders: [this.context.aztecNode], }, + proverNodeDeps, }, { genesis: this.context.genesis, diff --git a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts index 862c2f864af2..0bf10aba4d2b 100644 --- a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts @@ -321,7 +321,7 @@ export class TestWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, skipTxValidation, skipFeeEnforcement, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const skipKernels = this.simulationMode !== 'full'; const useOverride = this.simulationMode === 'kernelless-override'; @@ -387,7 +387,7 @@ export class TestWallet extends BaseWallet { }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(exec, opts.from, fee); const txProvingResult = await this.pxe.proveTx(txRequest, { - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); return new ProvenTx( diff --git a/yarn-project/ethereum/scripts/anvil_kill_wrapper.sh b/yarn-project/ethereum/scripts/anvil_kill_wrapper.sh deleted file mode 100755 index 629750efda21..000000000000 --- a/yarn-project/ethereum/scripts/anvil_kill_wrapper.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash - -# Function to get the PPID in macOS -get_ppid_macos() { - ps -j $$ | awk 'NR==2 {print $3}' -} - -# Function to get the PPID in Linux -get_ppid_linux() { - awk '{print $4}' /proc/$$/stat -} - -# Function to check if a process is alive in macOS -is_process_alive_macos() { - ps -p $1 > /dev/null 2>&1 -} - -# Function to check if a process is alive in Linux -is_process_alive_linux() { - [ -d /proc/$1 ] -} - - -# Determine the operating system and call the appropriate function -if [[ "$OSTYPE" == "darwin"* ]]; then - PARENT_PID=$(get_ppid_macos) - check_process_alive() { is_process_alive_macos $1; } -elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - PARENT_PID=$(get_ppid_linux) - check_process_alive() { is_process_alive_linux $1; } -else - echo "Unsupported OS" - exit 1 -fi - -# echo "Parent PID: $PARENT_PID" - -# Start anvil in the background. -RAYON_NUM_THREADS=1 anvil $@ & -CHILD_PID=$! - -cleanup() { - kill $CHILD_PID -} - -trap cleanup EXIT - -# Continuously check if the parent process is still alive. -while check_process_alive $PARENT_PID; do - sleep 1 -done diff --git a/yarn-project/ethereum/src/deploy_aztec_l1_contracts.ts b/yarn-project/ethereum/src/deploy_aztec_l1_contracts.ts index 71e0189bc071..f62c005c0949 100644 --- a/yarn-project/ethereum/src/deploy_aztec_l1_contracts.ts +++ b/yarn-project/ethereum/src/deploy_aztec_l1_contracts.ts @@ -22,6 +22,7 @@ import { createExtendedL1Client } from './client.js'; import { type L1ContractsConfig, assertValidSlotDurations } from './config.js'; import { deployMulticall3 } from './contracts/multicall.js'; import { RollupContract } from './contracts/rollup.js'; +import { resolveFoundryBinary } from './foundry_binary.js'; import type { L1ContractAddresses } from './l1_contract_addresses.js'; import type { ExtendedViemWalletClient } from './types.js'; @@ -95,11 +96,16 @@ function runProcess( // Covers an edge where where we may have a cached BlobLib that is not meant for production. // Despite the profile apparently sometimes cached code remains (so says Lasse after his ignition-monorepo arc). -async function maybeForgeForceProductionBuild(l1ContractsPath: string, script: string, chainId: number) { +async function maybeForgeForceProductionBuild( + forgeBin: string, + l1ContractsPath: string, + script: string, + chainId: number, +) { if (chainId === mainnet.id) { logger.info(`Recompiling ${script} with production profile for mainnet deployment`); logger.info('This may take a minute but ensures production BlobLib is used.'); - await runProcess('forge', ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath); + await runProcess(forgeBin, ['build', script, '--force'], { FOUNDRY_PROFILE: 'production' }, l1ContractsPath); } } @@ -320,8 +326,9 @@ export async function deployAztecL1Contracts( // Use foundry-artifacts from l1-artifacts package const l1ContractsPath = prepareL1ContractsForDeployment(); + const forgeBin = resolveFoundryBinary('forge'); const FORGE_SCRIPT = 'script/deploy/DeployAztecL1Contracts.s.sol'; - await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId); + await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId); // Verify contracts on Etherscan when on mainnet/sepolia and ETHERSCAN_API_KEY is available. const isVerifiableChain = chainId === mainnet.id || chainId === sepolia.id; @@ -346,6 +353,8 @@ export async function deployAztecL1Contracts( ...(shouldVerify ? ['--verify'] : []), ]; const forgeEnv = { + // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH. + FORGE_BIN: forgeBin, // Env vars required by l1-contracts/script/deploy/DeploymentConfiguration.sol. NETWORK: getActiveNetworkName(), FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined, @@ -618,12 +627,15 @@ export const deployRollupForUpgrade = async ( // Use foundry-artifacts from l1-artifacts package const l1ContractsPath = prepareL1ContractsForDeployment(); + const forgeBin = resolveFoundryBinary('forge'); const FORGE_SCRIPT = 'script/deploy/DeployRollupForUpgrade.s.sol'; - await maybeForgeForceProductionBuild(l1ContractsPath, FORGE_SCRIPT, chainId); + await maybeForgeForceProductionBuild(forgeBin, l1ContractsPath, FORGE_SCRIPT, chainId); const scriptPath = join(getL1ContractsPath(), 'scripts', 'forge_broadcast.js'); const forgeArgs = [FORGE_SCRIPT, '--sig', 'run()', '--private-key', privateKey, '--rpc-url', rpcUrl]; const forgeEnv = { + // Resolved forge binary picked up by forge_broadcast.js, so it works without forge on PATH. + FORGE_BIN: forgeBin, FOUNDRY_PROFILE: chainId === mainnet.id ? 'production' : undefined, // Env vars required by l1-contracts/script/deploy/RollupConfiguration.sol. REGISTRY_ADDRESS: registryAddress.toString(), diff --git a/yarn-project/ethereum/src/foundry_binary.ts b/yarn-project/ethereum/src/foundry_binary.ts new file mode 100644 index 000000000000..3e4aeeabf0ac --- /dev/null +++ b/yarn-project/ethereum/src/foundry_binary.ts @@ -0,0 +1,57 @@ +import { spawnSync } from 'child_process'; +import { accessSync, constants } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Locate a Foundry binary (`anvil`, `forge`, ...) without relying on the caller's PATH. Order: + * 1. `$_BIN` (e.g. `$ANVIL_BIN`, `$FORGE_BIN`) — explicit override, e.g. for CI with a pinned + * version. Throws if set but not pointing at an executable, instead of silently falling back. + * 2. `~/.aztec/current/internal-bin/` — where aztec-up installs it. + * 3. `~/.aztec/current/bin/aztec-` — the publicly-exposed symlink. + * 4. `~/.foundry/bin/` — standalone foundryup install. + * 5. `command -v ` — anything else on PATH. + * + * Throws with a directive message if none work. + */ +export function resolveFoundryBinary(name: string): string { + const envVar = `${name.toUpperCase()}_BIN`; + const envBin = process.env[envVar]; + if (envBin) { + if (!isExecutable(envBin)) { + throw new Error(`$${envVar} is set to ${envBin}, which does not exist or is not executable.`); + } + return envBin; + } + + const candidates = [ + join(homedir(), '.aztec', 'current', 'internal-bin', name), + join(homedir(), '.aztec', 'current', 'bin', `aztec-${name}`), + join(homedir(), '.foundry', 'bin', name), + ]; + for (const path of candidates) { + if (isExecutable(path)) { + return path; + } + } + + const which = spawnSync('sh', ['-c', `command -v ${name}`], { encoding: 'utf8' }); + if (which.status === 0 && which.stdout.trim()) { + return which.stdout.trim(); + } + + throw new Error( + `${name} binary not found. Tried $${envVar}, ~/.aztec/current/internal-bin/${name}, ` + + `~/.aztec/current/bin/aztec-${name}, ~/.foundry/bin/${name}, and $PATH. ` + + `Install via \`aztec-up\` or set ${envVar} to a working binary.`, + ); +} diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index f5ba8609e15f..c1de2f673409 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -1,10 +1,10 @@ import { createLogger } from '@aztec/foundation/log'; import { makeBackoff, retry } from '@aztec/foundation/retry'; import type { TestDateProvider } from '@aztec/foundation/timer'; -import { fileURLToPath } from '@aztec/foundation/url'; import { type ChildProcess, spawn } from 'child_process'; -import { dirname, resolve } from 'path'; + +import { resolveFoundryBinary } from '../foundry_binary.js'; /** Minimal interface matching the @viem/anvil Anvil shape used by callers. */ export interface Anvil { @@ -14,6 +14,30 @@ export interface Anvil { stop(): Promise; } +// Watchdog wrapper: instead of spawning anvil directly, we spawn a small bash supervisor that runs +// anvil as a background child and polls its own parent (this node process). If the parent dies for +// ANY reason — including SIGKILL / crash / OOM, where node's own exit handlers never run — the poll +// loop ends and the EXIT trap reaps anvil. The script is inlined (rather than shipped as a `.sh`) so +// it works from the published npm tarball too, and the resolved anvil binary is passed via +// `$ANVIL_BIN` so it works without `anvil` on PATH. +// +// `$@` is the anvil argv; `bash -c