diff --git a/barretenberg/cpp/src/barretenberg/avm_fuzzer/fuzz_lib/constants.hpp b/barretenberg/cpp/src/barretenberg/avm_fuzzer/fuzz_lib/constants.hpp index 8988b8aaede0..d5587f417097 100644 --- a/barretenberg/cpp/src/barretenberg/avm_fuzzer/fuzz_lib/constants.hpp +++ b/barretenberg/cpp/src/barretenberg/avm_fuzzer/fuzz_lib/constants.hpp @@ -32,7 +32,7 @@ const std::vector REVERTIBLE_ACCUMULATED_DATA_L2_TO_L1_MESS const std::vector SETUP_ENQUEUED_CALLS = {}; const FF MSG_SENDER = 100; const std::optional TEARDOWN_ENQUEUED_CALLS = std::nullopt; -const Gas GAS_USED_BY_PRIVATE = Gas{ .l2_gas = 0, .da_gas = 0 }; +const Gas GAS_USED_BY_PRIVATE = Gas{ .l2_gas = PUBLIC_TX_L2_GAS_OVERHEAD, .da_gas = TX_DA_GAS_OVERHEAD }; const AztecAddress FEE_PAYER = AztecAddress{ 0 }; const FF CONTRACT_ADDRESS = 42; const FF TRANSACTION_FEE = 0; diff --git a/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_data.cpp b/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_data.cpp index a78fe06b9f34..64f41ef467ca 100644 --- a/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_data.cpp +++ b/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_data.cpp @@ -112,7 +112,7 @@ void mutate_tx(Tx& tx, std::vector& contract_addresses, std::mt199 case TxMutationOptions::GasUsedByPrivate: // Mutate gas_used_by_private fuzz_info("Mutating gas used by private"); - mutate_gas(tx.gas_used_by_private, rng, tx.gas_settings.gas_limits); + mutate_gas(tx.gas_used_by_private, rng, GAS_USED_BY_PRIVATE, tx.gas_settings.gas_limits); break; case TxMutationOptions::FeePayer: // Mutate fee_payer diff --git a/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.cpp b/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.cpp index 4d12a4f3fd29..a5a61f3d2e79 100644 --- a/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.cpp +++ b/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.cpp @@ -28,26 +28,26 @@ uint128_t generate_u128(std::mt19937_64& rng, uint128_t min = 0, uint128_t max = namespace bb::avm2::fuzzer { -Gas generate_gas(std::mt19937_64& rng) +Gas generate_gas(std::mt19937_64& rng, const Gas& min, const Gas& max) { - uint32_t l2_gas = std::uniform_int_distribution(MIN_GAS, AVM_MAX_PROCESSABLE_L2_GAS)(rng); - uint32_t da_gas = std::uniform_int_distribution(MIN_GAS, AVM_MAX_PROCESSABLE_DA_GAS)(rng); + uint32_t l2_gas = std::uniform_int_distribution(min.l2_gas, max.l2_gas)(rng); + uint32_t da_gas = std::uniform_int_distribution(min.da_gas, max.da_gas)(rng); return Gas{ l2_gas, da_gas }; } -void mutate_gas(Gas& gas, std::mt19937_64& rng, const Gas& max) +void mutate_gas(Gas& gas, std::mt19937_64& rng, const Gas& min, const Gas& max) { auto choice = std::uniform_int_distribution(0, 1)(rng); switch (choice) { case 0: // Mutate l2_gas - gas.l2_gas = std::uniform_int_distribution(MIN_GAS, max.l2_gas)(rng); + gas.l2_gas = std::uniform_int_distribution(min.l2_gas, max.l2_gas)(rng); break; case 1: // Mutate da_gas - gas.da_gas = std::uniform_int_distribution(MIN_GAS, max.da_gas)(rng); + gas.da_gas = std::uniform_int_distribution(min.da_gas, max.da_gas)(rng); break; } } diff --git a/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.hpp b/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.hpp index f8715e30d1ee..e2f013637f32 100644 --- a/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.hpp +++ b/barretenberg/cpp/src/barretenberg/avm_fuzzer/mutations/tx_types/gas.hpp @@ -15,12 +15,13 @@ constexpr uint128_t MIN_FEE = 1; constexpr uint128_t MAX_FEE = 1000; // // Gas bounds for mutation -constexpr uint32_t MIN_GAS = 0; -constexpr uint32_t AVM_MAX_PROCESSABLE_DA_GAS = (MAX_NOTE_HASHES_PER_TX * AVM_EMITNOTEHASH_BASE_DA_GAS) + - (MAX_NULLIFIERS_PER_TX * AVM_EMITNULLIFIER_BASE_DA_GAS) + - (MAX_L2_TO_L1_MSGS_PER_TX * AVM_SENDL2TOL1MSG_BASE_DA_GAS) + - (MAX_TOTAL_PUBLIC_DATA_UPDATE_REQUESTS_PER_TX * AVM_SSTORE_DYN_DA_GAS) + - (PUBLIC_LOGS_LENGTH * AVM_EMITPUBLICLOG_BASE_DA_GAS); +constexpr uint32_t MAX_PROCESSABLE_DA_GAS = TX_DA_GAS_OVERHEAD + + (MAX_NOTE_HASHES_PER_TX * AVM_EMITNOTEHASH_BASE_DA_GAS) + + (MAX_NULLIFIERS_PER_TX * AVM_EMITNULLIFIER_BASE_DA_GAS) + + (MAX_L2_TO_L1_MSGS_PER_TX * AVM_SENDL2TOL1MSG_BASE_DA_GAS) + + (MAX_TOTAL_PUBLIC_DATA_UPDATE_REQUESTS_PER_TX * AVM_SSTORE_DYN_DA_GAS) + + (PUBLIC_LOGS_LENGTH * AVM_EMITPUBLICLOG_BASE_DA_GAS); +constexpr Gas MAX_GAS_LIMIT = Gas{ .l2_gas = MAX_PROCESSABLE_L2_GAS, .da_gas = MAX_PROCESSABLE_DA_GAS }; enum class GasSettingsMutationOptions : uint8_t { GasLimits, @@ -38,10 +39,8 @@ constexpr GasSettingsMutationConfig GAS_SETTINGS_MUTATION_CONFIGURATION = GasSet { GasSettingsMutationOptions::MaxPriorityFeesPerGas, 5 }, }); -Gas generate_gas(std::mt19937_64& rng); -void mutate_gas(Gas& gas, - std::mt19937_64& rng, - const Gas& max = Gas{ AVM_MAX_PROCESSABLE_L2_GAS, AVM_MAX_PROCESSABLE_DA_GAS }); +Gas generate_gas(std::mt19937_64& rng, const Gas& min = {}, const Gas& max = MAX_GAS_LIMIT); +void mutate_gas(Gas& gas, std::mt19937_64& rng, const Gas& min = {}, const Gas& max = MAX_GAS_LIMIT); GasSettings generate_gas_settings(std::mt19937_64& rng); void mutate_gas_settings(GasSettings& data, std::mt19937_64& rng); diff --git a/barretenberg/cpp/src/barretenberg/vm2/common/aztec_constants.hpp b/barretenberg/cpp/src/barretenberg/vm2/common/aztec_constants.hpp index 3890636f80b3..1939294f3582 100644 --- a/barretenberg/cpp/src/barretenberg/vm2/common/aztec_constants.hpp +++ b/barretenberg/cpp/src/barretenberg/vm2/common/aztec_constants.hpp @@ -179,7 +179,10 @@ #define AVM_PUBLIC_INPUTS_COLUMNS_COMBINED_LENGTH 18740 #define AVM_V2_PROOF_LENGTH_IN_FIELDS_PADDED 16400 #define AVM_V2_VERIFICATION_KEY_LENGTH_IN_FIELDS_PADDED 1000 +#define TX_DA_GAS_OVERHEAD 96 +#define PUBLIC_TX_L2_GAS_OVERHEAD 540000 #define AVM_MAX_PROCESSABLE_L2_GAS 6000000 +#define MAX_PROCESSABLE_L2_GAS 6540000 #define AVM_PC_SIZE_IN_BITS 32 #define AVM_MAX_OPERANDS 7 #define AVM_MAX_REGISTERS 6 @@ -221,7 +224,7 @@ #define AVM_L1TOL2MSGEXISTS_BASE_L2_GAS 540 #define AVM_GETCONTRACTINSTANCE_BASE_L2_GAS 6108 #define AVM_EMITPUBLICLOG_BASE_L2_GAS 15 -#define AVM_SENDL2TOL1MSG_BASE_L2_GAS 478 +#define AVM_SENDL2TOL1MSG_BASE_L2_GAS 5239 #define AVM_CALL_BASE_L2_GAS 9936 #define AVM_STATICCALL_BASE_L2_GAS 9936 #define AVM_RETURN_BASE_L2_GAS 9 diff --git a/barretenberg/cpp/src/barretenberg/vm2/constraining/avm_fixed_vk.hpp b/barretenberg/cpp/src/barretenberg/vm2/constraining/avm_fixed_vk.hpp index 93e925fff4c8..3e725f93b8c7 100644 --- a/barretenberg/cpp/src/barretenberg/vm2/constraining/avm_fixed_vk.hpp +++ b/barretenberg/cpp/src/barretenberg/vm2/constraining/avm_fixed_vk.hpp @@ -17,7 +17,7 @@ class AvmHardCodedVKAndHash { using FF = bb::curve::BN254::ScalarField; // Precomputed VK hash (hash of all commitments below). - static FF vk_hash() { return FF(uint256_t("0x02296b934ced1a5cdacae120d2032d88a119bdb0738d4c4f3ada4f5a831a5153")); } + static FF vk_hash() { return FF(uint256_t("0x18952f5711d5f7a6e29c980f1077eecd2ec45e80ba0ae78d5a4ae08e50428cab")); } static constexpr std::array get_all() { @@ -71,9 +71,9 @@ class AvmHardCodedVKAndHash { uint256_t( "0x090dda25e7d64ab5cabe09fd80fbb731af2a98de7a608157dc10394b4fc022a4")), // precomputed_exec_opcode_dynamic_l2_gas Commitment( - uint256_t("0x2216a1693dcb1cc83f57ea8058f681d71bdf0e6cfc839502cf16fb0a88a5f673"), + uint256_t("0x26086b5fb31a24f236f0441d5b922b94ca141e861b9cc640184681c518cd68d3"), uint256_t( - "0x255e6760ed9adda61aca7d0b7d4bb28bb62e3cca6e860009461a9a1708184be2")), // precomputed_exec_opcode_opcode_gas + "0x0bab134bb4e25ff33584c1094847e762ce6573054bae27715d0e4eb2b7278d80")), // precomputed_exec_opcode_opcode_gas Commitment( uint256_t("0x296def9415d1c96b4d8ab91df5f59ad8522a726f98461b1ab5c4d4c5b22471a4"), uint256_t( diff --git a/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin b/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin index 44bfd7a9a088..13cf29d653c4 100644 Binary files a/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin and b/barretenberg/cpp/src/barretenberg/vm2/testing/avm_inputs.testdata.bin differ diff --git a/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin b/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin index 57ac06aafc7d..10e8459aa257 100644 Binary files a/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin and b/barretenberg/cpp/src/barretenberg/vm2/testing/minimal_tx.testdata.bin differ diff --git a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/gas_meter.nr b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/gas_meter.nr index d598d9c99a5b..306af3f510a2 100644 --- a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/gas_meter.nr +++ b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/gas_meter.nr @@ -89,5 +89,11 @@ pub fn meter_gas_used(public_inputs: PrivateKernelCircuitPublicInputs, is_for_pu let metered_da_gas = metered_da_fields * DA_GAS_PER_FIELD; - Gas::tx_overhead() + Gas::new(metered_da_gas, metered_l2_gas) + teardown_gas + let overhead = if is_for_public { + Gas::public_tx_overhead() + } else { + Gas::private_tx_overhead() + }; + + overhead + Gas::new(metered_da_gas, metered_l2_gas) + teardown_gas } diff --git a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail/meter_gas_used_tests.nr b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail/meter_gas_used_tests.nr index 619b3e5af792..b88e87604e8e 100644 --- a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail/meter_gas_used_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail/meter_gas_used_tests.nr @@ -12,7 +12,7 @@ use types::{ /// A minimum (private) tx initialized in the TestBuilder contains a protocol nullifier, which must exist in every tx. fn get_minimum_private_tx_gas_used() -> Gas { let nullifier_gas_used = Gas { da_gas: DA_GAS_PER_FIELD, l2_gas: L2_GAS_PER_NULLIFIER }; - Gas::tx_overhead() + nullifier_gas_used + Gas::private_tx_overhead() + nullifier_gas_used } #[test] @@ -100,7 +100,7 @@ fn full_side_effects() { let mut builder = TestBuilder::new(); // Fill the tx with side effects and compute the expected gas used. - let mut expected_gas_used = Gas::tx_overhead(); + let mut expected_gas_used = Gas::private_tx_overhead(); // Note hashes. builder.previous_kernel.append_siloed_note_hashes(MAX_NOTE_HASHES_PER_TX); expected_gas_used += Gas { diff --git a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail_to_public/meter_gas_used_tests.nr b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail_to_public/meter_gas_used_tests.nr index fda045e1726b..a3ae3df2822a 100644 --- a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail_to_public/meter_gas_used_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/tests/private_kernel_tail_to_public/meter_gas_used_tests.nr @@ -17,7 +17,7 @@ fn get_minimum_public_tx_gas_used() -> Gas { let nullifier_gas_used = Gas { da_gas: DA_GAS_PER_FIELD, l2_gas: AVM_EMITNULLIFIER_BASE_L2_GAS }; let public_call_gas_used = Gas { da_gas: 0, l2_gas: FIXED_AVM_STARTUP_L2_GAS }; - Gas::tx_overhead() + nullifier_gas_used + public_call_gas_used + Gas::public_tx_overhead() + nullifier_gas_used + public_call_gas_used } #[test] @@ -236,7 +236,8 @@ fn full_side_effects() { + MAX_PRIVATE_LOGS_PER_TX * L2_GAS_PER_PRIVATE_LOG + MAX_CONTRACT_CLASS_LOGS_PER_TX * L2_GAS_PER_CONTRACT_CLASS_LOG + MAX_ENQUEUED_CALLS_PER_TX * FIXED_AVM_STARTUP_L2_GAS; - let expected_gas_used = Gas::tx_overhead() + Gas::new(da_gas, l2_gas) + teardown_gas_limits; + let expected_gas_used = + Gas::public_tx_overhead() + Gas::new(da_gas, l2_gas) + teardown_gas_limits; let public_inputs = builder.execute(); assert_eq(public_inputs.gas_used, expected_gas_used); diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/components/private_tail_validator.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/components/private_tail_validator.nr index f696642ceff4..a34fcc865938 100644 --- a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/components/private_tail_validator.nr +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/components/private_tail_validator.nr @@ -8,8 +8,8 @@ use types::{ log_hash::LogHash, tx_constant_data::TxConstantData, }, constants::{ - ARCHIVE_HEIGHT, AVM_MAX_PROCESSABLE_L2_GAS, CONTRACT_CLASS_LOG_SIZE_IN_FIELDS, - MAX_CONTRACT_CLASS_LOGS_PER_TX, + ARCHIVE_HEIGHT, CONTRACT_CLASS_LOG_SIZE_IN_FIELDS, MAX_CONTRACT_CLASS_LOGS_PER_TX, + MAX_PROCESSABLE_L2_GAS, }, hash::compute_contract_class_log_hash, merkle_tree::{check_membership, MembershipWitness}, @@ -101,10 +101,9 @@ pub fn validate_tx_constant_data( ); // Ensure that the l2 gas limit is within the max processable l2 gas. - // The constant is prefixed with `AVM_` but it applies to both private-only and public-inclusive txs. // TODO: This should be moved to the private kernels once they are not used for gas estimation anymore. assert( - tx_gas_settings.gas_limits.l2_gas <= AVM_MAX_PROCESSABLE_L2_GAS, + tx_gas_settings.gas_limits.l2_gas <= MAX_PROCESSABLE_L2_GAS, "l2 gas limit exceeds max processable l2 gas", ); } diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/private_tx_base/validate_private_tail_tests.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/private_tx_base/validate_private_tail_tests.nr index 8c478c970435..33c0831bdb81 100644 --- a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/private_tx_base/validate_private_tail_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/private_tx_base/validate_private_tail_tests.nr @@ -1,5 +1,5 @@ use super::TestBuilder; -use types::{constants::AVM_MAX_PROCESSABLE_L2_GAS, tests::fixture_builder::FixtureBuilder}; +use types::{constants::MAX_PROCESSABLE_L2_GAS, tests::fixture_builder::FixtureBuilder}; #[test(should_fail_with = "Membership check failed: anchor block header hash not found in archive tree")] unconstrained fn anchor_block_header_not_in_archive() { @@ -62,7 +62,7 @@ unconstrained fn gas_settings_l2_gas_limit_exceeds_max_processable_l2_gas() { let mut builder = TestBuilder::new(); builder.private_tail.constants.tx_context.gas_settings.gas_limits.l2_gas = - AVM_MAX_PROCESSABLE_L2_GAS + 1; + MAX_PROCESSABLE_L2_GAS + 1; builder.execute_and_fail(); } diff --git a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/public_tx_base/validate_private_tail_to_public_tests.nr b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/public_tx_base/validate_private_tail_to_public_tests.nr index cf5c99f91b3d..e3b8791540cf 100644 --- a/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/public_tx_base/validate_private_tail_to_public_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/rollup-lib/src/tx_base/tests/public_tx_base/validate_private_tail_to_public_tests.nr @@ -1,5 +1,5 @@ use super::TestBuilder; -use types::{constants::AVM_MAX_PROCESSABLE_L2_GAS, tests::fixture_builder::FixtureBuilder}; +use types::{constants::MAX_PROCESSABLE_L2_GAS, tests::fixture_builder::FixtureBuilder}; #[test(should_fail_with = "Membership check failed: anchor block header hash not found in archive tree")] unconstrained fn anchor_block_header_not_in_archive() { @@ -53,7 +53,7 @@ unconstrained fn gas_settings_l2_gas_limit_exceeds_max_processable_l2_gas() { let mut builder = TestBuilder::new(); builder.private_tail.constants.tx_context.gas_settings.gas_limits.l2_gas = - AVM_MAX_PROCESSABLE_L2_GAS + 1; + MAX_PROCESSABLE_L2_GAS + 1; builder.execute_and_fail(); } diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/abis/gas.nr b/noir-projects/noir-protocol-circuits/crates/types/src/abis/gas.nr index 8b85a81a6e39..aa878684297d 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/abis/gas.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/abis/gas.nr @@ -1,6 +1,6 @@ use crate::{ abis::gas_fees::GasFees, - constants::{FIXED_DA_GAS, FIXED_L2_GAS}, + constants::{PRIVATE_TX_L2_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD, TX_DA_GAS_OVERHEAD}, traits::{Deserialize, Empty, Serialize}, }; use std::{meta::derive, ops::{Add, Sub}}; @@ -16,8 +16,12 @@ impl Gas { Self { da_gas, l2_gas } } - pub fn tx_overhead() -> Self { - Self { da_gas: FIXED_DA_GAS, l2_gas: FIXED_L2_GAS } + pub fn private_tx_overhead() -> Self { + Self { da_gas: TX_DA_GAS_OVERHEAD, l2_gas: PRIVATE_TX_L2_GAS_OVERHEAD } + } + + pub fn public_tx_overhead() -> Self { + Self { da_gas: TX_DA_GAS_OVERHEAD, l2_gas: PUBLIC_TX_L2_GAS_OVERHEAD } } pub fn compute_fee(self, fees: GasFees) -> Field { diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr index c0ba749b920a..429afe14aec6 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants.nr @@ -1032,21 +1032,22 @@ pub global AVM_V2_PROOF_LENGTH_IN_FIELDS_PADDED: u32 = 16400; pub global AVM_V2_VERIFICATION_KEY_LENGTH_IN_FIELDS_PADDED: u32 = 1000; // GAS DEFAULTS -// The maximum amount of l2 gas that the AVM can process safely. -pub global AVM_MAX_PROCESSABLE_L2_GAS: u32 = 6_000_000; // Arbitrary. - pub global DA_BYTES_PER_FIELD: u32 = 32; pub global DA_GAS_PER_BYTE: u32 = 1; // Arbitrary. pub global DA_GAS_PER_FIELD: u32 = DA_BYTES_PER_FIELD * DA_GAS_PER_BYTE; // A tx always emits 3 fields: tx_start_marker, tx_hash, and tx_fee. -pub global FIXED_DA_GAS: u32 = 3 * DA_GAS_PER_FIELD; -// TODO: take into account the cost of executing and proving the rollup circuits (if they can be attributed to this tx... eg the tx base rollup, half a tx merge). -// We need a test suite to demonstrate these measurements -// pays for fixed tx costs like validation, and updating state roots -pub global FIXED_L2_GAS: u32 = 512; +pub global TX_DA_GAS_OVERHEAD: u32 = 3 * DA_GAS_PER_FIELD; +// Computed taking into account simulation time metrics. +pub global PUBLIC_TX_L2_GAS_OVERHEAD: u32 = 540000; +pub global PRIVATE_TX_L2_GAS_OVERHEAD: u32 = 440000; // base cost for a single public call pub global FIXED_AVM_STARTUP_L2_GAS: u32 = 20_000; +// The maximum amount of l2 gas that the AVM can process safely. +pub global AVM_MAX_PROCESSABLE_L2_GAS: u32 = 6_000_000; // Arbitrary. +// The following limit assumes that a private only tx will always be cheaper than a tx with a full public component. +pub global MAX_PROCESSABLE_L2_GAS: u32 = PUBLIC_TX_L2_GAS_OVERHEAD + AVM_MAX_PROCESSABLE_L2_GAS; + pub global MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT: u32 = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB * DA_GAS_PER_FIELD; @@ -1054,9 +1055,9 @@ pub global MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT: u32 = // Since we split in teardown and total, teardown has the theoretical maximum and total is that times 2. // After gas estimation, we tune it down to the actual amount necessary. // TODO: this is a wallet concern; it should not be part of the protocol -pub global GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT: u32 = AVM_MAX_PROCESSABLE_L2_GAS; +pub global GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT: u32 = MAX_PROCESSABLE_L2_GAS; pub global GAS_ESTIMATION_L2_GAS_LIMIT: u32 = - GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT + AVM_MAX_PROCESSABLE_L2_GAS; + GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT + MAX_PROCESSABLE_L2_GAS; pub global GAS_ESTIMATION_TEARDOWN_DA_GAS_LIMIT: u32 = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; pub global GAS_ESTIMATION_DA_GAS_LIMIT: u32 = @@ -1065,7 +1066,7 @@ pub global GAS_ESTIMATION_DA_GAS_LIMIT: u32 = // Default gas limits. Users should use gas estimation, or they will overpay gas fees. // TODO: consider moving to typescript pub global DEFAULT_TEARDOWN_L2_GAS_LIMIT: u32 = 1_000_000; // Arbitrary default number. -pub global DEFAULT_L2_GAS_LIMIT: u32 = AVM_MAX_PROCESSABLE_L2_GAS; // Arbitrary default number. +pub global DEFAULT_L2_GAS_LIMIT: u32 = MAX_PROCESSABLE_L2_GAS; // Arbitrary default number. pub global DEFAULT_TEARDOWN_DA_GAS_LIMIT: u32 = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT / 2; // Arbitrary default number. pub global DEFAULT_DA_GAS_LIMIT: u32 = MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT; // Arbitrary default number. @@ -1089,14 +1090,12 @@ pub global AVM_ADDRESSING_INDIRECT_L2_GAS: u32 = 3; // One mem access pub global AVM_ADDRESSING_RELATIVE_L2_GAS: u32 = 3; // One range check // Base L2 GAS -// TODO: Decide what the following constants should be. -pub global L2_GAS_PER_NOTE_HASH: u32 = 0; -pub global L2_GAS_PER_NULLIFIER: u32 = 0; -// Gas for writing message to L1 portal -pub global L2_GAS_PER_L2_TO_L1_MSG: u32 = 200; // TODO: Update and explain this. -// Zero gas because we don't have to hash and validate the private logs -pub global L2_GAS_PER_PRIVATE_LOG: u32 = 0; -pub global L2_GAS_PER_CONTRACT_CLASS_LOG: u32 = 0; // TODO: this should be nonzero, because the sequencer is doing work to hash this, as part of tx validation +// Based on simulation time metrics +pub global L2_GAS_PER_NOTE_HASH: u32 = 2700; +pub global L2_GAS_PER_NULLIFIER: u32 = 16000; +pub global L2_GAS_PER_L2_TO_L1_MSG: u32 = 5200; +pub global L2_GAS_PER_PRIVATE_LOG: u32 = 2500; +pub global L2_GAS_PER_CONTRACT_CLASS_LOG: u32 = 73000; // Note: magic numbers here are derived from each op's AVM circuit trace area // https://docs.google.com/spreadsheets/d/1FPyLfJFPOfZTmZC-T6b_4yB5dSrOAofz3dufte23_TA/edit?usp=sharing // Some have a "SLOW_SIM_MUL" multiplier because they are slower to simulate and their trace-area-derived gas cost @@ -1136,7 +1135,7 @@ pub global AVM_EMITNULLIFIER_BASE_L2_GAS: u32 = (516 + L2_GAS_DISTRIBUTED_STORAG pub global AVM_L1TOL2MSGEXISTS_BASE_L2_GAS: u32 = 108 * 5; // SLOW_SIM_MUL = 4 (+1 for slow proving) pub global AVM_GETCONTRACTINSTANCE_BASE_L2_GAS: u32 = 1527 * 4; // SLOW_SIM_MUL = 4 pub global AVM_EMITPUBLICLOG_BASE_L2_GAS: u32 = 15; -pub global AVM_SENDL2TOL1MSG_BASE_L2_GAS: u32 = (39 + L2_GAS_PER_L2_TO_L1_MSG) * 2; // SLOW_SIM_MUL = 2 +pub global AVM_SENDL2TOL1MSG_BASE_L2_GAS: u32 = 39 + L2_GAS_PER_L2_TO_L1_MSG; // See PR https://github.com/AztecProtocol/aztec-packages/pull/15495 on why we need this buffer. pub global AVM_CALL_BASE_L2_GAS: u32 = 3312 * 3; // SLOW_SIM_MUL = 3 pub global AVM_STATICCALL_BASE_L2_GAS: u32 = 3312 * 3; // SLOW_SIM_MUL = 3 diff --git a/playground/src/utils/networks.ts b/playground/src/utils/networks.ts index a221f5b6830a..5a02eb870e00 100644 --- a/playground/src/utils/networks.ts +++ b/playground/src/utils/networks.ts @@ -19,14 +19,14 @@ export type Network = { export const NETWORKS: Network[] = [ { - nodeURL: 'https://next.devnet.aztec-labs.com', + nodeURL: 'https://v4-devnet-2.aztec-labs.com/', name: 'Aztec Devnet', description: 'Public development network', chainId: 11155111, - version: 1647720761, + version: 615022430, hasTestAccounts: false, hasSponsoredFPC: true, - nodeVersion: '3.0.0-devnet', + nodeVersion: '4.0.0-devnet.2-patch.1', }, { nodeURL: 'http://localhost:8080', diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index bbe5f3aa236e..1779d1362a3e 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -126,7 +126,6 @@ describe('Archiver Sync', () => { publicClient, rollupContract, inboxContract, - contractAddresses, archiverStore, config, blobClient, diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index dc0ca5552d85..ca4d60f8a780 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -138,7 +138,6 @@ export async function createArchiver( debugClient, rollup, inbox, - { ...config.l1Contracts, slashingProposerAddress }, archiverStore, archiverConfig, deps.blobClient, diff --git a/yarn-project/archiver/src/l1/README.md b/yarn-project/archiver/src/l1/README.md index c1cb4ecdab8c..2076d0e61bbc 100644 --- a/yarn-project/archiver/src/l1/README.md +++ b/yarn-project/archiver/src/l1/README.md @@ -5,29 +5,27 @@ Modules and classes to handle data retrieval from L1 for the archiver. ## Calldata Retriever The sequencer publisher bundles multiple operations into a single multicall3 transaction for gas -efficiency. A typical transaction includes: +efficiency. The archiver needs to extract the `propose` calldata from these bundled transactions +to reconstruct L2 blocks. -1. Attestation invalidations (if needed): `invalidateBadAttestation`, `invalidateInsufficientAttestations` -2. Block proposal: `propose` (exactly one per transaction to the rollup contract) -3. Governance and slashing (if needed): votes, payload creation/execution +The retriever uses hash matching against `attestationsHash` and `payloadDigest` from the +`CheckpointProposed` L1 event to verify it has found the correct propose calldata. These hashes +are always required. -The archiver needs to extract the `propose` calldata from these bundled transactions to reconstruct -L2 blocks. This class needs to handle scenarios where the transaction was submitted via multicall3, -as well as alternative ways for submitting the `propose` call that other clients might use. +### Multicall3 Decoding with Hash Matching -### Multicall3 Validation and Decoding - -First attempt to decode the transaction as a multicall3 `aggregate3` call with validation: +First attempt to decode the transaction as a multicall3 `aggregate3` call: - Check if transaction is to multicall3 address (`0xcA11bde05977b3631167028862bE2a173976CA11`) - Decode as `aggregate3(Call3[] calldata calls)` -- Allow calls to known addresses and methods (rollup, governance, slashing contracts, etc.) -- Find the single `propose` call to the rollup contract -- Verify exactly one `propose` call exists -- Extract and return the propose calldata +- Find all calls matching the rollup contract address and the `propose` function selector +- Verify each candidate by computing `attestationsHash` (keccak256 of ABI-encoded attestations) + and `payloadDigest` (keccak256 of the consensus payload signing hash) and comparing against + expected values from the `CheckpointProposed` event +- Return the verified candidate (if multiple verify, return the first with a warning) -This step handles the common case efficiently without requiring expensive trace or debug RPC calls. -Any validation failure triggers fallback to the next step. +This approach works regardless of what other calls are in the multicall3 bundle, because hash +matching identifies the correct propose call without needing an allowlist. ### Direct Propose Call @@ -35,64 +33,23 @@ Second attempt to decode the transaction as a direct `propose` call to the rollu - Check if transaction is to the rollup address - Decode as `propose` function call -- Verify the function is indeed `propose` +- Verify against expected hashes - Return the transaction input as the propose calldata -This handles scenarios where clients submit transactions directly to the rollup contract without -using multicall3 for bundling. Any validation failure triggers fallback to the next step. - ### Spire Proposer Call -Given existing attempts to route the call via the Spire proposer, we also check if the tx is `to` the -proposer known address, and if so, we try decoding it as either a multicall3 or a direct call to the -rollup contract. - -Similar as with the multicall3 check, we check that there are no other calls in the Spire proposer, so -we are absolutely sure that the only call is the successful one to the rollup. Any extraneous call would -imply an unexpected path to calling `propose` in the rollup contract, and since we cannot verify if the -calldata arguments we extracted are the correct ones (see the section below), we cannot know for sure which -one is the call that succeeded, so we don't know which calldata to process. - -Furthermore, since the Spire proposer is upgradeable, we check if the implementation has not changed in -order to decode. As usual, any validation failure triggers fallback to the next step. - -### Verifying Multicall3 Arguments - -**This is NOT implemented for simplicity's sake** - -If the checks above don't hold, such as when there are multiple calls to `propose`, then we cannot -reliably extract the `propose` calldata from the multicall3 arguments alone. We can try a best-effort -where we try all `propose` calls we see and validate them against on-chain data. Note that we can use these -same strategies if we were to obtain the calldata from another source. - -#### TempBlockLog Verification - -Read the stored `TempBlockLog` for the L2 block number from L1 and verify it matches our decoded header hash, -since the `TempBlockLog` stores the hash of the proposed block header, the payload commitment, and the attestations. - -However, `TempBlockLog` is only stored temporarily and deleted after proven, so this method only works for recent -blocks, not for historical data syncing. - -#### Archive Verification - -Verify that the archive root in the decoded propose is correct with regard to the block header. This requires -hashing the block header we have retrieved, inserting it into the archive tree, and checking the resulting root -against the one we got from L1. - -However, this requires that the archive keeps a reference to world-state, which is not the case in the current -system. - -#### Emit Commitments in Rollup Contract - -Modify rollup contract to emit commitments to the block header in the `L2BlockProposed` event, allowing us to easily -verify the calldata we obtained vs the emitted event. +Given existing attempts to route the call via the Spire proposer, we also check if the tx is +`to` the proposer known address. If so, we extract all wrapped calls and try each as either +a multicall3 or direct propose call, using hash matching to find and verify the correct one. -However, modifying the rollup contract is out of scope for this change. But we can implement this approach in `v2`. +Since the Spire proposer is upgradeable, we check that the implementation has not changed in +order to decode. Any validation failure triggers fallback to the next step. ### Debug and Trace Transaction Fallback -Last, we use L1 node's trace/debug RPC methods to definitively identify the one successful `propose` call within the tx. -We can then extract the exact calldata that hit the `propose` function in the rollup contract. +Last, we use L1 node's trace/debug RPC methods to definitively identify the one successful +`propose` call within the tx. We can then extract the exact calldata that hit the `propose` +function in the rollup contract. -This approach requires access to a debug-enabled L1 node, which may be more resource-intensive, so we only -use it as a fallback when the first step fails, which should be rare in practice. \ No newline at end of file +This approach requires access to a debug-enabled L1 node, which may be more resource-intensive, +so we only use it as a fallback when earlier steps fail, which should be rare in practice. diff --git a/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts b/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts index 6be4953275e4..e81e02e86547 100644 --- a/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts +++ b/yarn-project/archiver/src/l1/bin/retrieve-calldata.ts @@ -5,7 +5,7 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi'; -import { type Hex, createPublicClient, getAbiItem, http, toEventSelector } from 'viem'; +import { type Hex, createPublicClient, decodeEventLog, getAbiItem, http, toEventSelector } from 'viem'; import { mainnet } from 'viem/chains'; import { CalldataRetriever } from '../calldata_retriever.js'; @@ -89,14 +89,6 @@ async function main() { logger.info(`Transaction found in block ${tx.blockNumber}`); - // For simplicity, use zero addresses for optional contract addresses - // In production, these would be fetched from the rollup contract or configuration - const slashingProposerAddress = EthAddress.ZERO; - const governanceProposerAddress = EthAddress.ZERO; - const slashFactoryAddress = undefined; - - logger.info('Using zero addresses for governance/slashing (can be configured if needed)'); - // Create CalldataRetriever const retriever = new CalldataRetriever( publicClient as unknown as ViemPublicClient, @@ -104,46 +96,67 @@ async function main() { targetCommitteeSize, undefined, logger, - { - rollupAddress, - governanceProposerAddress, - slashingProposerAddress, - slashFactoryAddress, - }, + rollupAddress, ); - // Extract checkpoint number from transaction logs - logger.info('Decoding transaction to extract checkpoint number...'); + // Extract checkpoint number and hashes from transaction logs + logger.info('Decoding transaction to extract checkpoint number and hashes...'); const receipt = await publicClient.getTransactionReceipt({ hash: txHash }); - // Look for CheckpointProposed event (emitted when a checkpoint is proposed to the rollup) - // Event signature: CheckpointProposed(uint256 indexed checkpointNumber, bytes32 indexed archive, bytes32[], bytes32, bytes32) - // Hash: keccak256("CheckpointProposed(uint256,bytes32,bytes32[],bytes32,bytes32)") - const checkpointProposedEvent = receipt.logs.find(log => { + // Look for CheckpointProposed event + const checkpointProposedEventAbi = getAbiItem({ abi: RollupAbi, name: 'CheckpointProposed' }); + const checkpointProposedLog = receipt.logs.find(log => { try { return ( log.address.toLowerCase() === rollupAddress.toString().toLowerCase() && - log.topics[0] === toEventSelector(getAbiItem({ abi: RollupAbi, name: 'CheckpointProposed' })) + log.topics[0] === toEventSelector(checkpointProposedEventAbi) ); } catch { return false; } }); - if (!checkpointProposedEvent || checkpointProposedEvent.topics[1] === undefined) { + if (!checkpointProposedLog || checkpointProposedLog.topics[1] === undefined) { throw new Error(`Checkpoint proposed event not found`); } - const checkpointNumber = CheckpointNumber.fromBigInt(BigInt(checkpointProposedEvent.topics[1])); + const checkpointNumber = CheckpointNumber.fromBigInt(BigInt(checkpointProposedLog.topics[1])); + + // Decode the full event to extract attestationsHash and payloadDigest + const decodedEvent = decodeEventLog({ + abi: RollupAbi, + data: checkpointProposedLog.data, + topics: checkpointProposedLog.topics, + }); + + const eventArgs = decodedEvent.args as { + checkpointNumber: bigint; + archive: Hex; + versionedBlobHashes: Hex[]; + attestationsHash: Hex; + payloadDigest: Hex; + }; + + if (!eventArgs.attestationsHash || !eventArgs.payloadDigest) { + throw new Error(`CheckpointProposed event missing attestationsHash or payloadDigest`); + } + + const expectedHashes = { + attestationsHash: eventArgs.attestationsHash, + payloadDigest: eventArgs.payloadDigest, + }; + + logger.info(`Checkpoint Number: ${checkpointNumber}`); + logger.info(`Attestations Hash: ${expectedHashes.attestationsHash}`); + logger.info(`Payload Digest: ${expectedHashes.payloadDigest}`); logger.info(''); logger.info('Retrieving checkpoint from rollup transaction...'); logger.info(''); - // For this script, we don't have blob hashes or expected hashes, so pass empty arrays/objects - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, expectedHashes); - logger.info(' Successfully retrieved block header!'); + logger.info(' Successfully retrieved block header!'); logger.info(''); logger.info('Block Header Details:'); logger.info('===================='); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.test.ts b/yarn-project/archiver/src/l1/calldata_retriever.test.ts index 45f0a81d9db1..0d1c78ef707c 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.test.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.test.ts @@ -32,7 +32,7 @@ import { EIP1967_IMPLEMENTATION_SLOT, SPIRE_PROPOSER_ADDRESS, SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION, - getCallFromSpireProposer, + getCallsFromSpireProposer, verifyProxyImplementation, } from './spire_proposer.js'; @@ -40,25 +40,35 @@ import { * Test class that exposes protected methods for testing */ class TestCalldataRetriever extends CalldataRetriever { - public override tryDecodeMulticall3(tx: Transaction): Hex | undefined { - return super.tryDecodeMulticall3(tx); + public override tryDecodeMulticall3( + tx: Transaction, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ) { + return super.tryDecodeMulticall3(tx, expectedHashes, checkpointNumber, blockHash); } - public override tryDecodeDirectPropose(tx: Transaction): Hex | undefined { - return super.tryDecodeDirectPropose(tx); + public override tryDecodeDirectPropose( + tx: Transaction, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ) { + return super.tryDecodeDirectPropose(tx, expectedHashes, checkpointNumber, blockHash); } public override async extractCalldataViaTrace(txHash: Hex): Promise { return await super.extractCalldataViaTrace(txHash); } - public override decodeAndBuildCheckpoint( + public override tryDecodeAndVerifyPropose( proposeCalldata: Hex, - blockHash: Hex, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, checkpointNumber: CheckpointNumber, - expectedHashes: { attestationsHash?: Hex; payloadDigest?: Hex }, + blockHash: Hex, ) { - return super.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, expectedHashes); + return super.tryDecodeAndVerifyPropose(proposeCalldata, expectedHashes, checkpointNumber, blockHash); } } @@ -72,10 +82,8 @@ describe('CalldataRetriever', () => { const TARGET_COMMITTEE_SIZE = 5; const rollupAddress = EthAddress.random(); - const governanceProposerAddress = EthAddress.random(); - const slashFactoryAddress = EthAddress.random(); - const slashingProposerAddress = EthAddress.random(); const blockHash = Buffer32.random().toString(); + const checkpointNumber = CheckpointNumber(42); beforeEach(() => { txHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; @@ -84,12 +92,14 @@ describe('CalldataRetriever', () => { logger = createLogger('test:calldata_retriever'); instrumentation = mock(); - retriever = new TestCalldataRetriever(publicClient, debugClient, TARGET_COMMITTEE_SIZE, instrumentation, logger, { + retriever = new TestCalldataRetriever( + publicClient, + debugClient, + TARGET_COMMITTEE_SIZE, + instrumentation, + logger, rollupAddress, - governanceProposerAddress, - slashFactoryAddress, - slashingProposerAddress, - }); + ); }); function makeViemHeader(): ViemHeader { @@ -136,6 +146,19 @@ describe('CalldataRetriever', () => { }); } + /** + * Sets up mocks for the hash computation methods to return specific test hashes. + * This allows us to test validation logic without recomputing hashes (which would duplicate production logic). + */ + function mockHashComputation( + attestationsHash: Hex = '0x1111111111111111111111111111111111111111111111111111111111111111', + payloadDigest: Hex = '0x2222222222222222222222222222222222222222222222222222222222222222', + ): { attestationsHash: Hex; payloadDigest: Hex } { + jest.spyOn(retriever as any, 'computeAttestationsHash').mockReturnValue(attestationsHash); + jest.spyOn(retriever as any, 'computePayloadDigest').mockReturnValue(payloadDigest); + return { attestationsHash, payloadDigest }; + } + function makeMulticall3Transaction(calls: { target: Hex; callData: Hex }[]): Transaction { const multicall3Data = encodeFunctionData({ abi: multicall3Abi, @@ -151,15 +174,14 @@ describe('CalldataRetriever', () => { } describe('getCheckpointFromRollupTx', () => { - const checkpointNumber = CheckpointNumber(42); - it('should successfully decode valid multicall3 transaction', async () => { const proposeCalldata = makeProposeCalldata(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); + const hashes = mockHashComputation(); publicClient.getTransaction.mockResolvedValue(tx); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result.checkpointNumber).toBe(checkpointNumber); expect(result.header).toBeInstanceOf(CheckpointHeader); @@ -171,6 +193,7 @@ describe('CalldataRetriever', () => { it('should fall back to direct propose when multicall3 decoding fails', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Transaction that's not multicall3 but is a direct propose call const tx = { @@ -182,7 +205,7 @@ describe('CalldataRetriever', () => { publicClient.getTransaction.mockResolvedValue(tx); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result.checkpointNumber).toBe(checkpointNumber); expect(result.header).toBeInstanceOf(CheckpointHeader); @@ -191,6 +214,7 @@ describe('CalldataRetriever', () => { it('should fall back to trace when both multicall3 and direct propose fail', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Transaction that's neither multicall3 nor direct propose (wrong address) const wrongAddress = EthAddress.random(); @@ -224,7 +248,7 @@ describe('CalldataRetriever', () => { }, ]); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result.checkpointNumber).toBe(checkpointNumber); expect(debugClient.request).toHaveBeenCalledWith({ method: 'trace_transaction', params: [txHash] }); @@ -233,6 +257,7 @@ describe('CalldataRetriever', () => { it('should throw when tracing fails', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Transaction that's neither multicall3 nor direct propose (wrong address) const wrongAddress = EthAddress.random(); @@ -248,20 +273,21 @@ describe('CalldataRetriever', () => { // Mock both trace methods to fail debugClient.request.mockRejectedValue(new Error(`Method not available`)); - await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {})).rejects.toThrow( + await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes)).rejects.toThrow( 'Failed to trace transaction', ); }); it('should throw when transaction retrieval fails', async () => { + const hashes = mockHashComputation(); publicClient.getTransaction.mockRejectedValue(new Error('Transaction not found')); - await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {})).rejects.toThrow( + await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes)).rejects.toThrow( 'Transaction not found', ); }); - it('should validate attestationsHash when provided', async () => { + it('should validate attestationsHash', async () => { const attestations = makeViemCommitteeAttestations(); const proposeCalldata = makeProposeCalldata(undefined, attestations); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); @@ -289,8 +315,14 @@ describe('CalldataRetriever', () => { ), ); + // Mock only payloadDigest computation; use real attestationsHash + jest + .spyOn(retriever as any, 'computePayloadDigest') + .mockReturnValue('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { attestationsHash: expectedAttestationsHash, + payloadDigest: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, }); expect(result.checkpointNumber).toBe(checkpointNumber); @@ -301,34 +333,23 @@ describe('CalldataRetriever', () => { const attestations = makeViemCommitteeAttestations(); const proposeCalldata = makeProposeCalldata(undefined, attestations); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); + const hashes = mockHashComputation(); publicClient.getTransaction.mockResolvedValue(tx); - // Use a different (wrong) attestationsHash + // Use a different (wrong) attestationsHash — hash mismatch causes tryDecodeMulticall3 to + // return undefined, falling through to trace which fails in tests const wrongAttestationsHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; await expect( retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { attestationsHash: wrongAttestationsHash, + payloadDigest: hashes.payloadDigest, }), - ).rejects.toThrow('Attestations hash mismatch'); - }); - - it('should work with empty expectedHashes for backwards compatibility', async () => { - const proposeCalldata = makeProposeCalldata(); - const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); - - publicClient.getTransaction.mockResolvedValue(tx); - - // Call with empty expectedHashes (simulating old event format without hash fields) - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); - - expect(result.checkpointNumber).toBe(checkpointNumber); - expect(result.header).toBeInstanceOf(CheckpointHeader); - // Should succeed without validation when hashes are not provided + ).rejects.toThrow('Failed to trace'); }); - it('should validate payloadDigest when provided', async () => { + it('should validate payloadDigest', async () => { const header = makeViemHeader(); const attestations = makeViemCommitteeAttestations(); const archiveRoot = Fr.random(); @@ -362,7 +383,13 @@ describe('CalldataRetriever', () => { const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); const expectedPayloadDigest = keccak256(payloadToSign); + // Mock only attestationsHash computation; use real payloadDigest + jest + .spyOn(retriever as any, 'computeAttestationsHash') + .mockReturnValue('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { + attestationsHash: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, payloadDigest: expectedPayloadDigest, }); @@ -373,132 +400,180 @@ describe('CalldataRetriever', () => { it('should throw when payloadDigest does not match', async () => { const proposeCalldata = makeProposeCalldata(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString(), callData: proposeCalldata }]); + const hashes = mockHashComputation(); publicClient.getTransaction.mockResolvedValue(tx); - // Use a different (wrong) payloadDigest + // Use a different (wrong) payloadDigest — hash mismatch causes tryDecodeMulticall3 to + // return undefined, falling through to trace which fails in tests const wrongPayloadDigest = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex; await expect( retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, { + attestationsHash: hashes.attestationsHash, payloadDigest: wrongPayloadDigest, }), - ).rejects.toThrow('Payload digest mismatch'); + ).rejects.toThrow('Failed to trace'); }); }); + describe('tryDecodeMulticall3', () => { - it('should decode simple multicall3 with single propose call', () => { + it('should decode multicall3 with single verified propose call', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); - expect(result).toBe(proposeCalldata); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(result!.archiveRoot).toBeInstanceOf(Fr); + expect(result!.checkpointNumber).toBe(checkpointNumber); }); - it('should decode multicall3 with propose and other rollup calls', () => { + it('should decode multicall3 with propose and other calls (hash matching ignores non-propose)', () => { const proposeCalldata = makeProposeCalldata(); - // Use the actual selector for these functions + const hashes = mockHashComputation(); const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); - const invalidateBadCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; // Minimal valid calldata + const invalidateBadCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; const tx = makeMulticall3Transaction([ { target: rollupAddress.toString() as Hex, callData: invalidateBadCalldata }, { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); - expect(result).toBe(proposeCalldata); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); }); - it('should decode multicall3 with mixed valid calls', () => { + it('should decode multicall3 with unknown calls when propose is hash-verified', () => { const proposeCalldata = makeProposeCalldata(); - const invalidateBadSelector = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, - ); - const invalidateBadCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; + const hashes = mockHashComputation(); + const unknownAddress = EthAddress.random(); const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: invalidateBadCalldata }, + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + }); + + it('should return first when multiple propose candidates all verify (with warning)', () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); - expect(result).toBe(proposeCalldata); + // Same calldata twice -> both verify + const tx = makeMulticall3Transaction([ + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + const warnSpy = jest.spyOn(logger, 'warn'); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Multiple propose candidates verified'), + expect.any(Object), + ); + warnSpy.mockRestore(); + }); + + it('should return the verified candidate when only one of multiple candidates verifies', () => { + const proposeCalldata1 = makeProposeCalldata(); + const proposeCalldata2 = makeProposeCalldata(); + + const hashes = mockHashComputation(); + + // Mock tryDecodeAndVerifyPropose to be selective - only first calldata verifies + jest.spyOn(retriever, 'tryDecodeAndVerifyPropose').mockImplementation((calldata, _hashes) => { + if (calldata === proposeCalldata1) { + return { + checkpointNumber, + archiveRoot: Fr.random(), + header: CheckpointHeader.random(), + attestations: [], + blockHash, + feeAssetPriceModifier: 0n, + }; + } + return undefined; + }); + + const tx = makeMulticall3Transaction([ + { target: rollupAddress.toString() as Hex, callData: proposeCalldata1 }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata2 }, + ]); + + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(checkpointNumber); }); it('should return undefined when not to multicall3 address', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: rollupAddress.toString() as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when to is null', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: null, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when not multicall3 aggregate3', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: MULTI_CALL_3_ADDRESS as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when call to unknown address', () => { + it('should return undefined when propose call to wrong address', () => { const proposeCalldata = makeProposeCalldata(); - const unknownAddress = EthAddress.random(); + const hashes = mockHashComputation(); + const wrongRollupAddress = EthAddress.random(); const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: wrongRollupAddress.toString() as Hex, callData: proposeCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when unknown function selector on rollup', () => { - const proposeCalldata = makeProposeCalldata(); - const invalidCalldata = '0x99999999' as Hex; // Unknown selector - - const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, - { target: rollupAddress.toString() as Hex, callData: invalidCalldata }, - ]); - - const result = retriever.tryDecodeMulticall3(tx); - + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when no propose calls found', () => { + const hashes = mockHashComputation(); const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); @@ -508,49 +583,53 @@ describe('CalldataRetriever', () => { { target: rollupAddress.toString() as Hex, callData: invalidateBadCalldata }, ]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when multiple propose calls', () => { - const proposeCalldata1 = makeProposeCalldata(); - const proposeCalldata2 = makeProposeCalldata(); - - const tx = makeMulticall3Transaction([ - { target: rollupAddress.toString() as Hex, callData: proposeCalldata1 }, - { target: rollupAddress.toString() as Hex, callData: proposeCalldata2 }, - ]); + it('should return undefined when empty calls array', () => { + const hashes = mockHashComputation(); + const tx = makeMulticall3Transaction([]); - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); - it('should return undefined when calldata too short', () => { - const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: '0x123' as Hex }]); - - const result = retriever.tryDecodeMulticall3(tx); - - expect(result).toBeUndefined(); - }); + it('should return undefined when hashes do not match', () => { + const proposeCalldata = makeProposeCalldata(); - it('should return undefined when empty calls array', () => { - const tx = makeMulticall3Transaction([]); + // Mock to return different hashes than expected + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); - const result = retriever.tryDecodeMulticall3(tx); + const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); + // Pass different hashes - validation will fail + const result = retriever.tryDecodeMulticall3( + tx, + { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); expect(result).toBeUndefined(); }); it('should return undefined when decoding throws exception', () => { + const hashes = mockHashComputation(); const tx = { input: '0xinvalid' as Hex, to: MULTI_CALL_3_ADDRESS as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeMulticall3(tx); + const result = retriever.tryDecodeMulticall3(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); @@ -559,6 +638,7 @@ describe('CalldataRetriever', () => { describe('tryDecodeDirectPropose', () => { it('should decode direct propose call to rollup', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: rollupAddress.toString() as Hex, @@ -566,13 +646,17 @@ describe('CalldataRetriever', () => { blockHash: Buffer32.random().toString() as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); - expect(result).toBe(proposeCalldata); + expect(result).toBeDefined(); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(result!.archiveRoot).toBeInstanceOf(Fr); + expect(result!.checkpointNumber).toBe(checkpointNumber); }); it('should return undefined when not to rollup address', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const wrongAddress = EthAddress.random(); const tx = { input: proposeCalldata, @@ -580,25 +664,27 @@ describe('CalldataRetriever', () => { hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when to is null', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = { input: proposeCalldata, to: null, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when function is not propose', () => { + const hashes = mockHashComputation(); const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); @@ -610,26 +696,55 @@ describe('CalldataRetriever', () => { hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); expect(result).toBeUndefined(); }); it('should return undefined when input cannot be decoded', () => { + const hashes = mockHashComputation(); const tx = { input: '0xinvalid' as Hex, to: rollupAddress.toString() as Hex, hash: '0x123' as Hex, } as Transaction; - const result = retriever.tryDecodeDirectPropose(tx); + const result = retriever.tryDecodeDirectPropose(tx, hashes, checkpointNumber, blockHash as Hex); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when hashes do not match', () => { + const proposeCalldata = makeProposeCalldata(); + + // Mock to return different hashes than expected + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const tx = { + input: proposeCalldata, + to: rollupAddress.toString() as Hex, + hash: '0x123' as Hex, + } as Transaction; + + const result = retriever.tryDecodeDirectPropose( + tx, + { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); expect(result).toBeUndefined(); }); }); describe('tryDecodeSpireProposer', () => { - function makeSpireProposerMulticallTransaction(call: { target: Hex; data: Hex }): Transaction { + function makeSpireProposerMulticallTransaction(calls: { target: Hex; data: Hex }[]): Transaction { const spireMulticallData = encodeFunctionData({ abi: [ { @@ -655,15 +770,13 @@ describe('CalldataRetriever', () => { ] as const, functionName: 'multicall', args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: call.target, - data: call.data, - value: 0n, - gasLimit: 1000000n, - }, - ], + calls.map(call => ({ + proposer: EthAddress.random().toString() as Hex, + target: call.target, + data: call.data, + value: 0n, + gasLimit: 1000000n, + })), ], }); @@ -677,21 +790,21 @@ describe('CalldataRetriever', () => { it('should decode Spire Proposer with direct propose call', async () => { const proposeCalldata = makeProposeCalldata(); - const tx = makeSpireProposerMulticallTransaction({ - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - }); + const tx = makeSpireProposerMulticallTransaction([ + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + ]); // Mock the proxy implementation verification publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(rollupAddress.toString().toLowerCase()); - expect(result?.data).toBe(proposeCalldata); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(rollupAddress.toString().toLowerCase()); + expect(result![0].data).toBe(proposeCalldata); expect(publicClient.getStorageAt).toHaveBeenCalledWith({ address: SPIRE_PROPOSER_ADDRESS, slot: EIP1967_IMPLEMENTATION_SLOT, @@ -706,21 +819,37 @@ describe('CalldataRetriever', () => { args: [[{ target: rollupAddress.toString() as Hex, allowFailure: false, callData: proposeCalldata }]], }); - const tx = makeSpireProposerMulticallTransaction({ - target: MULTI_CALL_3_ADDRESS as Hex, - data: multicall3Data, - }); + const tx = makeSpireProposerMulticallTransaction([{ target: MULTI_CALL_3_ADDRESS as Hex, data: multicall3Data }]); // Mock the proxy implementation verification publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to).toBe(MULTI_CALL_3_ADDRESS); - expect(result?.data).toBe(multicall3Data); + expect(result).toHaveLength(1); + expect(result![0].to).toBe(MULTI_CALL_3_ADDRESS); + expect(result![0].data).toBe(multicall3Data); + }); + + it('should return all calls when Spire Proposer contains multiple calls', async () => { + const proposeCalldata = makeProposeCalldata(); + const tx = makeSpireProposerMulticallTransaction([ + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + ]); + + // Mock the proxy implementation verification + publicClient.getStorageAt.mockResolvedValue( + ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, + ); + + const result = await getCallsFromSpireProposer(tx, publicClient, logger); + + expect(result).toBeDefined(); + expect(result).toHaveLength(2); }); it('should return undefined when not to Spire Proposer address', async () => { @@ -731,7 +860,7 @@ describe('CalldataRetriever', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); expect(publicClient.getStorageAt).not.toHaveBeenCalled(); @@ -739,135 +868,36 @@ describe('CalldataRetriever', () => { it('should return undefined when proxy implementation verification fails', async () => { const proposeCalldata = makeProposeCalldata(); - const tx = makeSpireProposerMulticallTransaction({ - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - }); + const tx = makeSpireProposerMulticallTransaction([ + { target: rollupAddress.toString() as Hex, data: proposeCalldata }, + ]); // Mock the proxy pointing to wrong implementation publicClient.getStorageAt.mockResolvedValue('0x000000000000000000000000wrongimplementation0000000000' as Hex); - const result = await getCallFromSpireProposer(tx, publicClient, logger); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when Spire Proposer contains multiple calls', async () => { - const proposeCalldata = makeProposeCalldata(); - const spireMulticallData = encodeFunctionData({ - abi: [ - { - inputs: [ - { - components: [ - { internalType: 'address', name: 'proposer', type: 'address' }, - { internalType: 'address', name: 'target', type: 'address' }, - { internalType: 'bytes', name: 'data', type: 'bytes' }, - { internalType: 'uint256', name: 'value', type: 'uint256' }, - { internalType: 'uint256', name: 'gasLimit', type: 'uint256' }, - ], - internalType: 'struct IProposerMulticall.Call[]', - name: '_calls', - type: 'tuple[]', - }, - ], - name: 'multicall', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - ] as const, - functionName: 'multicall', - args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - value: 0n, - gasLimit: 1000000n, - }, - { - proposer: EthAddress.random().toString() as Hex, - target: rollupAddress.toString() as Hex, - data: proposeCalldata, - value: 0n, - gasLimit: 1000000n, - }, - ], - ], - }); - - const tx = { - input: spireMulticallData, - blockHash, - to: SPIRE_PROPOSER_ADDRESS as Hex, - hash: txHash, - } as Transaction; - - // Mock the proxy implementation verification - publicClient.getStorageAt.mockResolvedValue( - ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, - ); - - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); it('should extract call even if target is unknown (validation happens in next step)', async () => { const unknownAddress = EthAddress.random(); - const tx = makeSpireProposerMulticallTransaction({ - target: unknownAddress.toString() as Hex, - data: '0x12345678' as Hex, - }); + const tx = makeSpireProposerMulticallTransaction([ + { target: unknownAddress.toString() as Hex, data: '0x12345678' as Hex }, + ]); // Mock the proxy implementation verification publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); // Spire proposer should successfully extract the call, even if target is unknown - // The validation of the target happens in the next step (tryDecodeMulticall3 or tryDecodeDirectPropose) expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(unknownAddress.toString().toLowerCase()); - expect(result?.data).toBe('0x12345678'); - }); - - it('should extract multicall3 call (validation of inner calls happens in next step)', async () => { - const proposeCalldata = makeProposeCalldata(); - const invalidCalldata = '0x99999999' as Hex; // Unknown selector - - const multicall3Data = encodeFunctionData({ - abi: multicall3Abi, - functionName: 'aggregate3', - args: [ - [ - { target: rollupAddress.toString() as Hex, allowFailure: false, callData: proposeCalldata }, - { target: rollupAddress.toString() as Hex, allowFailure: false, callData: invalidCalldata }, - ], - ], - }); - - const tx = makeSpireProposerMulticallTransaction({ - target: MULTI_CALL_3_ADDRESS as Hex, - data: multicall3Data, - }); - - // Mock the proxy implementation verification - publicClient.getStorageAt.mockResolvedValue( - ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, - ); - - const result = await getCallFromSpireProposer(tx, publicClient, logger); - - // Spire proposer should successfully extract the multicall3 call - // Validation of the inner calls happens in tryDecodeMulticall3 - expect(result).toBeDefined(); - expect(result?.to).toBe(MULTI_CALL_3_ADDRESS); - expect(result?.data).toBe(multicall3Data); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(unknownAddress.toString().toLowerCase()); + expect(result![0].data).toBe('0x12345678'); }); }); @@ -1102,57 +1132,103 @@ describe('CalldataRetriever', () => { }); }); - describe('decodeAndBuildCheckpoint', () => { - const blockHash = Fr.random().toString() as Hex; - const checkpointNumber = CheckpointNumber(42); - - it('should correctly decode propose calldata and build checkpoint', () => { + describe('tryDecodeAndVerifyPropose', () => { + it('should decode and verify propose calldata successfully', () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); - const result = retriever.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, {}); + const result = retriever.tryDecodeAndVerifyPropose(proposeCalldata, hashes, checkpointNumber, blockHash as Hex); - expect(result.checkpointNumber).toBe(checkpointNumber); - expect(result.header).toBeInstanceOf(CheckpointHeader); - expect(result.archiveRoot).toBeInstanceOf(Fr); - expect(Array.isArray(result.attestations)).toBe(true); - expect(result.blockHash).toBe(blockHash); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(checkpointNumber); + expect(result!.header).toBeInstanceOf(CheckpointHeader); + expect(result!.archiveRoot).toBeInstanceOf(Fr); + expect(Array.isArray(result!.attestations)).toBe(true); + expect(result!.blockHash).toBe(blockHash); }); it('should handle attestations correctly', () => { const attestations = makeViemCommitteeAttestations(); const proposeCalldata = makeProposeCalldata(undefined, attestations); + const hashes = mockHashComputation(); - const result = retriever.decodeAndBuildCheckpoint(proposeCalldata, blockHash, checkpointNumber, {}); + const result = retriever.tryDecodeAndVerifyPropose(proposeCalldata, hashes, checkpointNumber, blockHash as Hex); - expect(result.attestations).toHaveLength(TARGET_COMMITTEE_SIZE); + expect(result).toBeDefined(); + expect(result!.attestations).toHaveLength(TARGET_COMMITTEE_SIZE); }); - it('should throw when calldata is not for propose function', () => { + it('should return undefined when calldata is not for propose function', () => { const invalidateBadSelector = toFunctionSelector( RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, ); const invalidCalldata = (invalidateBadSelector + '0'.repeat(120)) as Hex; + const hashes = mockHashComputation(); + + const result = retriever.tryDecodeAndVerifyPropose(invalidCalldata, hashes, checkpointNumber, blockHash as Hex); - expect(() => retriever.decodeAndBuildCheckpoint(invalidCalldata, blockHash, checkpointNumber, {})).toThrow(); + expect(result).toBeUndefined(); }); - it('should throw when calldata is malformed', () => { + it('should return undefined when calldata is malformed', () => { const malformedCalldata = '0xinvalid' as Hex; + const hashes = mockHashComputation(); + + const result = retriever.tryDecodeAndVerifyPropose(malformedCalldata, hashes, checkpointNumber, blockHash as Hex); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when attestationsHash does not match', () => { + const proposeCalldata = makeProposeCalldata(); + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const result = retriever.tryDecodeAndVerifyPropose( + proposeCalldata, + { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); - expect(() => retriever.decodeAndBuildCheckpoint(malformedCalldata, blockHash, checkpointNumber, {})).toThrow(); + expect(result).toBeUndefined(); + }); + + it('should return undefined when payloadDigest does not match', () => { + const proposeCalldata = makeProposeCalldata(); + mockHashComputation( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex, + ); + + const result = retriever.tryDecodeAndVerifyPropose( + proposeCalldata, + { + attestationsHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }, + checkpointNumber, + blockHash as Hex, + ); + + expect(result).toBeUndefined(); }); }); describe('integration', () => { - const checkpointNumber = CheckpointNumber(42); - it('should complete full flow from tx hash to checkpoint via multicall3', async () => { const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); const tx = makeMulticall3Transaction([{ target: rollupAddress.toString() as Hex, callData: proposeCalldata }]); publicClient.getTransaction.mockResolvedValue(tx); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result).toBeDefined(); expect(result.checkpointNumber).toBe(checkpointNumber); @@ -1174,6 +1250,7 @@ describe('CalldataRetriever', () => { const SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION = '0x7d38d47e7c82195e6e607d3b0f1c20c615c7bf42'; const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation(); // Create Spire Proposer multicall transaction const spireMulticallData = encodeFunctionData({ @@ -1225,7 +1302,7 @@ describe('CalldataRetriever', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, {}); + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); expect(result).toBeDefined(); expect(result.checkpointNumber).toBe(checkpointNumber); @@ -1244,5 +1321,141 @@ describe('CalldataRetriever', () => { // Verify instrumentation was called with Spire Proposer address expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(SPIRE_PROPOSER_ADDRESS, false); }); + + it('should succeed via hash matching when multicall3 has unknown calls', async () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation( + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex, + '0x0fedcba987654321fedcba987654321fedcba987654321fedcba987654321fed' as Hex, + ); + const unknownAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + publicClient.getTransaction.mockResolvedValue(tx); + + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); + + expect(result.checkpointNumber).toBe(checkpointNumber); + expect(result.header).toBeInstanceOf(CheckpointHeader); + expect(result.archiveRoot).toBeInstanceOf(Fr); + expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(MULTI_CALL_3_ADDRESS, false); + }); + + it('should succeed via Spire-wrapped multicall3 with unknown calls', async () => { + const proposeCalldata = makeProposeCalldata(); + const hashes = mockHashComputation( + '0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba' as Hex, + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' as Hex, + ); + const unknownAddress = EthAddress.random(); + + const multicall3Data = encodeFunctionData({ + abi: multicall3Abi, + functionName: 'aggregate3', + args: [ + [ + { target: unknownAddress.toString() as Hex, allowFailure: false, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, allowFailure: false, callData: proposeCalldata }, + ], + ], + }); + + const spireMulticallData = encodeFunctionData({ + abi: [ + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'proposer', type: 'address' }, + { internalType: 'address', name: 'target', type: 'address' }, + { internalType: 'bytes', name: 'data', type: 'bytes' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + { internalType: 'uint256', name: 'gasLimit', type: 'uint256' }, + ], + internalType: 'struct IProposerMulticall.Call[]', + name: '_calls', + type: 'tuple[]', + }, + ], + name: 'multicall', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ] as const, + functionName: 'multicall', + args: [ + [ + { + proposer: EthAddress.random().toString() as Hex, + target: MULTI_CALL_3_ADDRESS as Hex, + data: multicall3Data, + value: 0n, + gasLimit: 1000000n, + }, + ], + ], + }); + + const tx = { + input: spireMulticallData, + blockHash, + to: SPIRE_PROPOSER_ADDRESS as Hex, + hash: txHash, + } as Transaction; + + publicClient.getTransaction.mockResolvedValue(tx); + publicClient.getStorageAt.mockResolvedValue( + ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, + ); + + const result = await retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, hashes); + + expect(result.checkpointNumber).toBe(checkpointNumber); + expect(result.header).toBeInstanceOf(CheckpointHeader); + expect(instrumentation.recordBlockProposalTxTarget).toHaveBeenCalledWith(SPIRE_PROPOSER_ADDRESS, false); + }); + + it('should fall back to trace with wrong hashes and final decode throws mismatch', async () => { + const proposeCalldata = makeProposeCalldata(); + const wrongHashes = { + attestationsHash: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, + payloadDigest: '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex, + }; + const unknownAddress = EthAddress.random(); + + const tx = makeMulticall3Transaction([ + { target: unknownAddress.toString() as Hex, callData: '0x12345678' as Hex }, + { target: rollupAddress.toString() as Hex, callData: proposeCalldata }, + ]); + + publicClient.getTransaction.mockResolvedValue(tx); + + // Mock trace to return the propose calldata (trace succeeds but final hash validation fails) + debugClient.request.mockResolvedValueOnce([ + { + type: 'call', + action: { + from: EthAddress.random().toString(), + to: rollupAddress.toString(), + callType: 'call', + input: proposeCalldata, + value: '0x0', + gas: '0x5208', + }, + result: { output: '0x', gasUsed: '0x5208' }, + subtraces: 0, + traceAddress: [], + }, + ]); + + await expect(retriever.getCheckpointFromRollupTx(txHash, [], checkpointNumber, wrongHashes)).rejects.toThrow( + /hash mismatch/i, + ); + }); }); }); diff --git a/yarn-project/archiver/src/l1/calldata_retriever.ts b/yarn-project/archiver/src/l1/calldata_retriever.ts index f023bfbac865..e21f499b9e90 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.ts @@ -3,15 +3,8 @@ import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/ty import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; -import type { ViemSignature } from '@aztec/foundation/eth-signature'; import type { Logger } from '@aztec/foundation/log'; -import { - EmpireSlashingProposerAbi, - GovernanceProposerAbi, - RollupAbi, - SlashFactoryAbi, - TallySlashingProposerAbi, -} from '@aztec/l1-artifacts'; +import { RollupAbi } from '@aztec/l1-artifacts'; import { CommitteeAttestation } from '@aztec/stdlib/block'; import { ConsensusPayload, SignatureDomainSeparator } from '@aztec/stdlib/p2p'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; @@ -30,13 +23,24 @@ import { import type { ArchiverInstrumentation } from '../modules/instrumentation.js'; import { getSuccessfulCallsFromDebug } from './debug_tx.js'; -import { getCallFromSpireProposer } from './spire_proposer.js'; +import { getCallsFromSpireProposer } from './spire_proposer.js'; import { getSuccessfulCallsFromTrace } from './trace_tx.js'; import type { CallInfo } from './types.js'; +/** Decoded checkpoint data from a propose calldata. */ +type CheckpointData = { + checkpointNumber: CheckpointNumber; + archiveRoot: Fr; + header: CheckpointHeader; + attestations: CommitteeAttestation[]; + blockHash: string; + feeAssetPriceModifier: bigint; +}; + /** * Extracts calldata to the `propose` method of the rollup contract from an L1 transaction - * in order to reconstruct an L2 block header. + * in order to reconstruct an L2 block header. Uses hash matching against expected hashes + * from the CheckpointProposed event to verify the correct propose calldata. */ export class CalldataRetriever { /** Tx hashes we've already logged for trace+debug failure (log once per tx per process). */ @@ -47,27 +51,14 @@ export class CalldataRetriever { CalldataRetriever.traceFailureWarnedTxHashes.clear(); } - /** Pre-computed valid contract calls for validation */ - private readonly validContractCalls: ValidContractCall[]; - - private readonly rollupAddress: EthAddress; - constructor( private readonly publicClient: ViemPublicClient, private readonly debugClient: ViemPublicDebugClient, private readonly targetCommitteeSize: number, private readonly instrumentation: ArchiverInstrumentation | undefined, private readonly logger: Logger, - contractAddresses: { - rollupAddress: EthAddress; - governanceProposerAddress: EthAddress; - slashingProposerAddress: EthAddress; - slashFactoryAddress?: EthAddress; - }, - ) { - this.rollupAddress = contractAddresses.rollupAddress; - this.validContractCalls = computeValidContractCalls(contractAddresses); - } + private readonly rollupAddress: EthAddress, + ) {} /** * Gets checkpoint header and metadata from the calldata of an L1 transaction. @@ -75,7 +66,7 @@ export class CalldataRetriever { * @param txHash - Hash of the tx that published it. * @param blobHashes - Blob hashes for the checkpoint. * @param checkpointNumber - Checkpoint number. - * @param expectedHashes - Optional expected hashes from the CheckpointProposed event for validation + * @param expectedHashes - Expected hashes from the CheckpointProposed event for validation * @returns Checkpoint header and metadata from the calldata, deserialized */ async getCheckpointFromRollupTx( @@ -83,51 +74,43 @@ export class CalldataRetriever { _blobHashes: Buffer[], checkpointNumber: CheckpointNumber, expectedHashes: { - attestationsHash?: Hex; - payloadDigest?: Hex; + attestationsHash: Hex; + payloadDigest: Hex; }, - ): Promise<{ - checkpointNumber: CheckpointNumber; - archiveRoot: Fr; - header: CheckpointHeader; - attestations: CommitteeAttestation[]; - blockHash: string; - feeAssetPriceModifier: bigint; - }> { - this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`, { - willValidateHashes: !!expectedHashes.attestationsHash || !!expectedHashes.payloadDigest, - hasAttestationsHash: !!expectedHashes.attestationsHash, - hasPayloadDigest: !!expectedHashes.payloadDigest, - }); + ): Promise { + this.logger.trace(`Fetching checkpoint ${checkpointNumber} from rollup tx ${txHash}`); const tx = await this.publicClient.getTransaction({ hash: txHash }); - const proposeCalldata = await this.getProposeCallData(tx, checkpointNumber); - return this.decodeAndBuildCheckpoint(proposeCalldata, tx.blockHash!, checkpointNumber, expectedHashes); + return this.getCheckpointFromTx(tx, checkpointNumber, expectedHashes); } - /** Gets rollup propose calldata from a transaction */ - protected async getProposeCallData(tx: Transaction, checkpointNumber: CheckpointNumber): Promise { - // Try to decode as multicall3 with validation - const proposeCalldata = this.tryDecodeMulticall3(tx); - if (proposeCalldata) { + /** Gets checkpoint data from a transaction by trying decode strategies then falling back to trace. */ + protected async getCheckpointFromTx( + tx: Transaction, + checkpointNumber: CheckpointNumber, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + ): Promise { + // Try to decode as multicall3 with hash-verified matching + const multicall3Result = this.tryDecodeMulticall3(tx, expectedHashes, checkpointNumber, tx.blockHash!); + if (multicall3Result) { this.logger.trace(`Decoded propose calldata from multicall3 for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); - return proposeCalldata; + return multicall3Result; } // Try to decode as direct propose call - const directProposeCalldata = this.tryDecodeDirectPropose(tx); - if (directProposeCalldata) { + const directResult = this.tryDecodeDirectPropose(tx, expectedHashes, checkpointNumber, tx.blockHash!); + if (directResult) { this.logger.trace(`Decoded propose calldata from direct call for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); - return directProposeCalldata; + return directResult; } // Try to decode as Spire Proposer multicall wrapper - const spireProposeCalldata = await this.tryDecodeSpireProposer(tx); - if (spireProposeCalldata) { + const spireResult = await this.tryDecodeSpireProposer(tx, expectedHashes, checkpointNumber, tx.blockHash!); + if (spireResult) { this.logger.trace(`Decoded propose calldata from Spire Proposer for tx ${tx.hash}`); this.instrumentation?.recordBlockProposalTxTarget(tx.to!, false); - return spireProposeCalldata; + return spireResult; } // Fall back to trace-based extraction @@ -135,52 +118,82 @@ export class CalldataRetriever { `Failed to decode multicall3, direct propose, or Spire proposer for L1 tx ${tx.hash}, falling back to trace for checkpoint ${checkpointNumber}`, ); this.instrumentation?.recordBlockProposalTxTarget(tx.to ?? EthAddress.ZERO.toString(), true); - return await this.extractCalldataViaTrace(tx.hash); + const tracedCalldata = await this.extractCalldataViaTrace(tx.hash); + const tracedResult = this.tryDecodeAndVerifyPropose( + tracedCalldata, + expectedHashes, + checkpointNumber, + tx.blockHash!, + ); + if (!tracedResult) { + throw new Error(`Hash mismatch for traced propose calldata in tx ${tx.hash} for checkpoint ${checkpointNumber}`); + } + return tracedResult; } /** * Attempts to decode a transaction as a Spire Proposer multicall wrapper. - * If successful, extracts the wrapped call and validates it as either multicall3 or direct propose. + * If successful, iterates all wrapped calls and validates each as either multicall3 + * or direct propose, verifying against expected hashes. * @param tx - The transaction to decode - * @returns The propose calldata if successfully decoded and validated, undefined otherwise + * @param expectedHashes - Expected hashes for hash-verified matching + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The checkpoint data if successfully decoded and validated, undefined otherwise */ - protected async tryDecodeSpireProposer(tx: Transaction): Promise { - // Try to decode as Spire Proposer multicall (extracts the wrapped call) - const spireWrappedCall = await getCallFromSpireProposer(tx, this.publicClient, this.logger); - if (!spireWrappedCall) { + protected async tryDecodeSpireProposer( + tx: Transaction, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): Promise { + // Try to decode as Spire Proposer multicall (extracts all wrapped calls) + const spireWrappedCalls = await getCallsFromSpireProposer(tx, this.publicClient, this.logger); + if (!spireWrappedCalls) { return undefined; } - this.logger.trace(`Decoded Spire Proposer wrapping for tx ${tx.hash}, inner call to ${spireWrappedCall.to}`); + this.logger.trace(`Decoded Spire Proposer wrapping for tx ${tx.hash}, ${spireWrappedCalls.length} inner call(s)`); - // Now try to decode the wrapped call as either multicall3 or direct propose - const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash }; + // Try each wrapped call as either multicall3 or direct propose + for (const spireWrappedCall of spireWrappedCalls) { + const wrappedTx = { to: spireWrappedCall.to, input: spireWrappedCall.data, hash: tx.hash }; - const multicall3Calldata = this.tryDecodeMulticall3(wrappedTx); - if (multicall3Calldata) { - this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`); - return multicall3Calldata; - } + const multicall3Result = this.tryDecodeMulticall3(wrappedTx, expectedHashes, checkpointNumber, blockHash); + if (multicall3Result) { + this.logger.trace(`Decoded propose calldata from Spire Proposer to multicall3 for tx ${tx.hash}`); + return multicall3Result; + } - const directProposeCalldata = this.tryDecodeDirectPropose(wrappedTx); - if (directProposeCalldata) { - this.logger.trace(`Decoded propose calldata from Spire Proposer to direct propose for tx ${tx.hash}`); - return directProposeCalldata; + const directResult = this.tryDecodeDirectPropose(wrappedTx, expectedHashes, checkpointNumber, blockHash); + if (directResult) { + this.logger.trace(`Decoded propose calldata from Spire Proposer to direct propose for tx ${tx.hash}`); + return directResult; + } } this.logger.warn( - `Spire Proposer wrapped call could not be decoded as multicall3 or direct propose for tx ${tx.hash}`, + `Spire Proposer wrapped calls could not be decoded as multicall3 or direct propose for tx ${tx.hash}`, ); return undefined; } /** * Attempts to decode transaction input as multicall3 and extract propose calldata. - * Returns undefined if validation fails. + * Finds all calls matching the rollup address and propose selector, then decodes + * and verifies each candidate against expected hashes from the CheckpointProposed event. * @param tx - The transaction-like object with to, input, and hash - * @returns The propose calldata if successfully validated, undefined otherwise + * @param expectedHashes - Expected hashes from CheckpointProposed event + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The checkpoint data if successfully validated, undefined otherwise */ - protected tryDecodeMulticall3(tx: { to: Hex | null | undefined; input: Hex; hash: Hex }): Hex | undefined { + protected tryDecodeMulticall3( + tx: { to: Hex | null | undefined; input: Hex; hash: Hex }, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { const txHash = tx.hash; try { @@ -209,59 +222,54 @@ export class CalldataRetriever { const [calls] = multicall3Args; - // Validate all calls and find propose calls + // Find all calls matching rollup address + propose selector const rollupAddressLower = this.rollupAddress.toString().toLowerCase(); - const proposeCalls: Hex[] = []; + const proposeSelectorLower = PROPOSE_SELECTOR.toLowerCase(); + const candidates: Hex[] = []; - for (let i = 0; i < calls.length; i++) { - const addr = calls[i].target.toLowerCase(); - const callData = calls[i].callData; + for (const call of calls) { + const addr = call.target.toLowerCase(); + const callData = call.callData; - // Extract function selector (first 4 bytes) if (callData.length < 10) { - // "0x" + 8 hex chars = 10 chars minimum for a valid function call - this.logger.warn(`Invalid calldata length at index ${i} (${callData.length})`, { txHash }); - return undefined; + continue; } - const functionSelector = callData.slice(0, 10) as Hex; - - // Validate this call is allowed by searching through valid calls - const validCall = this.validContractCalls.find( - vc => vc.address === addr && vc.functionSelector === functionSelector, - ); - if (!validCall) { - this.logger.warn(`Invalid contract call detected in multicall3`, { - index: i, - targetAddress: addr, - functionSelector, - validCalls: this.validContractCalls.map(c => ({ address: c.address, selector: c.functionSelector })), - txHash, - }); - return undefined; + const selector = callData.slice(0, 10).toLowerCase(); + if (addr === rollupAddressLower && selector === proposeSelectorLower) { + candidates.push(callData); } + } - this.logger.trace(`Valid call found to ${addr}`, { validCall }); + if (candidates.length === 0) { + this.logger.debug(`No propose candidates found in multicall3`, { txHash }); + return undefined; + } - // Collect propose calls specifically - if (addr === rollupAddressLower && validCall.functionName === 'propose') { - proposeCalls.push(callData); + // Decode, verify, and build for each candidate + const verified: CheckpointData[] = []; + for (const candidate of candidates) { + const result = this.tryDecodeAndVerifyPropose(candidate, expectedHashes, checkpointNumber, blockHash); + if (result) { + verified.push(result); } } - // Validate exactly ONE propose call - if (proposeCalls.length === 0) { - this.logger.warn(`No propose calls found in multicall3`, { txHash }); - return undefined; + if (verified.length === 1) { + this.logger.trace(`Verified single propose candidate via hash matching`, { txHash }); + return verified[0]; } - if (proposeCalls.length > 1) { - this.logger.warn(`Multiple propose calls found in multicall3 (${proposeCalls.length})`, { txHash }); - return undefined; + if (verified.length > 1) { + this.logger.warn( + `Multiple propose candidates verified (${verified.length}), returning first (identical data)`, + { txHash }, + ); + return verified[0]; } - // Successfully extracted single propose call - return proposeCalls[0]; + this.logger.debug(`No candidates verified against expected hashes`, { txHash }); + return undefined; } catch (err) { // Any decoding error triggers fallback to trace this.logger.warn(`Failed to decode multicall3: ${err}`, { txHash }); @@ -271,11 +279,19 @@ export class CalldataRetriever { /** * Attempts to decode transaction as a direct propose call to the rollup contract. - * Returns undefined if validation fails. + * Decodes, verifies hashes, and builds checkpoint data in a single pass. * @param tx - The transaction-like object with to, input, and hash - * @returns The propose calldata if successfully validated, undefined otherwise + * @param expectedHashes - Expected hashes from CheckpointProposed event + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The checkpoint data if successfully validated, undefined otherwise */ - protected tryDecodeDirectPropose(tx: { to: Hex | null | undefined; input: Hex; hash: Hex }): Hex | undefined { + protected tryDecodeDirectPropose( + tx: { to: Hex | null | undefined; input: Hex; hash: Hex }, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { const txHash = tx.hash; try { // Check if transaction is to the rollup address @@ -284,18 +300,16 @@ export class CalldataRetriever { return undefined; } - // Try to decode as propose call + // Validate it's a propose call before full decode+verify const { functionName } = decodeFunctionData({ abi: RollupAbi, data: tx.input }); - - // If not propose, return undefined if (functionName !== 'propose') { this.logger.warn(`Transaction to rollup is not propose (got ${functionName})`, { txHash }); return undefined; } - // Successfully validated direct propose call + // Decode, verify hashes, and build checkpoint data this.logger.trace(`Validated direct propose call to rollup`, { txHash }); - return tx.input; + return this.tryDecodeAndVerifyPropose(tx.input, expectedHashes, checkpointNumber, blockHash); } catch (err) { // Any decoding error means it's not a valid propose call this.logger.warn(`Failed to decode as direct propose: ${err}`, { txHash }); @@ -363,10 +377,102 @@ export class CalldataRetriever { return calls[0].input; } + /** + * Decodes propose calldata, verifies against expected hashes, and builds checkpoint data. + * Returns undefined on decode errors or hash mismatches (soft failure for try-based callers). + * @param proposeCalldata - The propose function calldata + * @param expectedHashes - Expected hashes from the CheckpointProposed event + * @param checkpointNumber - The checkpoint number + * @param blockHash - The L1 block hash + * @returns The decoded checkpoint data, or undefined on failure + */ + protected tryDecodeAndVerifyPropose( + proposeCalldata: Hex, + expectedHashes: { attestationsHash: Hex; payloadDigest: Hex }, + checkpointNumber: CheckpointNumber, + blockHash: Hex, + ): CheckpointData | undefined { + try { + const { functionName, args } = decodeFunctionData({ abi: RollupAbi, data: proposeCalldata }); + if (functionName !== 'propose') { + return undefined; + } + + const [decodedArgs, packedAttestations] = args! as readonly [ + { archive: Hex; oracleInput: { feeAssetPriceModifier: bigint }; header: ViemHeader }, + ViemCommitteeAttestations, + ...unknown[], + ]; + + // Verify attestationsHash + const computedAttestationsHash = this.computeAttestationsHash(packedAttestations); + if ( + !Buffer.from(hexToBytes(computedAttestationsHash)).equals( + Buffer.from(hexToBytes(expectedHashes.attestationsHash)), + ) + ) { + this.logger.warn(`Attestations hash mismatch during verification`, { + computed: computedAttestationsHash, + expected: expectedHashes.attestationsHash, + }); + return undefined; + } + + // Verify payloadDigest + const header = CheckpointHeader.fromViem(decodedArgs.header); + const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive))); + const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier; + const computedPayloadDigest = this.computePayloadDigest(header, archiveRoot, feeAssetPriceModifier); + if ( + !Buffer.from(hexToBytes(computedPayloadDigest)).equals(Buffer.from(hexToBytes(expectedHashes.payloadDigest))) + ) { + this.logger.warn(`Payload digest mismatch during verification`, { + computed: computedPayloadDigest, + expected: expectedHashes.payloadDigest, + }); + return undefined; + } + + const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize); + + this.logger.trace(`Validated and decoded propose calldata for checkpoint ${checkpointNumber}`, { + checkpointNumber, + archive: decodedArgs.archive, + header: decodedArgs.header, + l1BlockHash: blockHash, + attestations, + packedAttestations, + targetCommitteeSize: this.targetCommitteeSize, + }); + + return { + checkpointNumber, + archiveRoot, + header, + attestations, + blockHash, + feeAssetPriceModifier, + }; + } catch { + return undefined; + } + } + + /** Computes the keccak256 hash of ABI-encoded CommitteeAttestations. */ + private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex { + return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])); + } + + /** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */ + private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr, feeAssetPriceModifier: bigint): Hex { + const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier); + const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); + return keccak256(payloadToSign); + } + /** * Extracts the CommitteeAttestations struct definition from RollupAbi. * Finds the _attestations parameter by name in the propose function. - * Lazy-loaded to avoid issues during module initialization. */ private getCommitteeAttestationsStructDef(): AbiParameter { const proposeFunction = RollupAbi.find(item => item.type === 'function' && item.name === 'propose') as @@ -399,265 +505,7 @@ export class CalldataRetriever { components: tupleParam.components || [], } as AbiParameter; } - - /** - * Decodes propose calldata and builds the checkpoint header structure. - * @param proposeCalldata - The propose function calldata - * @param blockHash - The L1 block hash containing this transaction - * @param checkpointNumber - The checkpoint number - * @param expectedHashes - Optional expected hashes from the CheckpointProposed event for validation - * @returns The decoded checkpoint header and metadata - */ - protected decodeAndBuildCheckpoint( - proposeCalldata: Hex, - blockHash: Hex, - checkpointNumber: CheckpointNumber, - expectedHashes: { - attestationsHash?: Hex; - payloadDigest?: Hex; - }, - ): { - checkpointNumber: CheckpointNumber; - archiveRoot: Fr; - header: CheckpointHeader; - attestations: CommitteeAttestation[]; - blockHash: string; - feeAssetPriceModifier: bigint; - } { - const { functionName: rollupFunctionName, args: rollupArgs } = decodeFunctionData({ - abi: RollupAbi, - data: proposeCalldata, - }); - - if (rollupFunctionName !== 'propose') { - throw new Error(`Unexpected rollup method called ${rollupFunctionName}`); - } - - const [decodedArgs, packedAttestations, _signers, _attestationsAndSignersSignature, _blobInput] = - rollupArgs! as readonly [ - { - archive: Hex; - oracleInput: { feeAssetPriceModifier: bigint }; - header: ViemHeader; - }, - ViemCommitteeAttestations, - Hex[], - ViemSignature, - Hex, - ]; - - const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize); - const header = CheckpointHeader.fromViem(decodedArgs.header); - const archiveRoot = new Fr(Buffer.from(hexToBytes(decodedArgs.archive))); - - // Validate attestationsHash if provided (skip for backwards compatibility with older events) - if (expectedHashes.attestationsHash) { - // Compute attestationsHash: keccak256(abi.encode(CommitteeAttestations)) - const computedAttestationsHash = keccak256( - encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations]), - ); - - // Compare as buffers to avoid case-sensitivity and string comparison issues - const computedBuffer = Buffer.from(hexToBytes(computedAttestationsHash)); - const expectedBuffer = Buffer.from(hexToBytes(expectedHashes.attestationsHash)); - - if (!computedBuffer.equals(expectedBuffer)) { - throw new Error( - `Attestations hash mismatch for checkpoint ${checkpointNumber}: ` + - `computed=${computedAttestationsHash}, expected=${expectedHashes.attestationsHash}`, - ); - } - - this.logger.trace(`Validated attestationsHash for checkpoint ${checkpointNumber}`, { - computedAttestationsHash, - expectedAttestationsHash: expectedHashes.attestationsHash, - }); - } - - // Validate payloadDigest if provided (skip for backwards compatibility with older events) - if (expectedHashes.payloadDigest) { - // Use ConsensusPayload to compute the digest - this ensures we match the exact logic - // used by the network for signing and verification - const feeAssetPriceModifier = decodedArgs.oracleInput.feeAssetPriceModifier; - const consensusPayload = new ConsensusPayload(header, archiveRoot, feeAssetPriceModifier); - const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); - const computedPayloadDigest = keccak256(payloadToSign); - - // Compare as buffers to avoid case-sensitivity and string comparison issues - const computedBuffer = Buffer.from(hexToBytes(computedPayloadDigest)); - const expectedBuffer = Buffer.from(hexToBytes(expectedHashes.payloadDigest)); - - if (!computedBuffer.equals(expectedBuffer)) { - throw new Error( - `Payload digest mismatch for checkpoint ${checkpointNumber}: ` + - `computed=${computedPayloadDigest}, expected=${expectedHashes.payloadDigest}`, - ); - } - - this.logger.trace(`Validated payloadDigest for checkpoint ${checkpointNumber}`, { - computedPayloadDigest, - expectedPayloadDigest: expectedHashes.payloadDigest, - }); - } - - this.logger.trace(`Decoded propose calldata`, { - checkpointNumber, - archive: decodedArgs.archive, - header: decodedArgs.header, - l1BlockHash: blockHash, - attestations, - packedAttestations, - targetCommitteeSize: this.targetCommitteeSize, - }); - - return { - checkpointNumber, - archiveRoot, - header, - attestations, - blockHash, - feeAssetPriceModifier: decodedArgs.oracleInput.feeAssetPriceModifier, - }; - } } -/** - * Pre-computed function selectors for all valid contract calls. - * These are computed once at module load time from the ABIs. - * Based on analysis of sequencer-client/src/publisher/sequencer-publisher.ts - */ - -// Rollup contract function selectors (always valid) +/** Function selector for the `propose` method of the rollup contract. */ const PROPOSE_SELECTOR = toFunctionSelector(RollupAbi.find(x => x.type === 'function' && x.name === 'propose')!); -const INVALIDATE_BAD_ATTESTATION_SELECTOR = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateBadAttestation')!, -); -const INVALIDATE_INSUFFICIENT_ATTESTATIONS_SELECTOR = toFunctionSelector( - RollupAbi.find(x => x.type === 'function' && x.name === 'invalidateInsufficientAttestations')!, -); - -// Governance proposer function selectors -const GOVERNANCE_SIGNAL_WITH_SIG_SELECTOR = toFunctionSelector( - GovernanceProposerAbi.find(x => x.type === 'function' && x.name === 'signalWithSig')!, -); - -// Slash factory function selectors -const CREATE_SLASH_PAYLOAD_SELECTOR = toFunctionSelector( - SlashFactoryAbi.find(x => x.type === 'function' && x.name === 'createSlashPayload')!, -); - -// Empire slashing proposer function selectors -const EMPIRE_SIGNAL_WITH_SIG_SELECTOR = toFunctionSelector( - EmpireSlashingProposerAbi.find(x => x.type === 'function' && x.name === 'signalWithSig')!, -); -const EMPIRE_SUBMIT_ROUND_WINNER_SELECTOR = toFunctionSelector( - EmpireSlashingProposerAbi.find(x => x.type === 'function' && x.name === 'submitRoundWinner')!, -); - -// Tally slashing proposer function selectors -const TALLY_VOTE_SELECTOR = toFunctionSelector( - TallySlashingProposerAbi.find(x => x.type === 'function' && x.name === 'vote')!, -); -const TALLY_EXECUTE_ROUND_SELECTOR = toFunctionSelector( - TallySlashingProposerAbi.find(x => x.type === 'function' && x.name === 'executeRound')!, -); - -/** - * Defines a valid contract call that can appear in a sequencer publisher transaction - */ -interface ValidContractCall { - /** Contract address (lowercase for comparison) */ - address: string; - /** Function selector (4 bytes) */ - functionSelector: Hex; - /** Human-readable function name for logging */ - functionName: string; -} - -/** - * All valid contract calls that the sequencer publisher can make. - * Builds the list of valid (address, selector) pairs for validation. - * - * Alternatively, if we are absolutely sure that no code path from any of these - * contracts can eventually land on another call to `propose`, we can remove the - * function selectors. - */ -function computeValidContractCalls(addresses: { - rollupAddress: EthAddress; - governanceProposerAddress?: EthAddress; - slashFactoryAddress?: EthAddress; - slashingProposerAddress?: EthAddress; -}): ValidContractCall[] { - const { rollupAddress, governanceProposerAddress, slashFactoryAddress, slashingProposerAddress } = addresses; - const calls: ValidContractCall[] = []; - - // Rollup contract calls (always present) - calls.push( - { - address: rollupAddress.toString().toLowerCase(), - functionSelector: PROPOSE_SELECTOR, - functionName: 'propose', - }, - { - address: rollupAddress.toString().toLowerCase(), - functionSelector: INVALIDATE_BAD_ATTESTATION_SELECTOR, - functionName: 'invalidateBadAttestation', - }, - { - address: rollupAddress.toString().toLowerCase(), - functionSelector: INVALIDATE_INSUFFICIENT_ATTESTATIONS_SELECTOR, - functionName: 'invalidateInsufficientAttestations', - }, - ); - - // Governance proposer calls (optional) - if (governanceProposerAddress && !governanceProposerAddress.isZero()) { - calls.push({ - address: governanceProposerAddress.toString().toLowerCase(), - functionSelector: GOVERNANCE_SIGNAL_WITH_SIG_SELECTOR, - functionName: 'signalWithSig', - }); - } - - // Slash factory calls (optional) - if (slashFactoryAddress && !slashFactoryAddress.isZero()) { - calls.push({ - address: slashFactoryAddress.toString().toLowerCase(), - functionSelector: CREATE_SLASH_PAYLOAD_SELECTOR, - functionName: 'createSlashPayload', - }); - } - - // Slashing proposer calls (optional, can be either Empire or Tally) - if (slashingProposerAddress && !slashingProposerAddress.isZero()) { - // Empire calls - calls.push( - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: EMPIRE_SIGNAL_WITH_SIG_SELECTOR, - functionName: 'signalWithSig (empire)', - }, - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: EMPIRE_SUBMIT_ROUND_WINNER_SELECTOR, - functionName: 'submitRoundWinner', - }, - ); - - // Tally calls - calls.push( - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: TALLY_VOTE_SELECTOR, - functionName: 'vote', - }, - { - address: slashingProposerAddress.toString().toLowerCase(), - functionSelector: TALLY_EXECUTE_ROUND_SELECTOR, - functionName: 'executeRound', - }, - ); - } - - return calls; -} diff --git a/yarn-project/archiver/src/l1/data_retrieval.ts b/yarn-project/archiver/src/l1/data_retrieval.ts index 54b6dd62207d..4f5a529f1aae 100644 --- a/yarn-project/archiver/src/l1/data_retrieval.ts +++ b/yarn-project/archiver/src/l1/data_retrieval.ts @@ -157,11 +157,6 @@ export async function retrieveCheckpointsFromRollup( blobClient: BlobClientInterface, searchStartBlock: bigint, searchEndBlock: bigint, - contractAddresses: { - governanceProposerAddress: EthAddress; - slashFactoryAddress?: EthAddress; - slashingProposerAddress: EthAddress; - }, instrumentation: ArchiverInstrumentation, logger: Logger = createLogger('archiver'), isHistoricalSync: boolean = false, @@ -205,7 +200,6 @@ export async function retrieveCheckpointsFromRollup( blobClient, checkpointProposedLogs, rollupConstants, - contractAddresses, instrumentation, logger, isHistoricalSync, @@ -226,7 +220,6 @@ export async function retrieveCheckpointsFromRollup( * @param blobClient - The blob client client for fetching blob data. * @param logs - CheckpointProposed logs. * @param rollupConstants - The rollup constants (chainId, version, targetCommitteeSize). - * @param contractAddresses - The contract addresses (governanceProposerAddress, slashFactoryAddress, slashingProposerAddress). * @param instrumentation - The archiver instrumentation instance. * @param logger - The logger instance. * @param isHistoricalSync - Whether this is a historical sync. @@ -239,11 +232,6 @@ async function processCheckpointProposedLogs( blobClient: BlobClientInterface, logs: CheckpointProposedLog[], { chainId, version, targetCommitteeSize }: { chainId: Fr; version: Fr; targetCommitteeSize: number }, - contractAddresses: { - governanceProposerAddress: EthAddress; - slashFactoryAddress?: EthAddress; - slashingProposerAddress: EthAddress; - }, instrumentation: ArchiverInstrumentation, logger: Logger, isHistoricalSync: boolean, @@ -255,7 +243,7 @@ async function processCheckpointProposedLogs( targetCommitteeSize, instrumentation, logger, - { ...contractAddresses, rollupAddress: EthAddress.fromString(rollup.address) }, + EthAddress.fromString(rollup.address), ); await asyncPool(10, logs, async log => { @@ -266,10 +254,9 @@ async function processCheckpointProposedLogs( // The value from the event and contract will match only if the checkpoint is in the chain. if (archive.equals(archiveFromChain)) { - // Build expected hashes object (fields may be undefined for backwards compatibility with older events) const expectedHashes = { - attestationsHash: log.args.attestationsHash?.toString(), - payloadDigest: log.args.payloadDigest?.toString(), + attestationsHash: log.args.attestationsHash.toString() as Hex, + payloadDigest: log.args.payloadDigest.toString() as Hex, }; const checkpoint = await calldataRetriever.getCheckpointFromRollupTx( diff --git a/yarn-project/archiver/src/l1/spire_proposer.test.ts b/yarn-project/archiver/src/l1/spire_proposer.test.ts index ed9148a950ee..3d1c85056222 100644 --- a/yarn-project/archiver/src/l1/spire_proposer.test.ts +++ b/yarn-project/archiver/src/l1/spire_proposer.test.ts @@ -9,7 +9,7 @@ import { SPIRE_PROPOSER_ADDRESS, SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION, SpireProposerAbi, - getCallFromSpireProposer, + getCallsFromSpireProposer, verifyProxyImplementation, } from './spire_proposer.js'; @@ -102,21 +102,19 @@ describe('Spire Proposer', () => { }); }); - describe('getCallFromSpireProposer', () => { - function makeSpireProposerMulticallTransaction(call: { target: Hex; data: Hex }): Transaction { + describe('getCallsFromSpireProposer', () => { + function makeSpireProposerMulticallTransaction(...calls: { target: Hex; data: Hex }[]): Transaction { const spireMulticallData = encodeFunctionData({ abi: SpireProposerAbi, functionName: 'multicall', args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: call.target, - data: call.data, - value: 0n, - gasLimit: 1000000n, - }, - ], + calls.map(call => ({ + proposer: EthAddress.random().toString() as Hex, + target: call.target, + data: call.data, + value: 0n, + gasLimit: 1000000n, + })), ], }); @@ -143,11 +141,12 @@ describe('Spire Proposer', () => { data: calldata, }); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(targetAddress.toLowerCase()); - expect(result?.data).toBe(calldata); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(targetAddress.toLowerCase()); + expect(result![0].data).toBe(calldata); expect(publicClient.getStorageAt).toHaveBeenCalledWith({ address: SPIRE_PROPOSER_ADDRESS, slot: EIP1967_IMPLEMENTATION_SLOT, @@ -161,11 +160,12 @@ describe('Spire Proposer', () => { data: '0xabcdef' as Hex, }); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(unknownAddress.toLowerCase()); - expect(result?.data).toBe('0xabcdef'); + expect(result).toHaveLength(1); + expect(result![0].to.toLowerCase()).toBe(unknownAddress.toLowerCase()); + expect(result![0].data).toBe('0xabcdef'); }); it('should preserve exact calldata bytes', async () => { @@ -176,10 +176,11 @@ describe('Spire Proposer', () => { data: complexCalldata, }); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.data).toBe(complexCalldata); + expect(result).toHaveLength(1); + expect(result![0].data).toBe(complexCalldata); }); }); @@ -191,7 +192,7 @@ describe('Spire Proposer', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); expect(publicClient.getStorageAt).not.toHaveBeenCalled(); @@ -204,7 +205,7 @@ describe('Spire Proposer', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); expect(publicClient.getStorageAt).not.toHaveBeenCalled(); @@ -217,7 +218,7 @@ describe('Spire Proposer', () => { hash: txHash, } as unknown as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); @@ -231,7 +232,7 @@ describe('Spire Proposer', () => { // Mock the proxy pointing to wrong implementation publicClient.getStorageAt.mockResolvedValue('0x00000000000000000000000000000000000000000000000000bad' as Hex); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); @@ -250,12 +251,12 @@ describe('Spire Proposer', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); - it('should return undefined when Spire Proposer contains zero calls', async () => { + it('should return empty array when Spire Proposer contains zero calls', async () => { const spireMulticallData = encodeFunctionData({ abi: SpireProposerAbi, functionName: 'multicall', @@ -272,48 +273,30 @@ describe('Spire Proposer', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); - expect(result).toBeUndefined(); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); }); - it('should return undefined when Spire Proposer contains multiple calls', async () => { - const spireMulticallData = encodeFunctionData({ - abi: SpireProposerAbi, - functionName: 'multicall', - args: [ - [ - { - proposer: EthAddress.random().toString() as Hex, - target: EthAddress.random().toString() as Hex, - data: '0x12345678' as Hex, - value: 0n, - gasLimit: 1000000n, - }, - { - proposer: EthAddress.random().toString() as Hex, - target: EthAddress.random().toString() as Hex, - data: '0xabcdef' as Hex, - value: 0n, - gasLimit: 1000000n, - }, - ], - ], - }); - - const tx = { - input: spireMulticallData, - to: SPIRE_PROPOSER_ADDRESS as Hex, - hash: txHash, - } as Transaction; + it('should return all calls when Spire Proposer contains multiple calls', async () => { + const target1 = EthAddress.random().toString() as Hex; + const target2 = EthAddress.random().toString() as Hex; + const tx = makeSpireProposerMulticallTransaction( + { target: target1, data: '0x12345678' as Hex }, + { target: target2, data: '0xabcdef' as Hex }, + ); publicClient.getStorageAt.mockResolvedValue( ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); - expect(result).toBeUndefined(); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result![0].to.toLowerCase()).toBe(target1.toLowerCase()); + expect(result![1].to.toLowerCase()).toBe(target2.toLowerCase()); }); it('should return undefined when decoding throws exception', async () => { @@ -327,7 +310,7 @@ describe('Spire Proposer', () => { ('0x000000000000000000000000' + SPIRE_PROPOSER_EXPECTED_IMPLEMENTATION.slice(2)) as Hex, ); - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeUndefined(); }); @@ -366,11 +349,11 @@ describe('Spire Proposer', () => { hash: txHash, } as Transaction; - const result = await getCallFromSpireProposer(tx, publicClient, logger); + const result = await getCallsFromSpireProposer(tx, publicClient, logger); expect(result).toBeDefined(); - expect(result?.to.toLowerCase()).toBe(targetAddress.toLowerCase()); - expect(result?.data).toBe(calldata); + expect(result![0].to.toLowerCase()).toBe(targetAddress.toLowerCase()); + expect(result![0].data).toBe(calldata); }); }); }); diff --git a/yarn-project/archiver/src/l1/spire_proposer.ts b/yarn-project/archiver/src/l1/spire_proposer.ts index b328fa5a7fdb..3b7e64ebfd2a 100644 --- a/yarn-project/archiver/src/l1/spire_proposer.ts +++ b/yarn-project/archiver/src/l1/spire_proposer.ts @@ -87,17 +87,17 @@ export async function verifyProxyImplementation( /** * Attempts to decode transaction as a Spire Proposer Multicall. * Spire Proposer is a proxy contract that wraps multiple calls. - * Returns the target address and calldata of the wrapped call if validation succeeds and there is a single call. + * Returns all wrapped calls if validation succeeds (caller handles hash matching to find the propose call). * @param tx - The transaction to decode * @param publicClient - The viem public client for proxy verification * @param logger - Logger instance - * @returns Object with 'to' and 'data' of the wrapped call, or undefined if validation fails + * @returns Array of wrapped calls with 'to' and 'data', or undefined if not a valid Spire Proposer tx */ -export async function getCallFromSpireProposer( +export async function getCallsFromSpireProposer( tx: Transaction, publicClient: { getStorageAt: (params: { address: Hex; slot: Hex }) => Promise }, logger: Logger, -): Promise<{ to: Hex; data: Hex } | undefined> { +): Promise<{ to: Hex; data: Hex }[] | undefined> { const txHash = tx.hash; try { @@ -141,17 +141,9 @@ export async function getCallFromSpireProposer( const [calls] = spireArgs; - // Validate exactly ONE call (see ./README.md for rationale) - if (calls.length !== 1) { - logger.warn(`Spire Proposer multicall must contain exactly one call (got ${calls.length})`, { txHash }); - return undefined; - } - - const call = calls[0]; - - // Successfully extracted the single wrapped call - logger.trace(`Decoded Spire Proposer with single call to ${call.target}`, { txHash }); - return { to: call.target, data: call.data }; + // Return all wrapped calls (hash matching in the caller determines which is the propose call) + logger.trace(`Decoded Spire Proposer with ${calls.length} call(s)`, { txHash }); + return calls.map(call => ({ to: call.target, data: call.data })); } catch (err) { // Any decoding error triggers fallback to trace logger.warn(`Failed to decode Spire Proposer: ${err}`, { txHash }); diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 22b1ed5aba29..ae4bca9dc898 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -1,7 +1,6 @@ import type { BlobClientInterface } from '@aztec/blob-client/client'; import { EpochCache } from '@aztec/epoch-cache'; import { InboxContract, RollupContract } from '@aztec/ethereum/contracts'; -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { L1BlockId } from '@aztec/ethereum/l1-types'; import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types'; import { maxBigint } from '@aztec/foundation/bigint'; @@ -9,7 +8,6 @@ import { BlockNumber, CheckpointNumber, EpochNumber } from '@aztec/foundation/br import { Buffer32 } from '@aztec/foundation/buffer'; import { pick } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { count } from '@aztec/foundation/string'; import { DateProvider, Timer, elapsed } from '@aztec/foundation/timer'; @@ -61,10 +59,6 @@ export class ArchiverL1Synchronizer implements Traceable { private readonly debugClient: ViemPublicDebugClient, private readonly rollup: RollupContract, private readonly inbox: InboxContract, - private readonly l1Addresses: Pick< - L1ContractAddresses, - 'registryAddress' | 'governanceProposerAddress' | 'slashFactoryAddress' - > & { slashingProposerAddress: EthAddress }, private readonly store: KVArchiverDataStore, private config: { batchSize: number; @@ -708,7 +702,6 @@ export class ArchiverL1Synchronizer implements Traceable { this.blobClient, searchStartBlock, // TODO(palla/reorg): If the L2 reorg was due to an L1 reorg, we need to start search earlier searchEndBlock, - this.l1Addresses, this.instrumentation, this.log, !initialSyncComplete, // isHistoricalSync diff --git a/yarn-project/archiver/src/test/fake_l1_state.ts b/yarn-project/archiver/src/test/fake_l1_state.ts index e55a234b544b..b05fd2f8e505 100644 --- a/yarn-project/archiver/src/test/fake_l1_state.ts +++ b/yarn-project/archiver/src/test/fake_l1_state.ts @@ -14,6 +14,7 @@ import { CommitteeAttestation, CommitteeAttestationsAndSigners, L2Block } from ' import { Checkpoint } from '@aztec/stdlib/checkpoint'; import { getSlotAtTimestamp } from '@aztec/stdlib/epoch-helpers'; import { InboxLeaf } from '@aztec/stdlib/messaging'; +import { ConsensusPayload, SignatureDomainSeparator } from '@aztec/stdlib/p2p'; import { makeAndSignCommitteeAttestationsAndSigners, makeCheckpointAttestationFromCheckpoint, @@ -22,7 +23,16 @@ import { import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import { type MockProxy, mock } from 'jest-mock-extended'; -import { type FormattedBlock, type Transaction, encodeFunctionData, multicall3Abi, toHex } from 'viem'; +import { + type AbiParameter, + type FormattedBlock, + type Transaction, + encodeAbiParameters, + encodeFunctionData, + keccak256, + multicall3Abi, + toHex, +} from 'viem'; import { updateRollingHash } from '../structs/inbox_message.js'; @@ -87,6 +97,10 @@ type CheckpointData = { blobHashes: `0x${string}`[]; blobs: Blob[]; signers: Secp256k1Signer[]; + /** Hash of the packed attestations, matching what the L1 event emits. */ + attestationsHash: Buffer32; + /** Payload digest, matching what the L1 event emits. */ + payloadDigest: Buffer32; /** If true, archiveAt will ignore it */ pruned?: boolean; }; @@ -194,8 +208,8 @@ export class FakeL1State { // Store the messages internally so they match the checkpoint's inHash this.addMessages(checkpointNumber, messagesL1BlockNumber, messages); - // Create the transaction and blobs - const tx = await this.makeRollupTx(checkpoint, signers); + // Create the transaction, blobs, and event hashes + const { tx, attestationsHash, payloadDigest } = await this.makeRollupTx(checkpoint, signers); const blobHashes = await this.makeVersionedBlobHashes(checkpoint); const blobs = await this.makeBlobsFromCheckpoint(checkpoint); @@ -208,6 +222,8 @@ export class FakeL1State { blobHashes, blobs, signers, + attestationsHash, + payloadDigest, }); // Update last archive for auto-chaining @@ -510,10 +526,8 @@ export class FakeL1State { checkpointNumber: cpData.checkpointNumber, archive: cpData.checkpoint.archive.root, versionedBlobHashes: cpData.blobHashes.map(h => Buffer.from(h.slice(2), 'hex')), - // These are intentionally undefined to skip hash validation in the archiver - // (validation is skipped when these fields are falsy) - payloadDigest: undefined, - attestationsHash: undefined, + attestationsHash: cpData.attestationsHash, + payloadDigest: cpData.payloadDigest, }, })); } @@ -539,7 +553,10 @@ export class FakeL1State { })); } - private async makeRollupTx(checkpoint: Checkpoint, signers: Secp256k1Signer[]): Promise { + private async makeRollupTx( + checkpoint: Checkpoint, + signers: Secp256k1Signer[], + ): Promise<{ tx: Transaction; attestationsHash: Buffer32; payloadDigest: Buffer32 }> { const attestations = signers .map(signer => makeCheckpointAttestationFromCheckpoint(checkpoint, signer)) .map(attestation => CommitteeAttestation.fromSignature(attestation.signature)) @@ -557,6 +574,8 @@ export class FakeL1State { signers[0], ); + const packedAttestations = attestationsAndSigners.getPackedAttestations(); + const rollupInput = encodeFunctionData({ abi: RollupAbi, functionName: 'propose', @@ -566,7 +585,7 @@ export class FakeL1State { archive, oracleInput: { feeAssetPriceModifier: 0n }, }, - attestationsAndSigners.getPackedAttestations(), + packedAttestations, attestationsAndSigners.getSigners().map(signer => signer.toString()), attestationsAndSignersSignature.toViemSignature(), blobInput, @@ -587,12 +606,43 @@ export class FakeL1State { ], }); - return { + // Compute attestationsHash (same logic as CalldataRetriever) + const attestationsHash = Buffer32.fromString( + keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])), + ); + + // Compute payloadDigest (same logic as CalldataRetriever) + const consensusPayload = ConsensusPayload.fromCheckpoint(checkpoint); + const payloadToSign = consensusPayload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); + const payloadDigest = Buffer32.fromString(keccak256(payloadToSign)); + + const tx = { input: multiCallInput, hash: archive, blockHash: archive, to: MULTI_CALL_3_ADDRESS as `0x${string}`, } as Transaction; + + return { tx, attestationsHash, payloadDigest }; + } + + /** Extracts the CommitteeAttestations struct definition from RollupAbi for hash computation. */ + private getCommitteeAttestationsStructDef(): AbiParameter { + const proposeFunction = RollupAbi.find(item => item.type === 'function' && item.name === 'propose') as + | { type: 'function'; name: string; inputs: readonly AbiParameter[] } + | undefined; + + if (!proposeFunction) { + throw new Error('propose function not found in RollupAbi'); + } + + const attestationsParam = proposeFunction.inputs.find(param => param.name === '_attestations'); + if (!attestationsParam) { + throw new Error('_attestations parameter not found in propose function'); + } + + const tupleParam = attestationsParam as unknown as { type: 'tuple'; components?: readonly AbiParameter[] }; + return { type: 'tuple', components: tupleParam.components || [] } as AbiParameter; } private async makeVersionedBlobHashes(checkpoint: Checkpoint): Promise<`0x${string}`[]> { diff --git a/yarn-project/aztec.js/src/contract/deploy_method.ts b/yarn-project/aztec.js/src/contract/deploy_method.ts index 5c4d9544aa2e..4fc50d66265a 100644 --- a/yarn-project/aztec.js/src/contract/deploy_method.ts +++ b/yarn-project/aztec.js/src/contract/deploy_method.ts @@ -9,7 +9,14 @@ import { getContractInstanceFromInstantiationParams, } from '@aztec/stdlib/contract'; import type { PublicKeys } from '@aztec/stdlib/keys'; -import { type Capsule, TxHash, type TxProfileResult, type TxReceipt, collectOffchainEffects } from '@aztec/stdlib/tx'; +import { + type Capsule, + HashedValues, + TxHash, + type TxProfileResult, + type TxReceipt, + collectOffchainEffects, +} from '@aztec/stdlib/tx'; import { ExecutionPayload, mergeExecutionPayloads } from '@aztec/stdlib/tx'; import { publishContractClass } from '../deployment/publish_class.js'; @@ -164,6 +171,7 @@ export class DeployMethod extends constructorNameOrArtifact?: string | FunctionArtifact, authWitnesses: AuthWitness[] = [], capsules: Capsule[] = [], + private extraHashedArgs: HashedValues[] = [], ) { super(wallet, authWitnesses, capsules); this.constructorArtifact = getInitializer(artifact, constructorNameOrArtifact); @@ -174,20 +182,29 @@ export class DeployMethod extends * @param options - Configuration options. * @returns The execution payload for this operation */ - public async request(options?: RequestDeployOptions): Promise { + public async request(options: RequestDeployOptions = {}): Promise { const publication = await this.getPublicationExecutionPayload(options); if (!options?.skipRegistration) { await this.wallet.registerContract(await this.getInstance(options), this.artifact); } - + const { authWitnesses, capsules } = options; + + // Propagates the included authwitnesses, capsules, and extraHashedArgs + // potentially baked into the interaction + const initialExecutionPayload = new ExecutionPayload( + [], + this.authWitnesses.concat(authWitnesses ?? []), + this.capsules.concat(capsules ?? []), + this.extraHashedArgs, + ); const initialization = await this.getInitializationExecutionPayload(options); const feeExecutionPayload = options?.fee?.paymentMethod ? await options.fee.paymentMethod.getExecutionPayload() : undefined; const finalExecutionPayload = feeExecutionPayload - ? mergeExecutionPayloads([feeExecutionPayload, publication, initialization]) - : mergeExecutionPayloads([publication, initialization]); + ? mergeExecutionPayloads([initialExecutionPayload, feeExecutionPayload, publication, initialization]) + : mergeExecutionPayloads([initialExecutionPayload, publication, initialization]); if (!finalExecutionPayload.calls.length) { throw new Error(`No transactions are needed to publish or initialize contract ${this.artifact.name}`); } diff --git a/yarn-project/aztec.js/src/contract/get_gas_limits.test.ts b/yarn-project/aztec.js/src/contract/get_gas_limits.test.ts index 1fcdc12d2c9c..bf4edcc7c9ab 100644 --- a/yarn-project/aztec.js/src/contract/get_gas_limits.test.ts +++ b/yarn-project/aztec.js/src/contract/get_gas_limits.test.ts @@ -1,4 +1,4 @@ -import { AVM_MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; +import { MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; import { Gas } from '@aztec/stdlib/gas'; import { mockSimulatedTx, mockTxForRollup } from '@aztec/stdlib/testing'; import type { TxSimulationResult } from '@aztec/stdlib/tx'; @@ -50,19 +50,19 @@ describe('getGasLimits', () => { it('should fail if gas exceeds max processable gas', () => { // Consumes all of processable gas txSimulationResult.publicOutput!.gasUsed = { - totalGas: Gas.from({ daGas: 140, l2Gas: AVM_MAX_PROCESSABLE_L2_GAS }), - billedGas: Gas.from({ daGas: 150, l2Gas: AVM_MAX_PROCESSABLE_L2_GAS }), - teardownGas: Gas.from({ daGas: 10, l2Gas: AVM_MAX_PROCESSABLE_L2_GAS * 0.2 }), - publicGas: Gas.from({ daGas: 50, l2Gas: AVM_MAX_PROCESSABLE_L2_GAS }), + totalGas: Gas.from({ daGas: 140, l2Gas: MAX_PROCESSABLE_L2_GAS }), + billedGas: Gas.from({ daGas: 150, l2Gas: MAX_PROCESSABLE_L2_GAS }), + teardownGas: Gas.from({ daGas: 10, l2Gas: MAX_PROCESSABLE_L2_GAS * 0.2 }), + publicGas: Gas.from({ daGas: 50, l2Gas: MAX_PROCESSABLE_L2_GAS }), }; // Does not fail without padding since it's at the limit expect(getGasLimits(txSimulationResult, 0)).toEqual({ - gasLimits: Gas.from({ daGas: 140, l2Gas: AVM_MAX_PROCESSABLE_L2_GAS }), - teardownGasLimits: Gas.from({ daGas: 10, l2Gas: AVM_MAX_PROCESSABLE_L2_GAS * 0.2 }), + gasLimits: Gas.from({ daGas: 140, l2Gas: MAX_PROCESSABLE_L2_GAS }), + teardownGasLimits: Gas.from({ daGas: 10, l2Gas: Math.ceil(MAX_PROCESSABLE_L2_GAS * 0.2) }), }); // Fails with padding since it's over the limit expect(() => getGasLimits(txSimulationResult, 0.1)).toThrow( - 'Transaction consumes more gas than the AVM maximum processable gas', + 'Transaction consumes more l2 gas than the maximum processable gas', ); }); }); diff --git a/yarn-project/aztec.js/src/contract/get_gas_limits.ts b/yarn-project/aztec.js/src/contract/get_gas_limits.ts index d840011cce9f..d54a786c113b 100644 --- a/yarn-project/aztec.js/src/contract/get_gas_limits.ts +++ b/yarn-project/aztec.js/src/contract/get_gas_limits.ts @@ -1,4 +1,4 @@ -import { AVM_MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; +import { MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; import { Gas } from '@aztec/stdlib/gas'; import type { TxSimulationResult } from '@aztec/stdlib/tx'; @@ -23,8 +23,8 @@ export function getGasLimits( const gasLimits = simulationResult.gasUsed.totalGas.mul(1 + pad); const teardownGasLimits = simulationResult.gasUsed.teardownGas.mul(1 + pad); - if (gasLimits.l2Gas > AVM_MAX_PROCESSABLE_L2_GAS) { - throw new Error('Transaction consumes more gas than the AVM maximum processable gas'); + if (gasLimits.l2Gas > MAX_PROCESSABLE_L2_GAS) { + throw new Error('Transaction consumes more l2 gas than the maximum processable gas'); } return { gasLimits, diff --git a/yarn-project/aztec/src/mainnet_compatibility.test.ts b/yarn-project/aztec/src/mainnet_compatibility.test.ts index 9ef7fd974510..5dde99fdd902 100644 --- a/yarn-project/aztec/src/mainnet_compatibility.test.ts +++ b/yarn-project/aztec/src/mainnet_compatibility.test.ts @@ -9,7 +9,7 @@ import { getGenesisValues } from '@aztec/world-state/testing'; */ describe('Mainnet compatibility', () => { it('has expected VK tree root', () => { - const expectedRoots = [Fr.fromHexString('0x1e6494058514e655b4c479e25dc41590b7db8179f2fd71af38cee41f09b895c6')]; + const expectedRoots = [Fr.fromHexString('0x1621e3d2e4f04a6f0318b2099cb1e0afd60261055402e2f3c9ceee28849fb014')]; expect(expectedRoots).toContainEqual(getVKTreeRoot()); }); it('has expected Protocol Contracts tree root', () => { diff --git a/yarn-project/aztec/src/testnet_compatibility.test.ts b/yarn-project/aztec/src/testnet_compatibility.test.ts index 474b8fc114cf..6f339176e77c 100644 --- a/yarn-project/aztec/src/testnet_compatibility.test.ts +++ b/yarn-project/aztec/src/testnet_compatibility.test.ts @@ -11,7 +11,7 @@ import { getGenesisValues } from '@aztec/world-state/testing'; */ describe('Testnet compatibility', () => { it('has expected VK tree root', () => { - const expectedRoots = [Fr.fromHexString('0x1e6494058514e655b4c479e25dc41590b7db8179f2fd71af38cee41f09b895c6')]; + const expectedRoots = [Fr.fromHexString('0x1621e3d2e4f04a6f0318b2099cb1e0afd60261055402e2f3c9ceee28849fb014')]; expect(expectedRoots).toContainEqual(getVKTreeRoot()); }); it('has expected Protocol Contracts hash', () => { diff --git a/yarn-project/constants/src/constants.gen.ts b/yarn-project/constants/src/constants.gen.ts index ddc691f25053..99ea3d02afff 100644 --- a/yarn-project/constants/src/constants.gen.ts +++ b/yarn-project/constants/src/constants.gen.ts @@ -385,20 +385,22 @@ export const AVM_NUM_PUBLIC_INPUT_COLUMNS = 4; export const AVM_PUBLIC_INPUTS_COLUMNS_COMBINED_LENGTH = 18740; export const AVM_V2_PROOF_LENGTH_IN_FIELDS_PADDED = 16400; export const AVM_V2_VERIFICATION_KEY_LENGTH_IN_FIELDS_PADDED = 1000; -export const AVM_MAX_PROCESSABLE_L2_GAS = 6000000; export const DA_BYTES_PER_FIELD = 32; export const DA_GAS_PER_BYTE = 1; export const DA_GAS_PER_FIELD = 32; -export const FIXED_DA_GAS = 96; -export const FIXED_L2_GAS = 512; +export const TX_DA_GAS_OVERHEAD = 96; +export const PUBLIC_TX_L2_GAS_OVERHEAD = 540000; +export const PRIVATE_TX_L2_GAS_OVERHEAD = 440000; export const FIXED_AVM_STARTUP_L2_GAS = 20000; +export const AVM_MAX_PROCESSABLE_L2_GAS = 6000000; +export const MAX_PROCESSABLE_L2_GAS = 6540000; export const MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT = 786432; -export const GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT = 6000000; -export const GAS_ESTIMATION_L2_GAS_LIMIT = 12000000; +export const GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT = 6540000; +export const GAS_ESTIMATION_L2_GAS_LIMIT = 13080000; export const GAS_ESTIMATION_TEARDOWN_DA_GAS_LIMIT = 786432; export const GAS_ESTIMATION_DA_GAS_LIMIT = 1572864; export const DEFAULT_TEARDOWN_L2_GAS_LIMIT = 1000000; -export const DEFAULT_L2_GAS_LIMIT = 6000000; +export const DEFAULT_L2_GAS_LIMIT = 6540000; export const DEFAULT_TEARDOWN_DA_GAS_LIMIT = 393216; export const DEFAULT_DA_GAS_LIMIT = 786432; export const L2_GAS_DISTRIBUTED_STORAGE_PREMIUM = 1024; @@ -408,11 +410,11 @@ export const AVM_MAX_REGISTERS = 6; export const AVM_ADDRESSING_BASE_RESOLUTION_L2_GAS = 3; export const AVM_ADDRESSING_INDIRECT_L2_GAS = 3; export const AVM_ADDRESSING_RELATIVE_L2_GAS = 3; -export const L2_GAS_PER_NOTE_HASH = 0; -export const L2_GAS_PER_NULLIFIER = 0; -export const L2_GAS_PER_L2_TO_L1_MSG = 200; -export const L2_GAS_PER_PRIVATE_LOG = 0; -export const L2_GAS_PER_CONTRACT_CLASS_LOG = 0; +export const L2_GAS_PER_NOTE_HASH = 2700; +export const L2_GAS_PER_NULLIFIER = 16000; +export const L2_GAS_PER_L2_TO_L1_MSG = 5200; +export const L2_GAS_PER_PRIVATE_LOG = 2500; +export const L2_GAS_PER_CONTRACT_CLASS_LOG = 73000; export const AVM_ADD_BASE_L2_GAS = 12; export const AVM_SUB_BASE_L2_GAS = 12; export const AVM_MUL_BASE_L2_GAS = 27; @@ -448,7 +450,7 @@ export const AVM_EMITNULLIFIER_BASE_L2_GAS = 30800; export const AVM_L1TOL2MSGEXISTS_BASE_L2_GAS = 540; export const AVM_GETCONTRACTINSTANCE_BASE_L2_GAS = 6108; export const AVM_EMITPUBLICLOG_BASE_L2_GAS = 15; -export const AVM_SENDL2TOL1MSG_BASE_L2_GAS = 478; +export const AVM_SENDL2TOL1MSG_BASE_L2_GAS = 5239; export const AVM_CALL_BASE_L2_GAS = 9936; export const AVM_STATICCALL_BASE_L2_GAS = 9936; export const AVM_RETURN_BASE_L2_GAS = 9; diff --git a/yarn-project/constants/src/scripts/constants.in.ts b/yarn-project/constants/src/scripts/constants.in.ts index c43076c728a8..f1893cd88c32 100644 --- a/yarn-project/constants/src/scripts/constants.in.ts +++ b/yarn-project/constants/src/scripts/constants.in.ts @@ -28,6 +28,7 @@ const CPP_CONSTANTS = [ 'CONTRACT_CLASS_REGISTRY_CONTRACT_ADDRESS', 'MULTI_CALL_ENTRYPOINT_ADDRESS', 'FEE_JUICE_ADDRESS', + 'TX_DA_GAS_OVERHEAD', 'PUBLIC_CHECKS_ADDRESS', 'FEE_JUICE_BALANCES_SLOT', 'UPDATED_CLASS_IDS_SLOT', @@ -44,6 +45,7 @@ const CPP_CONSTANTS = [ 'MAX_NOTE_HASHES_PER_TX', 'MAX_NULLIFIERS_PER_TX', 'MAX_L2_TO_L1_MSGS_PER_TX', + 'MAX_PROCESSABLE_L2_GAS', 'MAX_PUBLIC_LOGS_PER_TX', 'MAX_PUBLIC_DATA_UPDATE_REQUESTS_PER_TX', 'MAX_PUBLIC_CALLS_TO_UNIQUE_CONTRACT_CLASS_IDS', @@ -111,6 +113,7 @@ const CPP_CONSTANTS = [ 'FLAT_PUBLIC_LOGS_PAYLOAD_LENGTH', 'PUBLIC_LOGS_LENGTH', 'PUBLIC_LOG_HEADER_LENGTH', + 'PUBLIC_TX_L2_GAS_OVERHEAD', 'MAX_PROTOCOL_CONTRACTS', 'DEFAULT_MAX_DEBUG_LOG_MEMORY_READS', ]; @@ -621,7 +624,11 @@ function evaluateExpressions(expressions: [string, string][]): { [key: string]: // We split the expression into terms... .split(/\s+/) // ...and then we convert each term to a BigInt if it is a number. - .map(term => (isNaN(+term) ? term : `BigInt('${term}')`)) + .map(term => { + // Remove underscores from numeric literals (e.g., 6_000_000 -> 6000000) + const termWithoutUnderscores = term.replace(/_/g, ''); + return isNaN(+termWithoutUnderscores) ? term : `BigInt('${termWithoutUnderscores}')`; + }) // .. also, we convert the known bigints to BigInts. .map(term => (knownBigInts.includes(term) ? `BigInt(${term})` : term)) // We join the terms back together. diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts index 1ce73ebffd1c..78db818d3e80 100644 --- a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts @@ -341,9 +341,13 @@ describe('HA Full Setup', () => { logger.info(`Found ${checkpointProposalDuties.length} checkpoint proposal duty`); // Check attestation duties + // All validators attest (tracked in DB), but the checkpoint posted to L1 is trimmed to quorum. const attestationDuties = allDuties.filter(d => d.dutyType === 'ATTESTATION'); - expect(attestationDuties.length).toBe(attestations.length); - logger.info(`Found ${attestationDuties.length} attestation duties`); + expect(attestationDuties.length).toBe(VALIDATOR_COUNT); + expect(attestations.length).toBe(quorum); + logger.info( + `Found ${attestationDuties.length} attestation duties, ${attestations.length} in checkpoint (quorum: ${quorum})`, + ); // Verify no duplicate attestations per validator (HA protection ensures 1 per validator address) const dutiesByValidator = verifyNoDuplicateAttestations(attestationDuties, logger); @@ -361,8 +365,8 @@ describe('HA Full Setup', () => { const p2pAttestations = await p2p.getCheckpointAttestationsForSlot(slot); const p2pAttestationsWithSignatures = p2pAttestations.filter(a => !a.signature.isEmpty()); - // Extract validator addresses from P2P attestations using getSender() - expect(p2pAttestationsWithSignatures.length).toBe(attestations.length); + // P2P pool has attestations from all committee members; checkpoint on L1 is trimmed to quorum + expect(p2pAttestationsWithSignatures.length).toBe(COMMITTEE_SIZE); const p2pValidatorAddresses = new Map(); for (const attestation of p2pAttestationsWithSignatures) { const sender = attestation.getSender(); @@ -662,13 +666,14 @@ describe('HA Full Setup', () => { (info: AttestationInfo) => info.status === 'recovered-from-signature' && info.address !== undefined, ); - // Verify checkpoint has at least quorum attestations + // Verify checkpoint has exactly quorum attestations (trimmed to minimum required) const checkpointValidatorAddresses = new Set(validAttestations.map(info => info.address!.toString())); - expect(checkpointValidatorAddresses.size).toBeGreaterThanOrEqual(quorum); + expect(checkpointValidatorAddresses.size).toBe(quorum); - // Verify checkpoint attestations match database records (each validator in DB should appear in checkpoint) - for (const validatorAddress of dutiesByValidator.keys()) { - expect(checkpointValidatorAddresses.has(validatorAddress)).toBe(true); + // Verify every validator in the checkpoint has a corresponding DB duty record + // (checkpoint is trimmed to quorum, so it's a subset of DB records) + for (const validatorAddress of checkpointValidatorAddresses) { + expect(dutiesByValidator.has(validatorAddress)).toBe(true); } } }); diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts index 233293f72314..9ed66d5b27f6 100644 --- a/yarn-project/end-to-end/src/e2e_bot.test.ts +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -13,7 +13,7 @@ import { SupportedTokenContracts, getBotDefaultConfig, } from '@aztec/bot'; -import { AVM_MAX_PROCESSABLE_L2_GAS, MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT } from '@aztec/constants'; +import { MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; import { SecretValue } from '@aztec/foundation/config'; import { bufferToHex } from '@aztec/foundation/string'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; @@ -73,7 +73,7 @@ describe('e2e_bot', () => { }); it('sends token transfers with hardcoded gas and no simulation', async () => { - bot.updateConfig({ daGasLimit: MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, l2GasLimit: AVM_MAX_PROCESSABLE_L2_GAS }); + bot.updateConfig({ daGasLimit: MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT, l2GasLimit: MAX_PROCESSABLE_L2_GAS }); const { recipient: recipientBefore } = await bot.getBalances(); await bot.run(); diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 30a1ba33ef84..40e10d287284 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -193,10 +193,10 @@ export type CheckpointProposedArgs = { checkpointNumber: CheckpointNumber; archive: Fr; versionedBlobHashes: Buffer[]; - /** Hash of attestations. Undefined for older events (backwards compatibility). */ - attestationsHash?: Buffer32; - /** Digest of the payload. Undefined for older events (backwards compatibility). */ - payloadDigest?: Buffer32; + /** Hash of attestations emitted in the CheckpointProposed event. */ + attestationsHash: Buffer32; + /** Digest of the payload emitted in the CheckpointProposed event. */ + payloadDigest: Buffer32; }; /** Log type for CheckpointProposed events. */ @@ -1060,8 +1060,22 @@ export class RollupContract { checkpointNumber: CheckpointNumber.fromBigInt(log.args.checkpointNumber!), archive: Fr.fromString(log.args.archive!), versionedBlobHashes: log.args.versionedBlobHashes!.map(h => Buffer.from(h.slice(2), 'hex')), - attestationsHash: log.args.attestationsHash ? Buffer32.fromString(log.args.attestationsHash) : undefined, - payloadDigest: log.args.payloadDigest ? Buffer32.fromString(log.args.payloadDigest) : undefined, + attestationsHash: (() => { + if (!log.args.attestationsHash) { + throw new Error( + `CheckpointProposed event missing attestationsHash for checkpoint ${log.args.checkpointNumber}`, + ); + } + return Buffer32.fromString(log.args.attestationsHash); + })(), + payloadDigest: (() => { + if (!log.args.payloadDigest) { + throw new Error( + `CheckpointProposed event missing payloadDigest for checkpoint ${log.args.checkpointNumber}`, + ); + } + return Buffer32.fromString(log.args.payloadDigest); + })(), }, })); } diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts index ae2b886818a9..ecb671eb7d80 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.test.ts @@ -376,7 +376,7 @@ describe('FeePayerBalanceEvictionRule', () => { await rule.evict(context, pool); - expect(mockWorldState.syncImmediate).toHaveBeenCalledWith(BlockNumber(3)); + expect(mockWorldState.syncImmediate).toHaveBeenCalledWith(); expect(mockWorldState.getSnapshot).toHaveBeenCalledWith(BlockNumber(3)); }); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts index 969fd127b1d1..6bd67d2929d0 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/fee_payer_balance_eviction_rule.ts @@ -34,7 +34,7 @@ export class FeePayerBalanceEvictionRule implements EvictionRule { } if (context.event === EvictionEvent.CHAIN_PRUNED) { - await this.worldState.syncImmediate(context.blockNumber); + await this.worldState.syncImmediate(); const feePayers = pool.getPendingFeePayers(); return await this.evictForFeePayers(feePayers, this.worldState.getSnapshot(context.blockNumber), pool); } diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts index 0d02b3431f88..c7ab0ab474a6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.test.ts @@ -137,7 +137,7 @@ describe('InvalidTxsAfterReorgRule', () => { expect(result.txsEvicted).toContain('0x1111'); expect(result.txsEvicted).toContain('0x2222'); // Ensure syncImmediate is called before accessing the world state snapshot - expect(worldState.syncImmediate).toHaveBeenCalledWith(BlockNumber(1)); + expect(worldState.syncImmediate).toHaveBeenCalledWith(); }); it('handles large number of transactions efficiently', async () => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts index 72462a8a687f..782d1beb5cb6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/invalid_txs_after_reorg_rule.ts @@ -45,8 +45,8 @@ export class InvalidTxsAfterReorgRule implements EvictionRule { txsByBlockHash.get(blockHashStr)!.push(meta.txHash); } - // Ensure world state is synced to this block before accessing the snapshot - await this.worldState.syncImmediate(context.blockNumber); + // Sync without a block number to ensure the world state processes the prune event. + await this.worldState.syncImmediate(); const db = this.worldState.getSnapshot(context.blockNumber); // Check which blocks exist in the archive diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.test.ts index 93744abc603b..3915b1ca6652 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.test.ts @@ -43,7 +43,7 @@ describe('LowPriorityEvictionRule', () => { }); describe('evict method', () => { - describe('non-TXS_ADDED events', () => { + describe('BLOCK_MINED events', () => { it('returns empty result for BLOCK_MINED event', async () => { const context: EvictionContext = { event: EvictionEvent.BLOCK_MINED, @@ -60,8 +60,45 @@ describe('LowPriorityEvictionRule', () => { txsEvicted: [], }); }); + }); + + describe('CHAIN_PRUNED events', () => { + it('evicts transactions when pool is over limit', async () => { + rule.updateConfig({ maxPendingTxCount: 2 }); + pool = createPoolOps(4, ['0x3333', '0x4444']); + + const context: EvictionContext = { + event: EvictionEvent.CHAIN_PRUNED, + blockNumber: BlockNumber(1), + }; + + const result = await rule.evict(context, pool); + + expect(result.success).toBe(true); + expect(result.txsEvicted).toEqual(['0x3333', '0x4444']); + expect(deleteTxsMock).toHaveBeenCalledWith(['0x3333', '0x4444'], 'LowPriorityEviction'); + }); + + it('returns empty result when pool is under limit', async () => { + pool = createPoolOps(50); + + const context: EvictionContext = { + event: EvictionEvent.CHAIN_PRUNED, + blockNumber: BlockNumber(1), + }; + + const result = await rule.evict(context, pool); + + expect(result).toEqual({ + reason: 'low_priority', + success: true, + txsEvicted: [], + }); + }); + + it('returns empty result when maxPoolSize is 0', async () => { + rule.updateConfig({ maxPendingTxCount: 0 }); - it('returns empty result for CHAIN_PRUNED event', async () => { const context: EvictionContext = { event: EvictionEvent.CHAIN_PRUNED, blockNumber: BlockNumber(1), @@ -75,6 +112,23 @@ describe('LowPriorityEvictionRule', () => { txsEvicted: [], }); }); + + it('handles error from pool operations', async () => { + rule.updateConfig({ maxPendingTxCount: 1 }); + pool = createPoolOps(2, ['0x3333']); + deleteTxsMock.mockRejectedValue(new Error('Test error')); + + const context: EvictionContext = { + event: EvictionEvent.CHAIN_PRUNED, + blockNumber: BlockNumber(1), + }; + + const result = await rule.evict(context, pool); + + expect(result.success).toBe(false); + expect(result.txsEvicted).toEqual([]); + expect(result.error?.message).toContain('Failed to evict low priority txs'); + }); }); describe('TXS_ADDED events', () => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.ts index 047695aaf681..283c9fe3467b 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/eviction/low_priority_eviction_rule.ts @@ -5,7 +5,7 @@ import { EvictionEvent } from './interfaces.js'; /** * Eviction rule that removes low-priority transactions when the pool exceeds configured limits. - * Only triggers on TXS_ADDED events. + * Triggers on TXS_ADDED and CHAIN_PRUNED events. */ export class LowPriorityEvictionRule implements EvictionRule { public readonly name = 'LowPriorityEviction'; @@ -18,7 +18,7 @@ export class LowPriorityEvictionRule implements EvictionRule { } async evict(context: EvictionContext, pool: PoolOperations): Promise { - if (context.event !== EvictionEvent.TXS_ADDED) { + if (context.event !== EvictionEvent.TXS_ADDED && context.event !== EvictionEvent.CHAIN_PRUNED) { return { reason: 'low_priority', success: true, @@ -51,15 +51,19 @@ export class LowPriorityEvictionRule implements EvictionRule { this.log.info(`Evicting low priority txs. Pending tx count above limit: ${currentTxCount} > ${this.maxPoolSize}`); const numberToEvict = currentTxCount - this.maxPoolSize; const txsToEvict = pool.getLowestPriorityPending(numberToEvict); - const toEvictSet = new Set(txsToEvict); - const numNewTxsEvicted = context.newTxHashes.filter(newTxHash => toEvictSet.has(newTxHash)).length; if (txsToEvict.length > 0) { - this.log.info(`Evicted ${txsToEvict.length} low priority txs, including ${numNewTxsEvicted} newly added txs`); + if (context.event === EvictionEvent.TXS_ADDED) { + const toEvictSet = new Set(txsToEvict); + const numNewTxsEvicted = context.newTxHashes.filter(newTxHash => toEvictSet.has(newTxHash)).length; + this.log.info(`Evicted ${txsToEvict.length} low priority txs, including ${numNewTxsEvicted} newly added txs`); + } else { + this.log.info(`Evicted ${txsToEvict.length} low priority txs after chain prune`); + } await pool.deleteTxs(txsToEvict, this.name); } - this.log.debug(`Evicted ${txsToEvict.length} low priority txs, including ${numNewTxsEvicted} newly added txs`, { + this.log.debug(`Evicted ${txsToEvict.length} low priority txs`, { txHashes: txsToEvict, }); diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts index 83106caec308..2095815d1762 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts @@ -1817,6 +1817,58 @@ describe('TxPoolV2', () => { expect(await pool.getTxStatus(txMined.getTxHash())).toBe('deleted'); expectRemovedTxs(txMined); // txMined deleted }); + + it('evicts low priority txs after chain prune when pool exceeds limit', async () => { + const txLow = await mockTxWithFee(1, 1); + const txMed = await mockTxWithFee(2, 5); + const txHigh = await mockTxWithFee(3, 10); + + // Add all 3 txs (no pool limit by default) + await pool.addPendingTxs([txLow, txMed, txHigh]); + expectAddedTxs(txLow, txMed, txHigh); + expect(await pool.getPendingTxCount()).toBe(3); + + // Mine all three + await pool.handleMinedBlock(makeBlock([txLow, txMed, txHigh], slot1Header)); + expectNoCallbacks(); + expect(await pool.getPendingTxCount()).toBe(0); + + // Now set pool limit to 2 + await pool.updateConfig({ maxPendingTxCount: 2 }); + + // Prune - all 3 txs return to pending, but pool limit is 2 + await pool.handlePrunedBlocks(block0Id); + + // Lowest priority tx should be evicted + const pending = toStrings(await pool.getPendingTxHashes()); + expect(pending).toHaveLength(2); + expect(pending).toContain(hashOf(txMed)); + expect(pending).toContain(hashOf(txHigh)); + expect(await pool.getTxStatus(txLow.getTxHash())).toBe('deleted'); + }); + + it('does not evict txs after chain prune when pool is within limit', async () => { + const tx1 = await mockTxWithFee(1, 1); + const tx2 = await mockTxWithFee(2, 2); + + await pool.addPendingTxs([tx1, tx2]); + expectAddedTxs(tx1, tx2); + expect(await pool.getPendingTxCount()).toBe(2); + + // Mine both + await pool.handleMinedBlock(makeBlock([tx1, tx2], slot1Header)); + expectNoCallbacks(); + + // Set limit to 3 (above what will be restored) + await pool.updateConfig({ maxPendingTxCount: 3 }); + + // Prune - both txs return to pending, under the limit + await pool.handlePrunedBlocks(block0Id); + + expect(await pool.getPendingTxCount()).toBe(2); + expect(await pool.getTxStatus(tx1.getTxHash())).toBe('pending'); + expect(await pool.getTxStatus(tx2.getTxHash())).toBe('pending'); + }); }); describe('validation during restore', () => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index c35a28455fd8..8612cb3755a6 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -234,6 +234,13 @@ export class TxPoolV2Impl { } } } + + // Run post-add eviction rules for pending txs (inside transaction for atomicity) + if (acceptedPending.size > 0) { + const feePayers = Array.from(acceptedPending).map(txHash => this.#indices.getMetadata(txHash)!.feePayer); + const uniqueFeePayers = new Set(feePayers); + await this.#evictionManager.evictAfterNewTxs(Array.from(acceptedPending), [...uniqueFeePayers]); + } }); // Build final accepted list for pending txs (excludes intra-batch evictions) @@ -249,13 +256,6 @@ export class TxPoolV2Impl { this.#instrumentation.recordRejected(rejected.length); } - // Run post-add eviction rules for pending txs - if (acceptedPending.size > 0) { - const feePayers = Array.from(acceptedPending).map(txHash => this.#indices.getMetadata(txHash)!.feePayer); - const uniqueFeePayers = new Set(feePayers); - await this.#evictionManager.evictAfterNewTxs(Array.from(acceptedPending), [...uniqueFeePayers]); - } - return { accepted, ignored, rejected, ...(errors.size > 0 ? { errors } : {}) }; } @@ -379,33 +379,35 @@ export class TxPoolV2Impl { let softDeletedHits = 0; let missingPreviouslyEvicted = 0; - for (const txHash of txHashes) { - const txHashStr = txHash.toString(); - - if (this.#indices.has(txHashStr)) { - // Update protection for existing tx - this.#indices.updateProtection(txHashStr, slotNumber); - } else if (this.#deletedPool.isSoftDeleted(txHashStr)) { - // Resurrect soft-deleted tx as protected - const buffer = await this.#txsDB.getAsync(txHashStr); - if (buffer) { - const tx = Tx.fromBuffer(buffer); - await this.#addTx(tx, { protected: slotNumber }); - softDeletedHits++; + await this.#store.transactionAsync(async () => { + for (const txHash of txHashes) { + const txHashStr = txHash.toString(); + + if (this.#indices.has(txHashStr)) { + // Update protection for existing tx + this.#indices.updateProtection(txHashStr, slotNumber); + } else if (this.#deletedPool.isSoftDeleted(txHashStr)) { + // Resurrect soft-deleted tx as protected + const buffer = await this.#txsDB.getAsync(txHashStr); + if (buffer) { + const tx = Tx.fromBuffer(buffer); + await this.#addTx(tx, { protected: slotNumber }); + softDeletedHits++; + } else { + // Data missing despite soft-delete flag — treat as truly missing + this.#indices.setProtection(txHashStr, slotNumber); + missing.push(txHash); + } } else { - // Data missing despite soft-delete flag — treat as truly missing + // Truly missing — pre-record protection for tx we don't have yet this.#indices.setProtection(txHashStr, slotNumber); missing.push(txHash); - } - } else { - // Truly missing — pre-record protection for tx we don't have yet - this.#indices.setProtection(txHashStr, slotNumber); - missing.push(txHash); - if (this.#evictedTxHashes.has(txHashStr)) { - missingPreviouslyEvicted++; + if (this.#evictedTxHashes.has(txHashStr)) { + missingPreviouslyEvicted++; + } } } - } + }); // Record metrics if (softDeletedHits > 0) { @@ -466,56 +468,60 @@ export class TxPoolV2Impl { } } - // Step 4: Mark txs as mined (only those we have in the pool) - for (const meta of found) { - this.#indices.markAsMined(meta, blockId); - await this.#deletedPool.clearIfMinedHigher(meta.txHash, blockId.number); - } + await this.#store.transactionAsync(async () => { + // Step 4: Mark txs as mined (only those we have in the pool) + for (const meta of found) { + this.#indices.markAsMined(meta, blockId); + await this.#deletedPool.clearIfMinedHigher(meta.txHash, blockId.number); + } - // Step 5: Run eviction rules (remove pending txs with conflicting nullifiers/expired timestamps) - await this.#evictionManager.evictAfterNewBlock(block.header, nullifiers, feePayers); + // Step 5: Run post-event eviction rules (inside transaction for atomicity) + await this.#evictionManager.evictAfterNewBlock(block.header, nullifiers, feePayers); + }); this.#log.info(`Marked ${found.length} txs as mined in block ${blockId.number}`); } async prepareForSlot(slotNumber: SlotNumber): Promise { - // Step 0: Clean up slot-deleted txs from previous slots - await this.#deletedPool.cleanupSlotDeleted(slotNumber); + await this.#store.transactionAsync(async () => { + // Step 0: Clean up slot-deleted txs from previous slots + await this.#deletedPool.cleanupSlotDeleted(slotNumber); - // Step 1: Find expired protected txs - const expiredProtected = this.#indices.findExpiredProtectedTxs(slotNumber); + // Step 1: Find expired protected txs + const expiredProtected = this.#indices.findExpiredProtectedTxs(slotNumber); - // Step 2: Clear protection for all expired entries (including those without metadata) - this.#indices.clearProtection(expiredProtected); + // Step 2: Clear protection for all expired entries (including those without metadata) + this.#indices.clearProtection(expiredProtected); - // Step 3: Filter to only txs that have metadata and are not mined - const txsToRestore = this.#indices.filterRestorable(expiredProtected); - if (txsToRestore.length === 0) { - this.#log.debug(`Preparing for slot ${slotNumber}, no txs to unprotect`); - return; - } + // Step 3: Filter to only txs that have metadata and are not mined + const txsToRestore = this.#indices.filterRestorable(expiredProtected); + if (txsToRestore.length === 0) { + this.#log.debug(`Preparing for slot ${slotNumber}, no txs to unprotect`); + return; + } - this.#log.info(`Preparing for slot ${slotNumber}: unprotecting ${txsToRestore.length} txs`); + this.#log.info(`Preparing for slot ${slotNumber}: unprotecting ${txsToRestore.length} txs`); - // Step 4: Validate for pending pool - const { valid, invalid } = await this.#revalidateMetadata(txsToRestore, 'during prepareForSlot'); + // Step 4: Validate for pending pool + const { valid, invalid } = await this.#revalidateMetadata(txsToRestore, 'during prepareForSlot'); - // Step 5: Resolve nullifier conflicts and add winners to pending indices - const { added, toEvict } = this.#applyNullifierConflictResolution(valid); + // Step 5: Resolve nullifier conflicts and add winners to pending indices + const { added, toEvict } = this.#applyNullifierConflictResolution(valid); - // Step 6: Delete invalid txs and evict conflict losers - await this.#deleteTxsBatch(invalid); - await this.#evictTxs(toEvict, 'NullifierConflict'); + // Step 6: Delete invalid txs and evict conflict losers + await this.#deleteTxsBatch(invalid); + await this.#evictTxs(toEvict, 'NullifierConflict'); - // Step 7: Run eviction rules (enforce pool size limit) - if (added.length > 0) { - const feePayers = added.map(meta => meta.feePayer); - const uniqueFeePayers = new Set(feePayers); - await this.#evictionManager.evictAfterNewTxs( - added.map(m => m.txHash), - [...uniqueFeePayers], - ); - } + // Step 7: Run eviction rules (enforce pool size limit) + if (added.length > 0) { + const feePayers = added.map(meta => meta.feePayer); + const uniqueFeePayers = new Set(feePayers); + await this.#evictionManager.evictAfterNewTxs( + added.map(m => m.txHash), + [...uniqueFeePayers], + ); + } + }); } async handlePrunedBlocks(latestBlock: L2BlockId, options?: { deleteAllTxs?: boolean }): Promise { @@ -528,57 +534,60 @@ export class TxPoolV2Impl { this.#log.info(`Handling prune to block ${latestBlock.number}: un-mining ${txsToUnmine.length} txs`); - // Step 2: Mark ALL un-mined txs with their original mined block number - // This ensures they get soft-deleted if removed later, and only hard-deleted - // when their original mined block is finalized - await this.#deletedPool.markFromPrunedBlock( - txsToUnmine.map(m => ({ - txHash: m.txHash, - minedAtBlock: BlockNumber(m.minedL2BlockId!.number), - })), - ); + await this.#store.transactionAsync(async () => { + // Step 2: Mark ALL un-mined txs with their original mined block number + // This ensures they get soft-deleted if removed later, and only hard-deleted + // when their original mined block is finalized + await this.#deletedPool.markFromPrunedBlock( + txsToUnmine.map(m => ({ + txHash: m.txHash, + minedAtBlock: BlockNumber(m.minedL2BlockId!.number), + })), + ); - // Step 3: Unmine - clear mined status from metadata - for (const meta of txsToUnmine) { - this.#indices.markAsUnmined(meta); - } + // Step 3: Unmine - clear mined status from metadata + for (const meta of txsToUnmine) { + this.#indices.markAsUnmined(meta); + } - // If deleteAllTxs is set (epoch prune), delete all un-mined txs and return early - if (options?.deleteAllTxs) { - const allTxHashes = txsToUnmine.map(m => m.txHash); - await this.#deleteTxsBatch(allTxHashes); - this.#log.info( - `Handled prune to block ${latestBlock.number} with deleteAllTxs: deleted ${allTxHashes.length} txs`, - ); - return; - } + // If deleteAllTxs is set (epoch prune), delete all un-mined txs and return early + if (options?.deleteAllTxs) { + const allTxHashes = txsToUnmine.map(m => m.txHash); + await this.#deleteTxsBatch(allTxHashes); + this.#log.info( + `Handled prune to block ${latestBlock.number} with deleteAllTxs: deleted ${allTxHashes.length} txs`, + ); + return; + } - // Step 4: Filter out protected txs (they'll be handled by prepareForSlot) - const unprotectedTxs = this.#indices.filterUnprotected(txsToUnmine); + // Step 4: Filter out protected txs (they'll be handled by prepareForSlot) + const unprotectedTxs = this.#indices.filterUnprotected(txsToUnmine); - // Step 5: Validate for pending pool - const { valid, invalid } = await this.#revalidateMetadata(unprotectedTxs, 'during handlePrunedBlocks'); + // Step 5: Validate for pending pool + const { valid, invalid } = await this.#revalidateMetadata(unprotectedTxs, 'during handlePrunedBlocks'); - // Step 6: Resolve nullifier conflicts and add winners to pending indices - const { toEvict } = this.#applyNullifierConflictResolution(valid); + // Step 6: Resolve nullifier conflicts and add winners to pending indices + const { toEvict } = this.#applyNullifierConflictResolution(valid); - // Step 7: Delete invalid txs and evict conflict losers - await this.#deleteTxsBatch(invalid); - await this.#evictTxs(toEvict, 'NullifierConflict'); + // Step 7: Delete invalid txs and evict conflict losers + await this.#deleteTxsBatch(invalid); + await this.#evictTxs(toEvict, 'NullifierConflict'); - this.#log.info( - `Handled prune to block ${latestBlock.number}: ${valid.length} txs restored to pending, ${invalid.length} invalid, ${toEvict.length} evicted due to nullifier conflicts`, - { txHashesRestored: valid.map(m => m.txHash), txHashesInvalid: invalid, txHashesEvicted: toEvict }, - ); + this.#log.info( + `Handled prune to block ${latestBlock.number}: ${valid.length} txs restored to pending, ${invalid.length} invalid, ${toEvict.length} evicted due to nullifier conflicts`, + { txHashesRestored: valid.map(m => m.txHash), txHashesInvalid: invalid, txHashesEvicted: toEvict }, + ); - // Step 8: Run eviction rules for ALL pending txs (not just restored ones) - // This handles cases like existing pending txs with invalid fee payer balances - await this.#evictionManager.evictAfterChainPrune(latestBlock.number); + // Step 8: Run eviction rules for ALL pending txs (not just restored ones) + // This handles cases like existing pending txs with invalid fee payer balances + await this.#evictionManager.evictAfterChainPrune(latestBlock.number); + }); } async handleFailedExecution(txHashes: TxHash[]): Promise { - // Delete failed txs - await this.#deleteTxsBatch(txHashes.map(h => h.toString())); + await this.#store.transactionAsync(async () => { + await this.#deleteTxsBatch(txHashes.map(h => h.toString())); + }); this.#log.info(`Deleted ${txHashes.length} failed txs`, { txHashes: txHashes.map(h => h.toString()) }); } @@ -589,27 +598,29 @@ export class TxPoolV2Impl { // Step 1: Find mined txs at or before finalized block const minedTxsToFinalize = this.#indices.findTxsMinedAtOrBefore(blockNumber); - // Step 2: Collect mined txs for archiving (before deletion) - const txsToArchive: Tx[] = []; - if (this.#archive.isEnabled()) { - for (const txHashStr of minedTxsToFinalize) { - const buffer = await this.#txsDB.getAsync(txHashStr); - if (buffer) { - txsToArchive.push(Tx.fromBuffer(buffer)); + await this.#store.transactionAsync(async () => { + // Step 2: Collect mined txs for archiving (before deletion) + const txsToArchive: Tx[] = []; + if (this.#archive.isEnabled()) { + for (const txHashStr of minedTxsToFinalize) { + const buffer = await this.#txsDB.getAsync(txHashStr); + if (buffer) { + txsToArchive.push(Tx.fromBuffer(buffer)); + } } } - } - // Step 3: Delete mined txs from active pool - await this.#deleteTxsBatch(minedTxsToFinalize); + // Step 3: Delete mined txs from active pool + await this.#deleteTxsBatch(minedTxsToFinalize); - // Step 4: Finalize soft-deleted txs - await this.#deletedPool.finalizeBlock(blockNumber); + // Step 4: Finalize soft-deleted txs + await this.#deletedPool.finalizeBlock(blockNumber); - // Step 5: Archive mined txs - if (txsToArchive.length > 0) { - await this.#archive.archiveTxs(txsToArchive); - } + // Step 5: Archive mined txs + if (txsToArchive.length > 0) { + await this.#archive.archiveTxs(txsToArchive); + } + }); if (minedTxsToFinalize.length > 0) { this.#log.info(`Finalized ${minedTxsToFinalize.length} mined txs from blocks up to ${blockNumber}`, { diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts index 0330c20c496e..e014c085f6bf 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.test.ts @@ -1,9 +1,9 @@ import { - AVM_MAX_PROCESSABLE_L2_GAS, DEFAULT_DA_GAS_LIMIT, DEFAULT_TEARDOWN_DA_GAS_LIMIT, - FIXED_DA_GAS, - FIXED_L2_GAS, + MAX_PROCESSABLE_L2_GAS, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { Writeable } from '@aztec/foundation/types'; @@ -22,6 +22,7 @@ import { type Tx, } from '@aztec/stdlib/tx'; +import assert from 'assert'; import { type MockProxy, mock, mockFn } from 'jest-mock-extended'; import { GasTxValidator } from './gas_validator.js'; @@ -112,16 +113,32 @@ describe('GasTxValidator', () => { }); it('rejects txs if the DA gas limit is not above the minimum amount', async () => { + assert(!!tx.data.forPublic); tx.data.constants.txContext.gasSettings = GasSettings.default({ - gasLimits: new Gas(1, FIXED_L2_GAS), + gasLimits: new Gas(1, PUBLIC_TX_L2_GAS_OVERHEAD), maxFeesPerGas: gasFees.clone(), }); await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); }); - it('rejects txs if the L2 gas limit is not above the minimum amount', async () => { + it('rejects txs if the L2 gas limit is not above the minimum amount in a public tx', async () => { + assert(!!tx.data.forPublic); tx.data.constants.txContext.gasSettings = GasSettings.default({ - gasLimits: new Gas(FIXED_DA_GAS, 1), + gasLimits: new Gas(TX_DA_GAS_OVERHEAD, 1), + maxFeesPerGas: gasFees.clone(), + }); + await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); + }); + + it('rejects txs if the L2 gas limit is not above the minimum amount in a private tx', async () => { + tx = await mockTx(1, { + numberOfNonRevertiblePublicCallRequests: 0, + numberOfRevertiblePublicCallRequests: 0, + hasPublicTeardownCallRequest: false, + }); + assert(!tx.data.forPublic); + tx.data.constants.txContext.gasSettings = GasSettings.default({ + gasLimits: new Gas(TX_DA_GAS_OVERHEAD, 1), maxFeesPerGas: gasFees.clone(), }); await expectInvalid(tx, TX_ERROR_INSUFFICIENT_GAS_LIMIT); @@ -147,7 +164,7 @@ describe('GasTxValidator', () => { it('rejects txs if the l2 gas limit is too high', async () => { tx.data.constants.txContext.gasSettings = GasSettings.default({ - gasLimits: new Gas(DEFAULT_DA_GAS_LIMIT, AVM_MAX_PROCESSABLE_L2_GAS + 1), + gasLimits: new Gas(DEFAULT_DA_GAS_LIMIT, MAX_PROCESSABLE_L2_GAS + 1), maxFeesPerGas: gasFees.clone(), teardownGasLimits: new Gas(DEFAULT_TEARDOWN_DA_GAS_LIMIT, 1), }); diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts index 2b07bd66cd0b..a404cb0287bd 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/gas_validator.ts @@ -1,4 +1,9 @@ -import { AVM_MAX_PROCESSABLE_L2_GAS, FIXED_DA_GAS, FIXED_L2_GAS } from '@aztec/constants'; +import { + MAX_PROCESSABLE_L2_GAS, + PRIVATE_TX_L2_GAS_OVERHEAD, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, +} from '@aztec/constants'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { computeFeePayerBalanceStorageSlot } from '@aztec/protocol-contracts/fee-juice'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -73,7 +78,10 @@ export class GasTxValidator implements TxValidator { */ #validateGasLimit(tx: Tx): TxValidationResult { const gasLimits = tx.data.constants.txContext.gasSettings.gasLimits; - const minGasLimits = new Gas(FIXED_DA_GAS, FIXED_L2_GAS); + const minGasLimits = new Gas( + TX_DA_GAS_OVERHEAD, + tx.data.forPublic ? PUBLIC_TX_L2_GAS_OVERHEAD : PRIVATE_TX_L2_GAS_OVERHEAD, + ); if (minGasLimits.gtAny(gasLimits)) { this.#log.verbose(`Rejecting transaction due to the gas limit(s) not being above the minimum gas limit`, { @@ -83,7 +91,7 @@ export class GasTxValidator implements TxValidator { return { result: 'invalid', reason: [TX_ERROR_INSUFFICIENT_GAS_LIMIT] }; } - if (gasLimits.l2Gas > AVM_MAX_PROCESSABLE_L2_GAS) { + if (gasLimits.l2Gas > MAX_PROCESSABLE_L2_GAS) { this.#log.verbose(`Rejecting transaction due to the gas limit(s) being higher than the maximum processable gas`, { gasLimits, minGasLimits, diff --git a/yarn-project/protocol-contracts/src/scripts/generate_data.ts b/yarn-project/protocol-contracts/src/scripts/generate_data.ts index 69d62cfe7631..58b8d8aa14c7 100644 --- a/yarn-project/protocol-contracts/src/scripts/generate_data.ts +++ b/yarn-project/protocol-contracts/src/scripts/generate_data.ts @@ -1,3 +1,7 @@ +// Reads compiled Noir artifacts for each protocol contract and derives their addresses, class IDs, +// bytecode commitments, and other deployment data, emitting everything as precomputed constants into +// `protocol_contract_data.ts`. This avoids clients repeating the expensive hashing at runtime and +// ensures a single source of truth for the protocol contracts hash enforced by circuits, P2P, and L1. import { CANONICAL_AUTH_REGISTRY_ADDRESS, CONTRACT_CLASS_REGISTRY_CONTRACT_ADDRESS, diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index 488fa554df73..c1c9f56e172a 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -4,8 +4,6 @@ import { AVM_SENDL2TOL1MSG_BASE_L2_GAS, DA_GAS_PER_FIELD, FIXED_AVM_STARTUP_L2_GAS, - FIXED_DA_GAS, - FIXED_L2_GAS, L2_GAS_PER_CONTRACT_CLASS_LOG, L2_GAS_PER_L2_TO_L1_MSG, L2_GAS_PER_NOTE_HASH, @@ -19,6 +17,9 @@ import { MAX_NULLIFIERS_PER_TX, MAX_NULLIFIER_READ_REQUESTS_PER_TX, MAX_PRIVATE_LOGS_PER_TX, + PRIVATE_TX_L2_GAS_OVERHEAD, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, } from '@aztec/constants'; import { arrayNonEmptyLength, padArrayEnd } from '@aztec/foundation/collection'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -653,7 +654,12 @@ export async function generateSimulatedProvingResult( const publicInputs = new PrivateKernelTailCircuitPublicInputs( constantData, - /*gasUsed=*/ gasUsed.add(Gas.from({ l2Gas: FIXED_L2_GAS, daGas: FIXED_DA_GAS })), + /*gasUsed=*/ gasUsed.add( + Gas.from({ + l2Gas: isPrivateOnlyTx ? PRIVATE_TX_L2_GAS_OVERHEAD : PUBLIC_TX_L2_GAS_OVERHEAD, + daGas: TX_DA_GAS_OVERHEAD, + }), + ), /*feePayer=*/ AztecAddress.zero(), /*expirationTimestamp=*/ 0n, hasPublicCalls ? inputsForPublic : undefined, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 8ef5a19129ba..4361634eb771 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -256,6 +256,7 @@ describe('CheckpointProposalJob', () => { validatorClient.signAttestationsAndSigners.mockImplementation(() => Promise.resolve(getSignatures()[0].signature)); validatorClient.getCoinbaseForAttestor.mockReturnValue(coinbase); validatorClient.getFeeRecipientForAttestor.mockReturnValue(feeRecipient); + validatorClient.getValidatorAddresses.mockReturnValue([attestorAddress]); slasherClient = mock(); slasherClient.getProposerActions.mockResolvedValue([]); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index 2e9ebb18219e..ad88b7d040c1 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -436,6 +436,7 @@ describe('CheckpointProposalJob Timing Tests', () => { validatorClient.signAttestationsAndSigners.mockResolvedValue(mockedSig); validatorClient.getCoinbaseForAttestor.mockReturnValue(coinbase); validatorClient.getFeeRecipientForAttestor.mockReturnValue(globalVariables.feeRecipient); + validatorClient.getValidatorAddresses.mockReturnValue([attestorAddress]); slasherClient = mock(); slasherClient.getProposerActions.mockResolvedValue([]); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 44abe045ba91..184e83a76506 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -38,7 +38,7 @@ import { } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposalOptions, CheckpointProposal, CheckpointProposalOptions } from '@aztec/stdlib/p2p'; -import { orderAttestations } from '@aztec/stdlib/p2p'; +import { orderAttestations, trimAttestations } from '@aztec/stdlib/p2p'; import type { L2BlockBuiltStats } from '@aztec/stdlib/stats'; import { type FailedTx, Tx } from '@aztec/stdlib/tx'; import { AttestationTimeoutError } from '@aztec/stdlib/validators'; @@ -743,8 +743,20 @@ export class CheckpointProposalJob implements Traceable { collectedAttestationsCount = attestations.length; + // Trim attestations to minimum required to save L1 calldata gas + const localAddresses = this.validatorClient.getValidatorAddresses(); + const trimmed = trimAttestations( + attestations, + numberOfRequiredAttestations, + this.attestorAddress, + localAddresses, + ); + if (trimmed.length < attestations.length) { + this.log.debug(`Trimmed attestations from ${attestations.length} to ${trimmed.length} for L1 submission`); + } + // Rollup contract requires that the signatures are provided in the order of the committee - const sorted = orderAttestations(attestations, committee); + const sorted = orderAttestations(trimmed, committee); // Manipulate the attestations if we've been configured to do so if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) { diff --git a/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts b/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts index ae636ab8fb9e..895f6cc76ec0 100644 --- a/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts +++ b/yarn-project/simulator/src/public/fixtures/public_tx_simulation_tester.ts @@ -1,4 +1,9 @@ -import { DEFAULT_TEARDOWN_DA_GAS_LIMIT, DEFAULT_TEARDOWN_L2_GAS_LIMIT } from '@aztec/constants'; +import { + DEFAULT_TEARDOWN_DA_GAS_LIMIT, + DEFAULT_TEARDOWN_L2_GAS_LIMIT, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, +} from '@aztec/constants'; import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -131,8 +136,11 @@ export class PublicTxSimulationTester extends BaseAvmSimulationTester { teardownCallRequest, feePayer, /*gasUsedByPrivate*/ teardownCall - ? new Gas(DEFAULT_TEARDOWN_DA_GAS_LIMIT, DEFAULT_TEARDOWN_L2_GAS_LIMIT) - : Gas.empty(), + ? new Gas( + DEFAULT_TEARDOWN_DA_GAS_LIMIT + TX_DA_GAS_OVERHEAD, + DEFAULT_TEARDOWN_L2_GAS_LIMIT + PUBLIC_TX_L2_GAS_OVERHEAD, + ) + : new Gas(TX_DA_GAS_OVERHEAD, PUBLIC_TX_L2_GAS_OVERHEAD), defaultGlobals(), ); } diff --git a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.test.ts b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.test.ts index 38771de3382a..a0a86e68d5c0 100644 --- a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.test.ts +++ b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.test.ts @@ -1,9 +1,9 @@ import { - AVM_MAX_PROCESSABLE_L2_GAS, GAS_ESTIMATION_DA_GAS_LIMIT, GAS_ESTIMATION_L2_GAS_LIMIT, GAS_ESTIMATION_TEARDOWN_DA_GAS_LIMIT, GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT, + MAX_PROCESSABLE_L2_GAS, NULLIFIER_SUBTREE_HEIGHT, } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -498,14 +498,14 @@ describe('public_tx_simulator', () => { it('fails a tx that consumes more than the AVM maximum processable gas', async () => { gasLimits = new Gas(GAS_ESTIMATION_DA_GAS_LIMIT, GAS_ESTIMATION_L2_GAS_LIMIT); teardownGasLimits = new Gas(GAS_ESTIMATION_TEARDOWN_DA_GAS_LIMIT, GAS_ESTIMATION_TEARDOWN_L2_GAS_LIMIT); - enqueuedCallGasUsed = new Gas(GAS_ESTIMATION_L2_GAS_LIMIT, AVM_MAX_PROCESSABLE_L2_GAS); + enqueuedCallGasUsed = new Gas(GAS_ESTIMATION_L2_GAS_LIMIT, MAX_PROCESSABLE_L2_GAS); const tx = await mockTxWithPublicCalls({ numberOfAppLogicCalls: 1, }); await expect(simulator.simulate(tx)).rejects.toThrow( - `exceeds the AVM maximum processable gas of ${AVM_MAX_PROCESSABLE_L2_GAS}`, + `exceeds the maximum processable gas of ${MAX_PROCESSABLE_L2_GAS}`, ); expect(simulateInternal).toHaveBeenCalledTimes(1); diff --git a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts index 6e3af53f7c2f..c07f0d04945c 100644 --- a/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts +++ b/yarn-project/simulator/src/public/public_tx_simulator/public_tx_simulator.ts @@ -1,4 +1,4 @@ -import { AVM_MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; +import { MAX_PROCESSABLE_L2_GAS } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { ProtocolContractAddress, ProtocolContractsList } from '@aztec/protocol-contracts'; @@ -199,8 +199,8 @@ export class PublicTxSimulator implements PublicTxSimulatorInterface { // Such transactions should be filtered by GasTxValidator. assert( - context.getActualGasUsed().l2Gas <= AVM_MAX_PROCESSABLE_L2_GAS, - `Transaction consumes ${context.getActualGasUsed().l2Gas} L2 gas, which exceeds the AVM maximum processable gas of ${AVM_MAX_PROCESSABLE_L2_GAS}`, + context.getActualGasUsed().l2Gas <= MAX_PROCESSABLE_L2_GAS, + `Transaction consumes ${context.getActualGasUsed().l2Gas} L2 gas, which exceeds the maximum processable gas of ${MAX_PROCESSABLE_L2_GAS}`, ); await this.payFee(context); diff --git a/yarn-project/stdlib/src/p2p/attestation_utils.test.ts b/yarn-project/stdlib/src/p2p/attestation_utils.test.ts new file mode 100644 index 000000000000..c06353f6f959 --- /dev/null +++ b/yarn-project/stdlib/src/p2p/attestation_utils.test.ts @@ -0,0 +1,151 @@ +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Fr } from '@aztec/foundation/curves/bn254'; + +import { jest } from '@jest/globals'; + +import { CheckpointHeader } from '../rollup/index.js'; +import { trimAttestations } from './attestation_utils.js'; +import { CheckpointAttestation } from './checkpoint_attestation.js'; +import { ConsensusPayload } from './consensus_payload.js'; +import { SignatureDomainSeparator, getHashedSignaturePayloadEthSignedMessage } from './signature_utils.js'; + +function makeAttestation(signer: Secp256k1Signer): CheckpointAttestation { + const header = CheckpointHeader.random({ slotNumber: SlotNumber(0) }); + const payload = new ConsensusPayload(header, Fr.random(), 0n); + const attestationHash = getHashedSignaturePayloadEthSignedMessage( + payload, + SignatureDomainSeparator.checkpointAttestation, + ); + const proposalHash = getHashedSignaturePayloadEthSignedMessage(payload, SignatureDomainSeparator.checkpointProposal); + return new CheckpointAttestation(payload, signer.sign(attestationHash), signer.sign(proposalHash)); +} + +function makeSignerAndAttestation() { + const signer = Secp256k1Signer.random(); + return { signer, attestation: makeAttestation(signer), address: signer.address }; +} + +describe('trimAttestations', () => { + it('returns attestations unchanged when count <= required', () => { + const items = Array.from({ length: 3 }, () => makeSignerAndAttestation()); + const proposer = items[0]; + + const result = trimAttestations( + items.map(i => i.attestation), + 3, + proposer.address, + [], + ); + + expect(result).toHaveLength(3); + }); + + it('trims to required count', () => { + const items = Array.from({ length: 5 }, () => makeSignerAndAttestation()); + const proposer = items[0]; + + const result = trimAttestations( + items.map(i => i.attestation), + 3, + proposer.address, + [], + ); + + expect(result).toHaveLength(3); + }); + + it('always keeps proposer attestation', () => { + const items = Array.from({ length: 5 }, () => makeSignerAndAttestation()); + // Proposer is the last item in the array + const proposer = items[4]; + + const result = trimAttestations( + items.map(i => i.attestation), + 3, + proposer.address, + [], + ); + + expect(result).toHaveLength(3); + const resultSenders = result.map(a => a.getSender()!.toString()); + expect(resultSenders).toContain(proposer.address.toString()); + }); + + it('prioritizes local validator attestations over external ones', () => { + const proposer = makeSignerAndAttestation(); + const local1 = makeSignerAndAttestation(); + const local2 = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + const external2 = makeSignerAndAttestation(); + + const allAttestations = [proposer, local1, local2, external1, external2].map(i => i.attestation); + const localAddresses = [local1.address, local2.address]; + + const result = trimAttestations(allAttestations, 3, proposer.address, localAddresses); + + expect(result).toHaveLength(3); + const resultSenders = new Set(result.map(a => a.getSender()!.toString())); + expect(resultSenders.has(proposer.address.toString())).toBe(true); + expect(resultSenders.has(local1.address.toString())).toBe(true); + expect(resultSenders.has(local2.address.toString())).toBe(true); + expect(resultSenders.has(external1.address.toString())).toBe(false); + expect(resultSenders.has(external2.address.toString())).toBe(false); + }); + + it('fills with external attestations when not enough local ones', () => { + const proposer = makeSignerAndAttestation(); + const local1 = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + const external2 = makeSignerAndAttestation(); + const external3 = makeSignerAndAttestation(); + + const allAttestations = [proposer, local1, external1, external2, external3].map(i => i.attestation); + + const result = trimAttestations(allAttestations, 3, proposer.address, [local1.address]); + + expect(result).toHaveLength(3); + const resultSenders = new Set(result.map(a => a.getSender()!.toString())); + expect(resultSenders.has(proposer.address.toString())).toBe(true); + expect(resultSenders.has(local1.address.toString())).toBe(true); + // One external fills the remaining slot + const externalIncluded = [external1, external2, external3].filter(e => resultSenders.has(e.address.toString())); + expect(externalIncluded).toHaveLength(1); + }); + + it('handles proposer also being in local addresses without double-counting', () => { + const proposer = makeSignerAndAttestation(); + const local1 = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + const external2 = makeSignerAndAttestation(); + + const allAttestations = [proposer, local1, external1, external2].map(i => i.attestation); + // Proposer address is also listed in local addresses + const localAddresses = [proposer.address, local1.address]; + + const result = trimAttestations(allAttestations, 3, proposer.address, localAddresses); + + expect(result).toHaveLength(3); + const resultSenders = result.map(a => a.getSender()!.toString()); + // Proposer should appear exactly once + expect(resultSenders.filter(s => s === proposer.address.toString())).toHaveLength(1); + expect(resultSenders).toContain(local1.address.toString()); + }); + + it('skips attestations with unrecoverable signatures', () => { + const proposer = makeSignerAndAttestation(); + const valid = makeSignerAndAttestation(); + const external1 = makeSignerAndAttestation(); + + const badAttestation = makeSignerAndAttestation().attestation; + jest.spyOn(badAttestation, 'getSender').mockReturnValue(undefined); + + const allAttestations = [proposer.attestation, valid.attestation, badAttestation, external1.attestation]; + + const result = trimAttestations(allAttestations, 3, proposer.address, []); + + expect(result).toHaveLength(3); + const resultSenders = result.map(a => a.getSender()?.toString()).filter(Boolean); + expect(resultSenders).toHaveLength(3); + }); +}); diff --git a/yarn-project/stdlib/src/p2p/attestation_utils.ts b/yarn-project/stdlib/src/p2p/attestation_utils.ts index 646ea04d546c..33fb150909af 100644 --- a/yarn-project/stdlib/src/p2p/attestation_utils.ts +++ b/yarn-project/stdlib/src/p2p/attestation_utils.ts @@ -33,3 +33,59 @@ export function orderAttestations( return orderedAttestations; } + +/** + * Trims attestations to the minimum required number to save L1 calldata gas. + * Each signature costs 65 bytes of calldata vs 20 bytes for just an address. + * + * Priority order for keeping attestations: + * 1. The proposer's attestation (required by L1 contract - MissingProposerSignature revert) + * 2. Attestations from the local node's validator keys + * 3. Remaining attestations filled to reach the required count + */ +export function trimAttestations( + attestations: CheckpointAttestation[], + required: number, + proposerAddress: EthAddress, + localAddresses: EthAddress[], +): CheckpointAttestation[] { + if (attestations.length <= required) { + return attestations; + } + + const proposerAttestation: CheckpointAttestation[] = []; + const localAttestations: CheckpointAttestation[] = []; + const otherAttestations: CheckpointAttestation[] = []; + + for (const attestation of attestations) { + const sender = attestation.getSender(); + if (!sender) { + continue; + } + if (sender.equals(proposerAddress)) { + proposerAttestation.push(attestation); + } else if (localAddresses.some(addr => addr.equals(sender))) { + localAttestations.push(attestation); + } else { + otherAttestations.push(attestation); + } + } + + const result: CheckpointAttestation[] = [...proposerAttestation]; + + for (const att of localAttestations) { + if (result.length >= required) { + break; + } + result.push(att); + } + + for (const att of otherAttestations) { + if (result.length >= required) { + break; + } + result.push(att); + } + + return result; +} diff --git a/yarn-project/stdlib/src/tests/mocks.ts b/yarn-project/stdlib/src/tests/mocks.ts index 6ce72b3563de..ceffb21c01a8 100644 --- a/yarn-project/stdlib/src/tests/mocks.ts +++ b/yarn-project/stdlib/src/tests/mocks.ts @@ -1,10 +1,11 @@ import { - FIXED_DA_GAS, - FIXED_L2_GAS, MAX_ENQUEUED_CALLS_PER_TX, MAX_NULLIFIERS_PER_TX, MAX_TOTAL_PUBLIC_DATA_UPDATE_REQUESTS_PER_TX, MAX_TX_LIFETIME, + PRIVATE_TX_L2_GAS_OVERHEAD, + PUBLIC_TX_L2_GAS_OVERHEAD, + TX_DA_GAS_OVERHEAD, } from '@aztec/constants'; import { type FieldsOf, makeTuple } from '@aztec/foundation/array'; import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; @@ -205,8 +206,11 @@ export async function mockProcessedTx({ feePayer, feePaymentPublicDataWrite, // The default gasUsed is the tx overhead. - gasUsed = Gas.from({ daGas: FIXED_DA_GAS, l2Gas: FIXED_L2_GAS }), privateOnly = false, + gasUsed = Gas.from({ + daGas: TX_DA_GAS_OVERHEAD, + l2Gas: privateOnly ? PRIVATE_TX_L2_GAS_OVERHEAD : PUBLIC_TX_L2_GAS_OVERHEAD, + }), avmAccumulatedData, ...mockTxOpts }: { diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.ts b/yarn-project/wallets/src/embedded/embedded_wallet.ts index bcb373985b4c..e1f69cf6ad09 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.ts @@ -180,10 +180,15 @@ export class EmbeddedWallet extends BaseWallet { const accountManager = await AccountManager.create(this, secret, contract, salt); const instance = accountManager.getInstance(); - const artifact = await accountManager.getAccountContract().getContractArtifact(); - - await this.registerContract(instance, artifact, accountManager.getSecretKey()); - + const existingInstance = await this.pxe.getContractInstance(instance.address); + if (!existingInstance) { + const existingArtifact = await this.pxe.getContractArtifact(instance.currentContractClassId); + await this.registerContract( + instance, + !existingArtifact ? await accountManager.getAccountContract().getContractArtifact() : undefined, + accountManager.getSecretKey(), + ); + } return accountManager; }