diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 7d9fbfa86d59..6b3dc2c43775 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -82,14 +82,23 @@ For an unconstrained self-send (the recipient is one of the wallet's own account ### In Noir tests -When testing in Noir, leaving the strategy unset makes `TestEnvironment` fall back to the bare PXE default. Set a strategy when creating the environment to exercise a specific one; it affects message delivery in private executions: +When testing in Noir, leaving the strategy unset makes `TestEnvironment` fall back to the bare PXE default. Set a strategy +when creating the environment to exercise a specific one; it affects message delivery in private executions that use the +default wallet strategy hook. Use `with_default_tag_secret_strategy` to configure the strategy for a specific delivery +mode: ```rust let env = TestEnvironment::new_opts( - TestEnvironmentOptions::new().with_tagging_secret_strategy(TaggingSecretStrategy::non_interactive_handshake()), + TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), + TaggingSecretStrategy::non_interactive_handshake(), + ), ); ``` +Use `with_default_tag_secret_strategy_all_modes` only when the same strategy should apply to both constrained and +unconstrained delivery. Contract-fixed delivery derivations bypass this default strategy. + ### In production Pass a `resolveTaggingSecretStrategy` hook when [creating the PXE](#configuring-hooks). It receives a `TaggingSecretStrategyRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr index 056e5479782e..93ee3b2b3041 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr @@ -180,6 +180,29 @@ mod test { }); } + #[test(should_fail_with = "an unconstrained tagging secret cannot back constrained delivery")] + unconstrained fn constrained_delivery_rejects_a_resolved_unconstrained_secret() { + let env = TestEnvironment::new(); + let secret: Field = 7; + let index: u32 = 0; + + env.private_context(|context| { + mock_existing_handshake_secrets(Option::none()); + let _ = OracleMock::mock("aztec_prv_resolveTaggingStrategy").returns( + ResolvedTaggingStrategy::unconstrained_secret(secret), + ); + let _ = OracleMock::mock("aztec_prv_getNextTaggingIndex").returns(index); + + let _ = derive_log_tag( + context, + OnchainDeliveryMode::onchain_constrained(), + SENDER, + RECIPIENT, + Option::none(), + ); + }); + } + #[test] unconstrained fn constrained_delivery_emits_the_sequence_nullifier_and_constrained_tag() { let env = TestEnvironment::new(); diff --git a/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr b/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr index 1b77cd7d7885..d81e99a71067 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr @@ -1,8 +1,11 @@ -use crate::protocol::{ - abis::block_header::BlockHeader, - constants::{ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT}, - merkle_tree::MembershipWitness, - traits::Hash, +use crate::{ + ephemeral::EphemeralArray, + protocol::{ + abis::block_header::BlockHeader, + constants::{ARCHIVE_HEIGHT, NOTE_HASH_TREE_HEIGHT}, + merkle_tree::MembershipWitness, + traits::Hash, + }, }; #[oracle(aztec_utl_getNoteHashMembershipWitness)] @@ -17,6 +20,12 @@ unconstrained fn get_block_hash_membership_witness_oracle( block_hash: Field, ) -> Option> {} +#[oracle(aztec_utl_areBlockHashesInArchive)] +unconstrained fn are_block_hashes_in_archive_oracle( + anchor_block_hash: Field, + block_hashes: EphemeralArray, +) -> EphemeralArray {} + // Note: get_nullifier_membership_witness function is implemented in get_nullifier_membership_witness.nr /// Returns a membership witness for a `note_hash` in the note hash tree whose root is defined in @@ -53,6 +62,15 @@ pub unconstrained fn get_maybe_block_hash_membership_witness( get_block_hash_membership_witness_oracle(anchor_block_hash, block_hash) } +/// Returns whether each block hash is present in the archive tree whose root is defined in `anchor_block_header`. +pub unconstrained fn are_block_hashes_in_archive( + anchor_block_header: BlockHeader, + block_hashes: EphemeralArray, +) -> EphemeralArray { + let anchor_block_hash = anchor_block_header.hash(); + are_block_hashes_in_archive_oracle(anchor_block_hash, block_hashes) +} + mod test { use crate::history::test::{create_note, NOTE_CREATED_AT}; use crate::note::note_interface::NoteHash; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr index f6182d59f83d..fc7722521040 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr @@ -66,3 +66,11 @@ pub unconstrained fn get_tx_effect(tx_hash: Field) -> Option { #[oracle(aztec_utl_getTxEffect)] unconstrained fn get_tx_effect_oracle(tx_hash: Field) -> Option {} + +/// Fetches all effects of settled transactions by hash, preserving request order. +pub unconstrained fn get_tx_effects(tx_hashes: EphemeralArray) -> EphemeralArray> { + get_tx_effects_oracle(tx_hashes) +} + +#[oracle(aztec_utl_getTxEffects)] +unconstrained fn get_tx_effects_oracle(tx_hashes: EphemeralArray) -> EphemeralArray> {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 3fe750eb3e10..a37ac15b6fa1 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -22,13 +22,7 @@ use crate::macros::oracle_testing::{generate_oracle_tests, generate_oracle_tests ], )] pub mod avm; -#[generate_oracle_tests_excluding( - @[ - // TODO: cover once the iv/sym_key BUFFER mapping is expressible as a plain fixed array of bytes, so the - // fixture synthesizer does not need to special-case it. - quote { aes128_decrypt_oracle }, - ], -)] +#[generate_oracle_tests] pub mod aes128_decrypt; #[generate_oracle_tests] pub mod auth_witness; @@ -99,7 +93,12 @@ pub mod get_nullifier_membership_witness; ], )] pub mod get_public_data_witness; -#[generate_oracle_tests] +#[generate_oracle_tests_excluding( + @[ + // TODO: cover once the test resolver can serve EphemeralArray contents. + quote { are_block_hashes_in_archive_oracle }, + ], +)] pub mod get_membership_witness; #[generate_oracle_tests] pub mod keys; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr b/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr index b288f4897e07..37f0e2e68985 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr @@ -18,104 +18,3 @@ pub(crate) unconstrained fn get_resolved_txs(requests: EphemeralArray) -> #[oracle(aztec_utl_getResolvedTxs)] unconstrained fn get_resolved_txs_oracle(requests: EphemeralArray) -> EphemeralArray> {} - -mod test { - use crate::oracle::tx_resolution::ResolvedTx; - use crate::protocol::traits::Deserialize; - - #[test] - unconstrained fn resolved_tx_serialization_matches_typescript() { - // Setup test data - let tx_hash = 123; - let unique_note_hashes = BoundedVec::from_array([4, 5]); - let first_nullifier = 6; - let block_number: u32 = 7; - let block_hash = 8; - - // Create a ResolvedTx instance - let resolved_tx = ResolvedTx { - tx_hash, - unique_note_hashes_in_tx: unique_note_hashes, - first_nullifier_in_tx: first_nullifier, - block_number, - block_hash, - }; - - // Expected output generated from TypeScript's `ResolvedTx.toFields()` - let serialized_resolved_tx_from_typescript = [ - 0x000000000000000000000000000000000000000000000000000000000000007b, - 0x0000000000000000000000000000000000000000000000000000000000000004, - 0x0000000000000000000000000000000000000000000000000000000000000005, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000000, - 0x0000000000000000000000000000000000000000000000000000000000000002, - 0x0000000000000000000000000000000000000000000000000000000000000006, - 0x0000000000000000000000000000000000000000000000000000000000000007, - 0x0000000000000000000000000000000000000000000000000000000000000008, - ]; - - let deserialized = ResolvedTx::deserialize(serialized_resolved_tx_from_typescript); - - assert_eq(deserialized, resolved_tx); - } -} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index 54924c79d5d3..6137d3d641d7 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -11,7 +11,7 @@ /// immediately if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency /// without actually using any of the new oracles then there is no reason to throw. pub global ORACLE_VERSION_MAJOR: Field = 30; -pub global ORACLE_VERSION_MINOR: Field = 6; +pub global ORACLE_VERSION_MINOR: Field = 8; /// Asserts that the version of the oracle is compatible with the version expected by the contract. pub fn assert_compatible_oracle_version() { diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr index 6cd7f18ac34f..1732db45a308 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr @@ -13,7 +13,7 @@ pub mod test_environment; quote { private_call_new_flow_oracle }, // TODO: implement once we support more complex types quote { public_call_new_flow_oracle }, // TODO: implement once we support more complex types quote { set_private_txe_context_oracle }, // TODO: implement once we support more complex types - quote { set_tagging_secret_strategy }, // TODO: implement once we support more complex types + quote { set_tagging_secret_strategies }, // TODO: implement once we support more complex types ], )] pub mod txe_oracles; diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr index 93f4baa0b0c8..8a694b5b3592 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/tagging_secret_strategy.nr @@ -1,4 +1,11 @@ -use crate::protocol::{point::EmbeddedCurvePoint, traits::{Deserialize, Serialize}, utils::reader::Reader}; +use crate::keys::ecdh_shared_secret::compute_app_siloed_shared_secret; +use crate::protocol::{ + address::AztecAddress, + hash::poseidon2_hash, + point::EmbeddedCurvePoint, + traits::{Deserialize, Serialize, ToField}, + utils::reader::Reader, +}; global NON_INTERACTIVE_HANDSHAKE: u8 = 1; global ARBITRARY_SECRET: u8 = 2; @@ -7,7 +14,7 @@ global INTERACTIVE_HANDSHAKE: u8 = 4; /// How a message's tagging secret is chosen: the wallet's strategy. /// -/// This type only exists for tests: a Noir test sets it via `with_tagging_secret_strategy` on +/// This type only exists for tests: a Noir test sets it via `with_default_tag_secret_strategy` on /// [`TestEnvironmentOptions`](crate::test::helpers::test_environment::TestEnvironmentOptions). /// Production wallets express the strategy in PXE; the Noir path only consumes the resolved /// [`ResolvedTaggingStrategy`](crate::messages::delivery::ResolvedTaggingStrategy). @@ -45,18 +52,26 @@ impl TaggingSecretStrategy { /// Validates a raw discriminant, as deserialization must always reject unknown values. fn from_parts(kind: u8, secret: EmbeddedCurvePoint) -> Self { let strategy = Self { kind, secret }; + let is_no_payload_strategy = + (kind == NON_INTERACTIVE_HANDSHAKE) | (kind == INTERACTIVE_HANDSHAKE) | (kind == ADDRESS_DERIVED); assert( - ( - ((kind == NON_INTERACTIVE_HANDSHAKE) | (kind == ADDRESS_DERIVED) | (kind == INTERACTIVE_HANDSHAKE)) - & secret.is_infinite() - ) - | (kind == ARBITRARY_SECRET), + (is_no_payload_strategy & secret.is_infinite()) | (kind == ARBITRARY_SECRET), f"unrecognized tagging secret strategy kind: {kind}", ); strategy } } +/// Computes the directional tagging secret PXE derives from an arbitrary shared secret point: the point is app-siloed +/// and the recipient is folded in for direction. Mirrors `AppTaggingSecret.computeDirectional` on the PXE side; tests +/// use it to derive the secret they expect an [`arbitrary_secret`](TaggingSecretStrategy::arbitrary_secret) strategy +/// to produce. +pub fn compute_directional_app_secret(secret: EmbeddedCurvePoint, app: AztecAddress, recipient: AztecAddress) -> Field { + poseidon2_hash( + [compute_app_siloed_shared_secret(secret, app), recipient.to_field()], + ) +} + impl Deserialize for TaggingSecretStrategy { let N: u32 = 3; @@ -81,14 +96,14 @@ mod test { #[test] fn strategy_roundtrips_through_serialization() { let non_interactive = TaggingSecretStrategy::non_interactive_handshake(); + let interactive = TaggingSecretStrategy::interactive_handshake(); let address = TaggingSecretStrategy::address_derived(); let provided = TaggingSecretStrategy::arbitrary_secret(EmbeddedCurvePoint { x: 7, y: 11 }); - let interactive = TaggingSecretStrategy::interactive_handshake(); assert(TaggingSecretStrategy::deserialize(non_interactive.serialize()) == non_interactive); + assert(TaggingSecretStrategy::deserialize(interactive.serialize()) == interactive); assert(TaggingSecretStrategy::deserialize(address.serialize()) == address); assert(TaggingSecretStrategy::deserialize(provided.serialize()) == provided); - assert(TaggingSecretStrategy::deserialize(interactive.serialize()) == interactive); } #[test(should_fail_with = "unrecognized tagging secret strategy kind")] @@ -98,16 +113,22 @@ mod test { #[test(should_fail_with = "unrecognized tagging secret strategy kind")] fn deserializing_handshake_with_noninfinite_point_fails() { - let _ = TaggingSecretStrategy::deserialize([1, 7, 11]); + let _ = TaggingSecretStrategy::deserialize([ + TaggingSecretStrategy::non_interactive_handshake().kind as Field, + 7, + 11, + ]); } #[test(should_fail_with = "unrecognized tagging secret strategy kind")] fn deserializing_address_derived_with_noninfinite_point_fails() { - let _ = TaggingSecretStrategy::deserialize([3, 7, 11]); + let _ = TaggingSecretStrategy::deserialize( + [TaggingSecretStrategy::address_derived().kind as Field, 7, 11], + ); } #[test(should_fail_with = "unrecognized tagging secret strategy kind")] fn deserializing_interactive_handshake_with_noninfinite_point_fails() { - let _ = TaggingSecretStrategy::deserialize([4, 7, 11]); + let _ = TaggingSecretStrategy::deserialize([TaggingSecretStrategy::interactive_handshake().kind as Field, 7, 11]); } } diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr index e1c3f26e022c..fae0e28ab92f 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr @@ -13,6 +13,7 @@ use crate::{ event::{event_interface::EventInterface, EventMessage}, hash::{compute_secret_hash, hash_args}, messages::{ + delivery::OnchainDeliveryMode, discovery::{ ComputeNoteHash, ComputeNoteNullifier, CustomMessageHandler, process_message::process_message_plaintext, }, @@ -91,26 +92,65 @@ pub struct TestEnvironment { /// methods setting each value, e.g.: /// ```noir /// let env = TestEnvironment::new_opts( -/// TestEnvironmentOptions::new().with_tagging_secret_strategy(TaggingSecretStrategy::non_interactive_handshake()), +/// TestEnvironmentOptions::new().with_default_tag_secret_strategy( +/// MessageDelivery::onchain_unconstrained(), +/// TaggingSecretStrategy::non_interactive_handshake(), +/// ), /// ); /// ``` pub struct TestEnvironmentOptions { - tagging_secret_strategy: Option, + default_unconstrained_tagging_secret_strategy: Option, + default_constrained_tagging_secret_strategy: Option, + authorize_all_utility_call_targets: bool, } impl TestEnvironmentOptions { /// Creates a new `TestEnvironmentOptions` with default values. pub fn new() -> Self { - Self { tagging_secret_strategy: Option::none() } + Self { + default_unconstrained_tagging_secret_strategy: Option::none(), + default_constrained_tagging_secret_strategy: Option::none(), + authorize_all_utility_call_targets: false, + } } - /// Sets the [`TaggingSecretStrategy`] the wallet reports through the + /// Sets the default [`TaggingSecretStrategy`] the wallet reports for `mode` through the /// [`resolve_tagging_strategy`](crate::oracle::resolve_tagging_strategy) oracle, affecting message delivery in - /// private executions. + /// private executions that do not fix their own delivery derivation. + /// + /// If no strategy is set for either mode, the wallet hook is left unconfigured and the private execution + /// environment applies its own default. A mode left unset while the other is configured falls back to the default + /// [`TaggingSecretStrategy::non_interactive_handshake`], the strategy a PXE `resolveTaggingSecretStrategy` hook + /// typically hardcodes for modes it does not customize. + pub fn with_default_tag_secret_strategy(&mut self, mode: M, strategy: TaggingSecretStrategy) -> Self + where + M: Into, + { + if mode.into() == OnchainDeliveryMode::onchain_unconstrained() { + self.default_unconstrained_tagging_secret_strategy = Option::some(strategy); + } else { + self.default_constrained_tagging_secret_strategy = Option::some(strategy); + } + *self + } + + /// Sets `strategy` for both delivery modes: the equivalent of a PXE `resolveTaggingSecretStrategy` hook that + /// ignores the request's mode. See + /// [`with_default_tag_secret_strategy`](Self::with_default_tag_secret_strategy) to configure a single mode. + pub fn with_default_tag_secret_strategy_all_modes(&mut self, strategy: TaggingSecretStrategy) -> Self { + let _ = self.with_default_tag_secret_strategy(OnchainDeliveryMode::onchain_unconstrained(), strategy); + self.with_default_tag_secret_strategy(OnchainDeliveryMode::onchain_constrained(), strategy) + } + + /// Authorizes all cross-contract utility call targets for the entire test. /// - /// If not set, the wallet hook is left unconfigured and the private execution environment applies its own default. - pub fn with_tagging_secret_strategy(&mut self, strategy: TaggingSecretStrategy) -> Self { - self.tagging_secret_strategy = Option::some(strategy); + /// Removes the need to list targets on each call via + /// [`CallPrivateOptions::with_authorized_utility_call_targets`] and its siblings. + /// + /// By default, cross-contract utility calls are denied, mirroring a wallet that authorizes no such call. Use + /// this in tests that do not care about utility call authorization. + pub fn with_all_utility_call_targets_authorized(&mut self) -> Self { + self.authorize_all_utility_call_targets = true; *self } } @@ -648,18 +688,21 @@ impl TestEnvironment { /// /// ### Sample usage /// ```noir + /// let strategy = TaggingSecretStrategy::non_interactive_handshake(); /// let env = TestEnvironment::new_opts( - /// TestEnvironmentOptions::new().with_tagging_secret_strategy( - /// TaggingSecretStrategy::non_interactive_handshake(), - /// ), + /// TestEnvironmentOptions::new().with_default_tag_secret_strategy_all_modes(strategy), /// ); /// ``` pub unconstrained fn new_opts(options: TestEnvironmentOptions) -> Self { assert_compatible_oracle_version(); txe_oracles::assert_compatible_txe_oracle_version(); - // Forward the configured strategy to the wallet. - txe_oracles::set_tagging_secret_strategy(options.tagging_secret_strategy); + txe_oracles::set_tagging_secret_strategies( + options.default_unconstrained_tagging_secret_strategy, + options.default_constrained_tagging_secret_strategy, + ); + + txe_oracles::set_authorize_all_utility_call_targets(options.authorize_all_utility_call_targets); Self { // Use an offset to avoid secret collision with account secrets. Without this, when diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr index b02994d2e6ca..e2aed6337206 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_strategy.nr @@ -1,14 +1,56 @@ use crate::{ - keys::ecdh_shared_secret::compute_app_siloed_shared_secret, - messages::delivery::{OnchainDeliveryMode, ResolvedTaggingStrategy}, + messages::delivery::{MessageDelivery, OnchainDeliveryMode, ResolvedTaggingStrategy}, oracle::resolve_tagging_strategy::resolve_tagging_strategy, - protocol::{address::AztecAddress, hash::poseidon2_hash, point::EmbeddedCurvePoint, traits::{FromField, ToField}}, + protocol::{address::AztecAddress, point::EmbeddedCurvePoint, traits::FromField}, test::helpers::{ - tagging_secret_strategy::TaggingSecretStrategy, + tagging_secret_strategy::{compute_directional_app_secret, TaggingSecretStrategy}, test_environment::{TestEnvironment, TestEnvironmentOptions}, }, }; +#[test] +unconstrained fn an_unset_constrained_mode_falls_back_to_the_default_strategy() { + // Only the unconstrained mode is configured: the constrained mode falls back to the default non-interactive + // handshake strategy. + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), + TaggingSecretStrategy::arbitrary_secret(EmbeddedCurvePoint { x: 7, y: 11 }), + )); + let app = env.create_contract_account(); + let recipient = env.create_light_account(); + + env.private_context_at(app, |_| { + let resolved = resolve_tagging_strategy( + AztecAddress::from_field(1), + recipient, + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(resolved, ResolvedTaggingStrategy::non_interactive_handshake()); + }); +} + +#[test] +unconstrained fn an_unset_unconstrained_mode_falls_back_to_the_default_strategy() { + // Only the constrained mode is configured: the unconstrained mode falls back to the default non-interactive + // handshake strategy for an external recipient. + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_constrained(), + TaggingSecretStrategy::interactive_handshake(), + )); + let app = env.create_contract_account(); + let sender = env.create_light_account(); + let recipient = AztecAddress::from_field(8); + + env.private_context_at(app, |_| { + let resolved = resolve_tagging_strategy( + sender, + recipient, + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(resolved, ResolvedTaggingStrategy::non_interactive_handshake()); + }); +} + #[test] unconstrained fn defaults_an_unconstrained_self_send_to_the_address_derived_shared_secret() { let mut env = TestEnvironment::new(); @@ -69,10 +111,9 @@ unconstrained fn defaults_constrained_delivery_to_a_non_interactive_handshake() #[test] unconstrained fn constrained_delivery_succeeds_with_a_configured_strategy() { - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy_all_modes( TaggingSecretStrategy::non_interactive_handshake(), )); - // The hook path looks up the executing contract's class ID, so run in a deployed contract's context. let app = env.create_contract_account(); let recipient = env.create_light_account(); @@ -88,12 +129,12 @@ unconstrained fn constrained_delivery_succeeds_with_a_configured_strategy() { #[test] unconstrained fn applies_a_configured_interactive_handshake_strategy() { - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_constrained(), TaggingSecretStrategy::interactive_handshake(), )); - // The hook path looks up the executing contract's class ID, so run in a deployed contract's context. let app = env.create_contract_account(); - let recipient = AztecAddress::from_field(8); + let recipient = env.create_light_account(); env.private_context_at(app, |_| { let resolved = resolve_tagging_strategy( @@ -107,10 +148,10 @@ unconstrained fn applies_a_configured_interactive_handshake_strategy() { #[test] unconstrained fn applies_the_strategy_set_in_the_options() { - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), TaggingSecretStrategy::arbitrary_secret(EmbeddedCurvePoint { x: 7, y: 11 }), )); - // The hook path looks up the executing contract's class ID, so run in a deployed contract's context. let app = env.create_contract_account(); let recipient = AztecAddress::from_field(8); @@ -126,33 +167,40 @@ unconstrained fn applies_the_strategy_set_in_the_options() { #[test] unconstrained fn app_silos_an_arbitrary_secret_point() { + // Both delivery modes resolve the configured arbitrary secret to the same app-siloed point. Mode-blindness + // is deliberate: PXE's tagging-strategy hook resolves whatever the wallet configured regardless of mode, and + // the TXE oracle must match that behavior. Constrained delivery rejects unsound secrets later, not here. let point = EmbeddedCurvePoint { x: 7, y: 11 }; - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy_all_modes( TaggingSecretStrategy::arbitrary_secret(point), )); - // The hook path looks up the executing contract's class ID, so run in a deployed contract's context. let app = env.create_contract_account(); let recipient = AztecAddress::from_field(8); env.private_context_at(app, |_| { - let resolved = resolve_tagging_strategy( + let expected_secret = compute_directional_app_secret(point, app, recipient); + + let resolved_unconstrained = resolve_tagging_strategy( AztecAddress::from_field(1), recipient, OnchainDeliveryMode::onchain_unconstrained(), ); + assert_eq(resolved_unconstrained, ResolvedTaggingStrategy::unconstrained_secret(expected_secret)); - // PXE app-silos the raw point before handing it over, then folds in the recipient for direction. This mirrors - // `AppTaggingSecret.computeDirectional` on the PXE side. - let app_secret = compute_app_siloed_shared_secret(point, app); - let expected_secret = poseidon2_hash([app_secret, recipient.to_field()]); - assert_eq(resolved, ResolvedTaggingStrategy::unconstrained_secret(expected_secret)); + let resolved_constrained = resolve_tagging_strategy( + AztecAddress::from_field(1), + recipient, + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(resolved_constrained, ResolvedTaggingStrategy::unconstrained_secret(expected_secret)); }); } #[test] unconstrained fn overrides_a_configured_arbitrary_secret_for_an_unconstrained_self_send() { let point = EmbeddedCurvePoint { x: 7, y: 11 }; - let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_tagging_secret_strategy( + let mut env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), TaggingSecretStrategy::arbitrary_secret(point), )); let app = env.create_contract_account(); @@ -168,8 +216,7 @@ unconstrained fn overrides_a_configured_arbitrary_secret_for_an_unconstrained_se // A self-send forces an address-derived secret even with an arbitrary secret configured. The resolved // secret is unconstrained, but not the app-siloed arbitrary point the configured secret would have produced. - let app_secret = compute_app_siloed_shared_secret(point, app); - let arbitrary_secret = poseidon2_hash([app_secret, recipient.to_field()]); + let arbitrary_secret = compute_directional_app_secret(point, app, recipient); assert(resolved.is_unconstrained_secret()); assert(resolved != ResolvedTaggingStrategy::unconstrained_secret(arbitrary_secret)); }); diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr index fbf3e519e450..54cbf803d8f0 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr @@ -21,8 +21,8 @@ use crate::protocol::{ /// [`oracle::version`](crate::oracle::version), which covers oracles used during contract execution by PXE. /// /// The TypeScript counterparts are in `yarn-project/txe/src/txe_oracle_version.ts`. -pub global TXE_ORACLE_VERSION_MAJOR: Field = 3; -pub global TXE_ORACLE_VERSION_MINOR: Field = 0; +pub global TXE_ORACLE_VERSION_MAJOR: Field = 4; +pub global TXE_ORACLE_VERSION_MINOR: Field = 1; /// Asserts that the TXE oracle interface version is compatible. pub unconstrained fn assert_compatible_txe_oracle_version() { @@ -265,8 +265,14 @@ pub unconstrained fn send_l1_to_l2_message( recipient: AztecAddress, ) -> Field {} -#[oracle(aztec_txe_setTaggingSecretStrategy)] -pub unconstrained fn set_tagging_secret_strategy(strategy: Option) {} +#[oracle(aztec_txe_setTaggingSecretStrategies)] +pub unconstrained fn set_tagging_secret_strategies( + unconstrained_strategy: Option, + constrained_strategy: Option, +) {} + +#[oracle(aztec_txe_setAuthorizeAllUtilityCallTargets)] +pub unconstrained fn set_authorize_all_utility_call_targets(authorize_all: bool) {} #[oracle(aztec_txe_privateCallNewFlow)] unconstrained fn private_call_new_flow_oracle( diff --git a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr index 954984055473..b00671840b2a 100644 --- a/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/nested_utility_contract/src/test.nr @@ -1,11 +1,19 @@ use crate::NestedUtility; use aztec::{ protocol::{address::AztecAddress, traits::FromField}, - test::helpers::test_environment::{CallPrivateOptions, ExecuteUtilityOptions, TestEnvironment, ViewPrivateOptions}, + test::helpers::test_environment::{ + CallPrivateOptions, ExecuteUtilityOptions, TestEnvironment, TestEnvironmentOptions, ViewPrivateOptions, + }, }; unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { - let mut env = TestEnvironment::new(); + setup_with_opts(TestEnvironmentOptions::new()) +} + +unconstrained fn setup_with_opts( + options: TestEnvironmentOptions, +) -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new_opts(options); let account = env.create_light_account(); let addr_a = env.deploy("NestedUtility").without_initializer(); let addr_b = env.deploy("NestedUtility").without_initializer(); @@ -88,6 +96,21 @@ unconstrained fn cross_contract_utility_call_from_private_view_succeeds_with_aut assert_eq(result, 8); } +#[test] +unconstrained fn all_call_types_succeed_with_all_utility_call_targets_authorized() { + let (env, account, addr_a, addr_b) = setup_with_opts(TestEnvironmentOptions::new() + .with_all_utility_call_targets_authorized()); + + let utility_result = env.execute_utility(NestedUtility::at(addr_a).delegate_pow_utility(addr_b, 2, 3)); + assert_eq(utility_result, 8); + + let private_result = env.call_private(account, NestedUtility::at(addr_a).delegate_pow_private(addr_b, 2, 3)); + assert_eq(private_result, 8); + + let view_result = env.view_private(account, NestedUtility::at(addr_a).delegate_pow_view(addr_b, 2, 3)); + assert_eq(view_result, 8); +} + #[test] unconstrained fn top_level_utility_observes_no_caller_by_default() { let (env, _, addr_a, _) = setup(); diff --git a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr index 1fa646cdc699..1377f7466025 100644 --- a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr @@ -1,4 +1,4 @@ -//! Thin wrappers around onchain message delivery for tests. +//! Thin wrappers around onchain message delivery for TXE tests. use aztec::macros::aztec; mod test; @@ -7,7 +7,7 @@ mod test; pub contract OnchainDeliveryTest { use aztec::{ macros::{events::event, functions::external, storage::storage}, - messages::delivery::MessageDelivery, + messages::delivery::{MessageDelivery, OnchainDeliveryMode}, note::note_viewer_options::NoteViewerOptions, oracle::notes::get_next_tagging_index, protocol::{ @@ -34,10 +34,10 @@ pub contract OnchainDeliveryTest { } #[external("private")] - fn next_index_for_secret(secret: Field) -> u32 { + fn next_index_for_secret(secret: Field, mode: OnchainDeliveryMode) -> u32 { // Safety: test-only observation of the index the PXE hands out next for this secret. unsafe { - get_next_tagging_index(secret, MessageDelivery::onchain_constrained()) + get_next_tagging_index(secret, mode) } } diff --git a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr index 32b62ead583d..8a0fa818b7c5 100644 --- a/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr @@ -1,20 +1,33 @@ -//! Tests for constrained delivery through the public message-delivery API. +//! Tests for onchain message delivery through the public message-delivery API. //! -//! These tests exercise the sender-side helper flow indirectly via constrained note/event delivery: resolve or +//! The constrained tests exercise the sender-side helper flow indirectly via note/event delivery: resolve or //! bootstrap the app-siloed handshake secret, reserve the next per-secret index, constrain that index, emit the //! sequence nullifier, then emit the tagged private log. The malformed-log test lands a constrained tag without its -//! sequence nullifier so the next real delivery must reject the broken sequence. +//! sequence nullifier so the next real delivery must reject the broken sequence. The unconstrained tests cover +//! delivery in the mode without sequence nullifiers, alongside and independent of the constrained flow. use crate::OnchainDeliveryTest; use aztec::{ - protocol::address::AztecAddress, + messages::delivery::MessageDelivery, + protocol::{address::AztecAddress, point::EmbeddedCurvePoint, traits::FromField}, standard_addresses::STANDARD_HANDSHAKE_REGISTRY_ADDRESS, - test::helpers::test_environment::{CallPrivateOptions, DeployOptions, ExecuteUtilityOptions, TestEnvironment}, + test::helpers::{ + tagging_secret_strategy::{compute_directional_app_secret, TaggingSecretStrategy}, + test_environment::{ + CallPrivateOptions, DeployOptions, ExecuteUtilityOptions, TestEnvironment, TestEnvironmentOptions, + }, + }, }; use handshake_registry_contract::HandshakeRegistry; unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress) { - let mut env = TestEnvironment::new(); + setup_opts(TestEnvironmentOptions::new()) +} + +unconstrained fn setup_opts( + options: TestEnvironmentOptions, +) -> (TestEnvironment, AztecAddress, AztecAddress, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new_opts(options); let sender = env.create_light_account(); let recipient = env.create_light_account(); @@ -35,6 +48,62 @@ unconstrained fn authorizing(registry_address: AztecAddress) -> CallPrivateOptio CallPrivateOptions::new().with_authorized_utility_call_targets([registry_address]) } +unconstrained fn external_recipient() -> AztecAddress { + AztecAddress::from_field(8) +} + +#[test] +unconstrained fn unconstrained_delivery_works_alongside_constrained_delivery() { + let point = EmbeddedCurvePoint { + x: 0x15d55a5b3b2caa6a6207f313f05c5113deba5da9927d6421bcaa164822b911bc, + y: 0x0974c3d0825031ae933243d653ebb1a0b08b90ee7f228f94c5c74739ea3c871e, + }; + // Only the unconstrained mode is configured: the constrained mode falls back to the default non-interactive + // handshake strategy, so the constrained delivery below still bootstraps a registry handshake. + let options = TestEnvironmentOptions::new().with_default_tag_secret_strategy( + MessageDelivery::onchain_unconstrained(), + TaggingSecretStrategy::arbitrary_secret(point), + ); + let (env, registry_address, test_address, sender, _) = setup_opts(options); + let recipient = external_recipient(); + let test_contract = OnchainDeliveryTest::at(test_address); + let registry = HandshakeRegistry::at(registry_address); + + env.call_private(sender, test_contract.emit_event_unconstrained(recipient, 1)); + + let expected_unconstrained_secret = compute_directional_app_secret(point, test_address, recipient); + let next_unconstrained_index = env.call_private( + sender, + test_contract.next_index_for_secret( + expected_unconstrained_secret, + MessageDelivery::onchain_unconstrained().into(), + ), + ); + assert_eq(next_unconstrained_index, 1); + + let maybe_handshake = env.execute_utility_opts( + ExecuteUtilityOptions::new().with_from(test_address), + registry.get_app_siloed_secrets(sender, recipient), + ); + assert(maybe_handshake.is_none()); + + env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); + + let handshake = env + .execute_utility_opts( + ExecuteUtilityOptions::new().with_from(test_address), + registry.get_app_siloed_secrets(sender, recipient), + ) + .expect(f"constrained delivery should have stored a handshake"); + assert(handshake.shared != expected_unconstrained_secret); + + let next_constrained_index = env.call_private( + sender, + test_contract.next_index_for_secret(handshake.shared, MessageDelivery::onchain_constrained().into()), + ); + assert_eq(next_constrained_index, 1); +} + #[test] unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { let (env, registry_address, test_address, sender, recipient) = setup(); @@ -62,7 +131,10 @@ unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); - let next_index = env.call_private(sender, test_contract.next_index_for_secret(second_secret)); + let next_index = env.call_private( + sender, + test_contract.next_index_for_secret(second_secret, MessageDelivery::onchain_constrained().into()), + ); assert_eq(next_index, 1); } @@ -104,7 +176,10 @@ unconstrained fn interactive_handshake_reuses_an_existing_registry_handshake() { test_contract.emit_event_via_interactive_handshake(recipient, 1), ); - let next_index = env.call_private(sender, test_contract.next_index_for_secret(secret)); + let next_index = env.call_private( + sender, + test_contract.next_index_for_secret(secret, MessageDelivery::onchain_constrained().into()), + ); assert_eq(next_index, 1); } diff --git a/yarn-project/aztec.js/src/ethereum/portal_manager.ts b/yarn-project/aztec.js/src/ethereum/portal_manager.ts index 5b913a6d2b4e..5f2113902cf0 100644 --- a/yarn-project/aztec.js/src/ethereum/portal_manager.ts +++ b/yarn-project/aztec.js/src/ethereum/portal_manager.ts @@ -1,3 +1,9 @@ +import { + type L1TxConfig, + type L1TxUtils, + createL1TxUtils, + getL1TxUtilsConfigEnvVars, +} from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient, ViemContract } from '@aztec/ethereum/types'; import { extractEvent } from '@aztec/ethereum/utils'; import type { EpochNumber } from '@aztec/foundation/branded-types'; @@ -16,7 +22,7 @@ import { computeL2ToL1MessageHash, computeSecretHash } from '@aztec/stdlib/hash' import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { getL2ToL1MessageLeafId } from '@aztec/stdlib/messaging'; -import { type Hex, getContract, toFunctionSelector } from 'viem'; +import { type Hex, encodeFunctionData, getContract, toFunctionSelector } from 'viem'; /** L1 to L2 message info to claim it on L2. */ export type L2Claim = { @@ -51,10 +57,26 @@ export async function generateClaimSecret(logger?: Logger): Promise<[Fr, Fr]> { return [secret, secretHash]; } +// `Inbox.sendL2Message` (reached by every `depositToAztec*` call) inserts into a height-10 +// (`L1_TO_L2_MSG_SUBTREE_HEIGHT`) incremental frontier tree. The per-insert cost swings by up to ~10 hash levels +// (which translates to ~40k gas) depending on where the global message index lands when the tx is mined: inserts that +// complete a subtree cascade through cold SLOADs + an SSTORE vs cheap inserts that touch one slot. A point-in-time +// `eth_estimateGas` taken at a cheap index therefore under-sizes a deposit that mines at a subtree boundary. That ~40k +// swing exceeds the default 20% buffer on a ~100k deposit. That is why we raise the gas-limit buffer to 2x here. +// Note: a larger operator override via L1_GAS_LIMIT_BUFFER_PERCENTAGE still wins. +const INBOX_DEPOSIT_GAS_LIMIT_BUFFER_PERCENTAGE = 100; + +/** Per-call gas config for `depositToAztec*`: the Inbox-deposit buffer floor, or a larger operator override. */ +function inboxDepositGasConfig(): L1TxConfig { + const configuredBuffer = getL1TxUtilsConfigEnvVars().gasLimitBufferPercentage ?? 0; + return { gasLimitBufferPercentage: Math.max(configuredBuffer, INBOX_DEPOSIT_GAS_LIMIT_BUFFER_PERCENTAGE) }; +} + /** Helper for managing an ERC20 on L1. */ export class L1TokenManager { private contract: ViemContract; private handler: ViemContract | undefined; + private readonly l1TxUtils: L1TxUtils; public constructor( /** Address of the ERC20 contract. */ @@ -76,6 +98,7 @@ export class L1TokenManager { client: this.extendedClient, }); } + this.l1TxUtils = createL1TxUtils(this.extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the amount of tokens available to mint via the handler. @@ -108,8 +131,10 @@ export class L1TokenManager { const mintAmount = await this.getMintAmount(); this.logger.info(`Minting ${mintAmount} tokens for ${stringifyEthAddress(address, addressName)}`); // NOTE: the handler mints a fixed amount. - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.handler.write.mint([address]), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.handler.address, + abi: FeeAssetHandlerAbi, + data: encodeFunctionData({ abi: FeeAssetHandlerAbi, functionName: 'mint', args: [address] }), }); } @@ -121,8 +146,10 @@ export class L1TokenManager { */ public async approve(amount: bigint, address: Hex, addressName = '') { this.logger.info(`Approving ${amount} tokens for ${stringifyEthAddress(address, addressName)}`); - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.contract.write.approve([address, amount]), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.contract.address, + abi: TestERC20Abi, + data: encodeFunctionData({ abi: TestERC20Abi, functionName: 'approve', args: [address, amount] }), }); } } @@ -131,6 +158,7 @@ export class L1TokenManager { export class L1FeeJuicePortalManager { private readonly tokenManager: L1TokenManager; private readonly contract: ViemContract; + private readonly l1TxUtils: L1TxUtils; constructor( portalAddress: EthAddress, @@ -145,6 +173,7 @@ export class L1FeeJuicePortalManager { abi: FeeJuicePortalAbi, client: extendedClient, }); + this.l1TxUtils = createL1TxUtils(extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the associated token manager for the L1 ERC20. */ @@ -176,9 +205,14 @@ export class L1FeeJuicePortalManager { await this.contract.simulate.depositToAztecPublic(args); - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.contract.write.depositToAztecPublic(args), - }); + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.contract.address, + abi: FeeJuicePortalAbi, + data: encodeFunctionData({ abi: FeeJuicePortalAbi, functionName: 'depositToAztecPublic', args }), + }, + inboxDepositGasConfig(), + ); this.logger.info('Deposited to Aztec public successfully', { txReceipt }); @@ -249,6 +283,7 @@ export class L1FeeJuicePortalManager { export class L1ToL2TokenPortalManager { protected readonly portal: ViemContract; protected readonly tokenManager: L1TokenManager; + protected readonly l1TxUtils: L1TxUtils; constructor( portalAddress: EthAddress, @@ -263,6 +298,7 @@ export class L1ToL2TokenPortalManager { abi: TokenPortalAbi, client: extendedClient, }); + this.l1TxUtils = createL1TxUtils(extendedClient, { logger }, getL1TxUtilsConfigEnvVars()); } /** Returns the token manager for the underlying L1 token. */ @@ -280,15 +316,17 @@ export class L1ToL2TokenPortalManager { const [claimSecret, claimSecretHash] = await this.bridgeSetup(amount, mint); this.logger.info('Sending L1 tokens to L2 to be claimed publicly'); - const { request } = await this.portal.simulate.depositToAztecPublic([ - to.toString(), - amount, - claimSecretHash.toString(), - ]); - - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(request), - }); + const args = [to.toString(), amount, claimSecretHash.toString()] as const; + await this.portal.simulate.depositToAztecPublic(args); + + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'depositToAztecPublic', args }), + }, + inboxDepositGasConfig(), + ); const log = extractEvent( txReceipt.logs, @@ -334,11 +372,17 @@ export class L1ToL2TokenPortalManager { const [claimSecret, claimSecretHash] = await this.bridgeSetup(amount, mint); this.logger.info('Sending L1 tokens to L2 to be claimed privately'); - const { request } = await this.portal.simulate.depositToAztecPrivate([amount, claimSecretHash.toString()]); - - const txReceipt = await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(request), - }); + const args = [amount, claimSecretHash.toString()] as const; + await this.portal.simulate.depositToAztecPrivate(args); + + const { receipt: txReceipt } = await this.l1TxUtils.sendAndMonitorTransaction( + { + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'depositToAztecPrivate', args }), + }, + inboxDepositGasConfig(), + ); const log = extractEvent( txReceipt.logs, @@ -437,7 +481,7 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { } // Call function on L1 contract to consume the message - const { request: withdrawRequest } = await this.portal.simulate.withdraw([ + const withdrawArgs = [ recipient.toString(), amount, false, @@ -445,10 +489,13 @@ export class L1TokenPortalManager extends L1ToL2TokenPortalManager { BigInt(numCheckpointsInEpoch), messageIndex, siblingPath.toBufferArray().map((buf: Buffer): Hex => `0x${buf.toString('hex')}`), - ]); + ] as const; + await this.portal.simulate.withdraw(withdrawArgs); - await this.extendedClient.waitForTransactionReceipt({ - hash: await this.extendedClient.writeContract(withdrawRequest), + await this.l1TxUtils.sendAndMonitorTransaction({ + to: this.portal.address, + abi: TokenPortalAbi, + data: encodeFunctionData({ abi: TokenPortalAbi, functionName: 'withdraw', args: withdrawArgs }), }); const isConsumedAfter = await this.outbox.read.hasMessageBeenConsumedAtEpoch([BigInt(epochNumber), messageLeafId]); diff --git a/yarn-project/aztec.js/src/utils/node.test.ts b/yarn-project/aztec.js/src/utils/node.test.ts index 18ea35d88e06..deec8998aba5 100644 --- a/yarn-project/aztec.js/src/utils/node.test.ts +++ b/yarn-project/aztec.js/src/utils/node.test.ts @@ -1,4 +1,5 @@ import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { TimeoutError } from '@aztec/foundation/error'; import { BlockHash } from '@aztec/stdlib/block'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { @@ -14,7 +15,7 @@ import { import { type MockProxy, mock } from 'jest-mock-extended'; -import { waitForTx } from './node.js'; +import { waitForNode, waitForTx } from './node.js'; describe('waitForTx', () => { let node: MockProxy; @@ -137,3 +138,24 @@ describe('waitForTx', () => { }); }); }); + +describe('waitForNode', () => { + let node: MockProxy; + + beforeEach(() => { + node = mock(); + }); + + it('resolves once the node becomes reachable', async () => { + node.getNodeInfo.mockRejectedValueOnce(new Error('not up yet')).mockResolvedValueOnce({} as any); + + await expect(waitForNode(node, undefined, { timeout: 5, interval: 0.01 })).resolves.toBeUndefined(); + expect(node.getNodeInfo).toHaveBeenCalledTimes(2); + }); + + it('rejects with a TimeoutError when the node stays unreachable', async () => { + node.getNodeInfo.mockRejectedValue(new Error('unreachable')); + + await expect(waitForNode(node, undefined, { timeout: 0.05, interval: 0.01 })).rejects.toThrow(TimeoutError); + }); +}); diff --git a/yarn-project/aztec.js/src/utils/node.ts b/yarn-project/aztec.js/src/utils/node.ts index ee88b68a7159..7f92a1a77948 100644 --- a/yarn-project/aztec.js/src/utils/node.ts +++ b/yarn-project/aztec.js/src/utils/node.ts @@ -6,18 +6,31 @@ import { SortedTxStatuses, TxStatus } from '@aztec/stdlib/tx'; import { DefaultWaitOpts, type WaitOpts } from '../contract/wait_opts.js'; -export const waitForNode = async (node: AztecNode, logger?: Logger) => { - await retryUntil(async () => { - try { - logger?.verbose('Attempting to contact Aztec node...'); - await node.getNodeInfo(); - logger?.verbose('Contacted Aztec node'); - return true; - } catch { - logger?.verbose('Failed to contact Aztec Node'); - } - return undefined; - }, 'RPC Get Node Info'); +/** + * Waits for an Aztec node to become reachable, polling {@link AztecNode.getNodeInfo} until it succeeds. + * @param node - The Aztec node to contact. + * @param logger - Optional logger for polling progress. + * @param opts - Optional timeout and interval (in seconds). `timeout` defaults to {@link DefaultWaitOpts.timeout}; + * pass `timeout: 0` to wait indefinitely. + * @throws TimeoutError if the node stays unreachable past the timeout. + */ +export const waitForNode = async (node: AztecNode, logger?: Logger, opts?: Pick) => { + await retryUntil( + async () => { + try { + logger?.verbose('Attempting to contact Aztec node...'); + await node.getNodeInfo(); + logger?.verbose('Contacted Aztec node'); + return true; + } catch { + logger?.verbose('Failed to contact Aztec Node'); + } + return undefined; + }, + 'RPC Get Node Info', + opts?.timeout ?? DefaultWaitOpts.timeout, + opts?.interval ?? DefaultWaitOpts.interval, + ); }; /** Returns true if the receipt status is at least the desired status level. */ diff --git a/yarn-project/cli-wallet/src/utils/wallet.ts b/yarn-project/cli-wallet/src/utils/wallet.ts index 865705f7038d..605939978ab1 100644 --- a/yarn-project/cli-wallet/src/utils/wallet.ts +++ b/yarn-project/cli-wallet/src/utils/wallet.ts @@ -145,7 +145,10 @@ export class CLIWallet extends BaseWallet { increasedFee: InteractionFeeOptions, ): Promise { const cancellationTxRequest = await this.createCancellationTxExecutionRequest(from, txNonce, increasedFee); - return await this.pxe.proveTx(cancellationTxRequest, { scopes: this.scopesFrom(from), senderForTags: from }); + return await this.pxe.proveTx(cancellationTxRequest, { + scopes: this.scopesFrom(from, [], undefined), + senderForTags: from, + }); } override async getAccountFromAddress(address: AztecAddress) { @@ -292,7 +295,7 @@ export class CLIWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const feeExecutionPayload = await feeOptions.walletFeePaymentMethod?.getExecutionPayload(); const finalExecutionPayload = feeExecutionPayload ? mergeExecutionPayloads([feeExecutionPayload, executionPayload]) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index c0ee53301c98..2389638bbfc5 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -17,6 +17,9 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; import type { TestWallet } from '../../test-wallet/test_wallet.js'; +// Keep in sync with aztec::messages::delivery::OnchainDeliveryMode. +const ONCHAIN_CONSTRAINED_DELIVERY_MODE = { inner: 3 }; + // Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts` describe('delivery/constrained', () => { jest.setTimeout(300_000); @@ -62,7 +65,9 @@ describe('delivery/constrained', () => { // The second send reuses the handshake rather than bootstrapping a new one: the secret is unchanged. expect(secret).toEqual(secretAfterFirstSend); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(2n); }); @@ -98,7 +103,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(2n); }); @@ -117,7 +124,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(2n); }); @@ -134,7 +143,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(1n); }); @@ -152,7 +163,9 @@ describe('delivery/constrained', () => { .simulate({ from: sender }); expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + const { result: index } = await contract.methods + .next_index_for_secret(secret, ONCHAIN_CONSTRAINED_DELIVERY_MODE) + .simulate({ from: sender }); expect(index).toEqual(1n); }); }); diff --git a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts index 862c2f864af2..0bf10aba4d2b 100644 --- a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts @@ -321,7 +321,7 @@ export class TestWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, skipTxValidation, skipFeeEnforcement, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const skipKernels = this.simulationMode !== 'full'; const useOverride = this.simulationMode === 'kernelless-override'; @@ -387,7 +387,7 @@ export class TestWallet extends BaseWallet { }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(exec, opts.from, fee); const txProvingResult = await this.pxe.proveTx(txRequest, { - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); return new ProvenTx( diff --git a/yarn-project/pxe/src/bin/check_oracle_version.ts b/yarn-project/pxe/src/bin/check_oracle_version.ts index 0475415dd69e..3310ccbe814b 100644 --- a/yarn-project/pxe/src/bin/check_oracle_version.ts +++ b/yarn-project/pxe/src/bin/check_oracle_version.ts @@ -3,6 +3,7 @@ import { keccak256String } from '@aztec/foundation/crypto/keccak'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { ORACLE_REGISTRY } from '../contract_function_simulator/index.js'; import { ORACLE_INTERFACE_HASH, ORACLE_VERSION_MAJOR } from '../oracle_version.js'; import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; @@ -10,7 +11,7 @@ import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_ * Verifies that the Oracle interface matches the expected interface hash. * * The Oracle interface needs to be versioned to ensure compatibility between Aztec.nr and PXE. This function computes - * a hash of the `ORACLE_REGISTRY` declaration (where each oracle's parameter names, parameter types, and return type + * a hash of `ORACLE_REGISTRY` (where each oracle's parameter names, parameter types, and return type * live) and compares it against a known hash. If they don't match, it means the interface has changed and the oracle * version needs to be bumped: * - If the change is backward-breaking (e.g. removing/renaming an oracle, or changing its params/return), bump @@ -18,16 +19,8 @@ import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_ * - If the change is an oracle addition (non-breaking), bump ORACLE_VERSION_MINOR. */ function assertOracleInterfaceMatches(): void { - // The script runs from dest/bin/ after compilation, so we go up to the package root then into src/ to find - // the source file. - const currentDir = dirname(fileURLToPath(import.meta.url)); - const packageRoot = dirname(dirname(currentDir)); // Go up from bin/ to pxe/ - const registrySourcePath = join(packageRoot, 'src/contract_function_simulator/oracle/oracle_registry.ts'); - - const oracleInterfaceSignature = getOracleRegistrySignature(registrySourcePath, 'ORACLE_REGISTRY'); - // We use keccak256 here just because we already have it in the dependencies. - const oracleInterfaceHash = keccak256String(oracleInterfaceSignature); + const oracleInterfaceHash = keccak256String(getOracleRegistrySignature(ORACLE_REGISTRY)); if (oracleInterfaceHash !== ORACLE_INTERFACE_HASH) { throw new Error( `The Oracle interface has changed. Update ORACLE_INTERFACE_HASH to ${oracleInterfaceHash} in pxe/src/oracle_version.ts and bump the oracle version (ORACLE_VERSION_MAJOR for breaking changes, ORACLE_VERSION_MINOR for oracle additions).`, diff --git a/yarn-project/pxe/src/bin/index.ts b/yarn-project/pxe/src/bin/index.ts index 44df032436f1..1acfa9e43f3f 100644 --- a/yarn-project/pxe/src/bin/index.ts +++ b/yarn-project/pxe/src/bin/index.ts @@ -1,5 +1 @@ -export { - getOracleInterfaceSignature, - getOracleRegistrySignature, - readNumericGlobal, -} from './oracle_version_helpers.js'; +export { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; diff --git a/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts b/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts index c2ea6c81f63c..ddbe2bb749fe 100644 --- a/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts +++ b/yarn-project/pxe/src/bin/oracle_version_helpers.test.ts @@ -1,7 +1,10 @@ +/* eslint-disable camelcase */ import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { ARRAY, AZTEC_ADDRESS, BOOL, FIELD, OPTION, U32, makeEntry } from '../contract_function_simulator/index.js'; +import { TX_HASH } from '../contract_function_simulator/oracle/oracle_type_mappings.js'; import { getOracleRegistrySignature, readNumericGlobal } from './oracle_version_helpers.js'; describe('readNumericGlobal', () => { @@ -51,17 +54,7 @@ describe('readNumericGlobal', () => { }); describe('getOracleRegistrySignature', () => { - let dir: string; - - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'oracle-registry-')); - }); - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - const SAMPLE_REGISTRY = `export const ORACLE_REGISTRY = { + const SAMPLE_REGISTRY = { aztec_utl_foo: makeEntry({ params: [ { name: 'a', type: U32 }, @@ -72,54 +65,42 @@ describe('getOracleRegistrySignature', () => { aztec_utl_bar: makeEntry({ returnType: FIELD }), aztec_prv_baz: makeEntry({ params: [{ name: 'x', type: FIELD }] }), aztec_prv_qux: makeEntry(), - } satisfies Record; -`; + }; it('builds a sorted signature of names, ordered typed params, and return types', () => { - const path = writeFixture(dir, 'registry.ts', SAMPLE_REGISTRY); - expect(getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toBe( - 'aztec_prv_baz(x: FIELD): void\n' + + expect(getOracleRegistrySignature(SAMPLE_REGISTRY)).toBe( + 'aztec_prv_baz(x: field): void\n' + 'aztec_prv_qux(): void\n' + - 'aztec_utl_bar(): FIELD\n' + - 'aztec_utl_foo(a: U32, b: OPTION(AZTEC_ADDRESS)): BOOL', + 'aztec_utl_bar(): field\n' + + 'aztec_utl_foo(a: u32, b: option(aztec-address)): bool', ); }); it('changes when a parameter type changes (the gap the Oracle-class hash missed)', () => { - const before = writeFixture(dir, 'before.ts', SAMPLE_REGISTRY); - const after = writeFixture(dir, 'after.ts', SAMPLE_REGISTRY.replace('type: OPTION(AZTEC_ADDRESS)', 'type: FIELD')); - expect(getOracleRegistrySignature(after, 'ORACLE_REGISTRY')).not.toBe( - getOracleRegistrySignature(before, 'ORACLE_REGISTRY'), - ); + const after = { + ...SAMPLE_REGISTRY, + aztec_utl_foo: makeEntry({ + params: [ + { name: 'a', type: U32 }, + { name: 'b', type: FIELD }, + ], + returnType: BOOL, + }), + }; + expect(getOracleRegistrySignature(after)).not.toBe(getOracleRegistrySignature(SAMPLE_REGISTRY)); }); - it('is insensitive to formatting of the type expressions', () => { - const reformatted = writeFixture( - dir, - 'reformatted.ts', - SAMPLE_REGISTRY.replace('OPTION(AZTEC_ADDRESS)', 'OPTION(\n AZTEC_ADDRESS\n )'), - ); - const original = writeFixture(dir, 'original.ts', SAMPLE_REGISTRY); - expect(getOracleRegistrySignature(reformatted, 'ORACLE_REGISTRY')).toBe( - getOracleRegistrySignature(original, 'ORACLE_REGISTRY'), - ); - }); - - it('throws on spread members, which are not yet supported', () => { - const path = writeFixture( - dir, - 'spread.ts', - `export const ORACLE_REGISTRY = { - ...BASE_REGISTRY, - aztec_utl_foo: makeEntry({ returnType: FIELD }), - } satisfies Record;\n`, - ); - expect(() => getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toThrow(/Spread elements are not supported/); + it('does not change when a mapping is swapped for a wire-equivalent one', () => { + const withField = { aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: FIELD }] }) }; + const withTxHash = { aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: TX_HASH }] }) }; + expect(getOracleRegistrySignature(withTxHash)).toBe(getOracleRegistrySignature(withField)); }); - it('throws when the registry is absent', () => { - const path = writeFixture(dir, 'absent.ts', `export const SOMETHING_ELSE = {};\n`); - expect(() => getOracleRegistrySignature(path, 'ORACLE_REGISTRY')).toThrow(/Could not find oracle registry/); + it('captures nested composite kinds in the signature', () => { + const registry = { + aztec_utl_foo: makeEntry({ params: [{ name: 'a', type: OPTION(ARRAY(FIELD)) }], returnType: AZTEC_ADDRESS }), + }; + expect(getOracleRegistrySignature(registry)).toBe('aztec_utl_foo(a: option(array(field))): aztec-address'); }); }); diff --git a/yarn-project/pxe/src/bin/oracle_version_helpers.ts b/yarn-project/pxe/src/bin/oracle_version_helpers.ts index 00bdbcbb7979..ee5640230c9c 100644 --- a/yarn-project/pxe/src/bin/oracle_version_helpers.ts +++ b/yarn-project/pxe/src/bin/oracle_version_helpers.ts @@ -1,75 +1,13 @@ -/* eslint-disable import-x/no-named-as-default-member */ import { readFileSync } from 'fs'; -import ts from 'typescript'; -/** - * Extracts method signatures from TypeScript classes or interfaces and returns a deterministic string representation. - * - * This is used to detect when an oracle interface changes so that the oracle version can be bumped. It works with both - * class declarations (e.g. PXE's `Oracle` class) and interface declarations (e.g. TXE's `IAvmExecutionOracle`). - * - * @param sourcePath - Absolute path to the TypeScript source file to parse. - * @param targets - Names of classes or interfaces to extract methods from. - * @param excludedMembers - Method names to skip (e.g. non-oracle helpers like `constructor`). - */ -export function getOracleInterfaceSignature(sourcePath: string, targets: string[], excludedMembers: string[]): string { - const sourceCode = readFileSync(sourcePath, 'utf-8'); - const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - - const methodSignatures: string[] = []; - - function visit(node: ts.Node) { - const isTarget = - (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) && targets.includes(node.name?.text ?? ''); - - if (isTarget) { - node.members.forEach(member => { - if ( - (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && - member.name && - ts.isIdentifier(member.name) - ) { - const methodName = member.name.text; - - if (excludedMembers.includes(methodName)) { - return; - } - - const paramSignatures: string[] = []; - member.parameters.forEach(param => { - const paramName = extractParameterName(param, sourceFile); - const paramType = extractTypeString(param.type, sourceFile); - paramSignatures.push(`${paramName}: ${paramType}`); - }); - - const returnType = extractTypeString(member.type, sourceFile); - - const signature = `${methodName}(${paramSignatures.join(', ')}): ${returnType}`; - methodSignatures.push(signature); - } - }); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - - methodSignatures.sort(); - - return methodSignatures.join(''); -} +import type { OracleRegistryEntry } from '../contract_function_simulator/index.js'; /** - * Extracts a deterministic signature string from an oracle registry object literal (e.g. PXE's `ORACLE_REGISTRY`). - * - * Reads the registry declaration where each oracle's wire ABI lives: the ordered parameter names with their - * `TypeMapping` expressions and the return type. The resulting hash is sensitive to parameter, type, and return - * changes, not just oracle additions and removals. + * Extracts a deterministic signature string from an oracle registry (e.g. PXE's `ORACLE_REGISTRY`). * - * Type expressions are captured as their source text (e.g. `OPTION(AZTEC_ADDRESS)`, `BOUNDED_VEC(NOTE)`), so the - * signature tracks the composition of types. However, if the internal serialization logic of a `TypeMapping` constant - * (e.g. `FIELD`) changes without the constant being renamed, the hash will not change. + * Reads the registry where each oracle's wire ABI lives: the ordered parameter names with their `TypeMapping` labels + * and the return type. The resulting hash is sensitive to parameter, type, and return changes, not just oracle + * additions and removals. * * @example * // Given a registry like: @@ -79,32 +17,13 @@ export function getOracleInterfaceSignature(sourcePath: string, targets: string[ * // } satisfies Record; * // * // Returns (sorted, newline-joined): - * // "aztec_prv_bar(): void\naztec_utl_foo(a: U32): BOOL" - * - * @param sourcePath - Absolute path to the TypeScript source file declaring the registry. - * @param registryName - Name of the registry constant to read (e.g. `ORACLE_REGISTRY`). + * // "aztec_prv_bar(): void\naztec_utl_foo(a: u32): bool" */ -export function getOracleRegistrySignature(sourcePath: string, registryName: string): string { - const sourceCode = readFileSync(sourcePath, 'utf-8'); - const sourceFile = ts.createSourceFile(sourcePath, sourceCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - - const registry = findObjectLiteral(sourceFile, registryName); - if (!registry) { - throw new Error(`Could not find oracle registry '${registryName}' in ${sourcePath}.`); - } - - const oracleSignatures = registry.properties.map(property => { - if (!ts.isPropertyAssignment(property)) { - throw new Error( - `Unexpected member in oracle registry '${registryName}': ${property.getText(sourceFile)}. Spread elements ` + - `are not supported.`, - ); - } - - const oracleName = getPropertyName(property.name, sourceFile); - const { params, returnType } = extractRegistryEntry(property.initializer, sourceFile); - const paramSignatures = params.map(param => `${param.name}: ${param.type}`); - return `${oracleName}(${paramSignatures.join(', ')}): ${returnType}`; +export function getOracleRegistrySignature(registry: Record): string { + const oracleSignatures = Object.entries(registry).map(([name, entry]) => { + const paramSignatures = entry.params.map(p => `${p.name}: ${p.type.label}`); + const returnType = entry.returnType === undefined ? 'void' : entry.returnType.label; + return `${name}(${paramSignatures.join(', ')}): ${returnType}`; }); oracleSignatures.sort(); @@ -133,179 +52,3 @@ export function readNumericGlobal(sourcePath: string, name: string): number { } return Number(match[1]); } - -function extractParameterName(param: ts.ParameterDeclaration, sourceFile: ts.SourceFile): string { - const name = param.name; - - if (ts.isIdentifier(name)) { - return name.text; - } - - if (ts.isArrayBindingPattern(name)) { - if (name.elements.length > 0) { - const element = name.elements[0]; - if (ts.isBindingElement(element)) { - const elementName = element.name; - if (ts.isIdentifier(elementName)) { - return elementName.text; - } - if (ts.isArrayBindingPattern(elementName) || ts.isObjectBindingPattern(elementName)) { - return elementName.getText(sourceFile); - } - } - } - return name.getText(sourceFile); - } - - if (ts.isObjectBindingPattern(name)) { - return name.getText(sourceFile); - } - - return (name as ts.Node).getText(sourceFile); -} - -/** Returns the source text of a type annotation, or `'void'` if absent. */ -function extractTypeString(typeNode: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string { - if (!typeNode) { - return 'void'; - } - - return typeNode.getText(sourceFile).replace(/\s+/g, ' ').trim(); -} - -/** - * Finds a top-level object-literal-valued constant by name, unwrapping `satisfies`/`as`/parenthesized wrappers. - * - * @example - * // `const REGISTRY = { ... } satisfies Record` => the `{ ... }` ObjectLiteralExpression - */ -function findObjectLiteral(sourceFile: ts.SourceFile, name: string): ts.ObjectLiteralExpression | undefined { - let result: ts.ObjectLiteralExpression | undefined; - - function visit(node: ts.Node) { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && node.initializer) { - result = unwrapObjectLiteral(node.initializer); - } - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return result; -} - -/** Peels `satisfies`, `as`, and parenthesized wrappers off an expression to reach the underlying object literal. */ -function unwrapObjectLiteral(node: ts.Expression): ts.ObjectLiteralExpression | undefined { - let current = node; - while (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) { - current = current.expression; - } - return ts.isObjectLiteralExpression(current) ? current : undefined; -} - -/** - * Extracts the ordered `params` (names + type expressions) and `returnType` from a `makeEntry({ ... })` call. - * - * @example - * // makeEntry({ params: [{ name: 'a', type: OPTION(FIELD) }], returnType: BOOL }) - * // => { params: [{ name: 'a', type: 'OPTION(FIELD)' }], returnType: 'BOOL' } - * // - * // makeEntry() - * // => { params: [], returnType: 'void' } - */ -function extractRegistryEntry( - initializer: ts.Expression, - sourceFile: ts.SourceFile, -): { params: { name: string; type: string }[]; returnType: string } { - if (!ts.isCallExpression(initializer)) { - throw new Error(`Expected a makeEntry(...) call but got: ${initializer.getText(sourceFile)}`); - } - - const [arg] = initializer.arguments; - if (arg === undefined) { - return { params: [], returnType: 'void' }; - } - if (!ts.isObjectLiteralExpression(arg)) { - throw new Error(`Expected a makeEntry object argument but got: ${arg.getText(sourceFile)}`); - } - - let params: { name: string; type: string }[] = []; - let returnType = 'void'; - - arg.properties.forEach(property => { - if (!ts.isPropertyAssignment(property)) { - throw new Error(`Unexpected makeEntry property: ${property.getText(sourceFile)}`); - } - const key = getPropertyName(property.name, sourceFile); - if (key === 'params') { - params = extractRegistryParams(property.initializer, sourceFile); - } else if (key === 'returnType') { - returnType = normalizeExpressionText(property.initializer, sourceFile); - } else { - throw new Error(`Unexpected makeEntry property '${key}'.`); - } - }); - - return { params, returnType }; -} - -/** - * Extracts `{ name, type }` pairs from the `params` array literal inside a `makeEntry` call. - */ -function extractRegistryParams( - initializer: ts.Expression, - sourceFile: ts.SourceFile, -): { name: string; type: string }[] { - if (!ts.isArrayLiteralExpression(initializer)) { - throw new Error(`Expected a params array but got: ${initializer.getText(sourceFile)}`); - } - - return initializer.elements.map(element => { - if (!ts.isObjectLiteralExpression(element)) { - throw new Error(`Expected a param object but got: ${element.getText(sourceFile)}`); - } - - let name: string | undefined; - let type: string | undefined; - element.properties.forEach(property => { - if (!ts.isPropertyAssignment(property)) { - throw new Error(`Unexpected param property: ${property.getText(sourceFile)}`); - } - const key = getPropertyName(property.name, sourceFile); - if (key === 'name') { - if (!ts.isStringLiteralLike(property.initializer)) { - throw new Error(`Expected a string literal param name but got: ${property.initializer.getText(sourceFile)}`); - } - name = property.initializer.text; - } else if (key === 'type') { - type = normalizeExpressionText(property.initializer, sourceFile); - } else { - throw new Error(`Unexpected param property '${key}'.`); - } - }); - - if (name === undefined || type === undefined) { - throw new Error(`Param missing 'name' or 'type': ${element.getText(sourceFile)}`); - } - return { name, type }; - }); -} - -/** Returns the text of an identifier or string-literal property name, throwing on computed or numeric names. */ -function getPropertyName(name: ts.PropertyName, sourceFile: ts.SourceFile): string { - if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { - return name.text; - } - throw new Error(`Unsupported property name: ${name.getText(sourceFile)}`); -} - -/** - * Returns the source text of an expression with all whitespace stripped for format-insensitive comparison. - * - * @example - * // `OPTION(\n AZTEC_ADDRESS\n)` => `"OPTION(AZTEC_ADDRESS)"` - */ -function normalizeExpressionText(node: ts.Expression, sourceFile: ts.SourceFile): string { - // Type expressions are TypeMapping references and calls (identifiers, parentheses, commas, numbers) with no - // meaningful whitespace, so we strip it entirely to keep the signature stable across reformatting. - return node.getText(sourceFile).replace(/\s+/g, ''); -} diff --git a/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts new file mode 100644 index 000000000000..133602298e3c --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.test.ts @@ -0,0 +1,109 @@ +import { ARCHIVE_HEIGHT } from '@aztec/constants'; +import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { MembershipWitness } from '@aztec/foundation/trees'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { BlockHash } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { PublicDataWitness } from '@aztec/stdlib/trees'; +import { MinedTxReceipt, TxEffect, TxExecutionResult, TxHash, TxStatus } from '@aztec/stdlib/tx'; + +import { mock } from 'jest-mock-extended'; + +import { AztecNodeReadCache } from './aztec_node_read_cache.js'; + +describe('AztecNodeReadCache', () => { + let aztecNode: ReturnType>; + let cache: AztecNodeReadCache; + + const makeMinedReceipt = (txHash: TxHash) => + new MinedTxReceipt( + txHash, + TxStatus.FINALIZED, + TxExecutionResult.SUCCESS, + 0n, + BlockHash.random(), + BlockNumber(1), + SlotNumber(1), + 0, + EpochNumber(1), + TxEffect.empty(), + ); + + beforeEach(() => { + aztecNode = mock(); + cache = new AztecNodeReadCache(aztecNode); + }); + + it('shares concurrent tx receipt reads', async () => { + const txHash = TxHash.random(); + const deferred = promiseWithResolvers>(); + aztecNode.getTxReceipt.mockReturnValue(deferred.promise); + + const first = cache.getTxReceiptWithEffect(txHash); + const second = cache.getTxReceiptWithEffect(txHash); + const receipt = makeMinedReceipt(txHash); + deferred.resolve(receipt); + + await expect(Promise.all([first, second])).resolves.toEqual([receipt, receipt]); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + }); + + it('evicts rejected reads so callers can retry', async () => { + const txHash = TxHash.random(); + const receipt = makeMinedReceipt(txHash); + aztecNode.getTxReceipt.mockRejectedValueOnce(new Error('temporary failure')); + aztecNode.getTxReceipt.mockResolvedValueOnce(receipt); + + await expect(cache.getTxReceiptWithEffect(txHash)).rejects.toThrow('temporary failure'); + await expect(cache.getTxReceiptWithEffect(txHash)).resolves.toBe(receipt); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('keeps cache entries separate by method and arguments', async () => { + const blockHash = BlockHash.random(); + const leafSlot = Fr.random(); + const blockWitness = MembershipWitness.empty(ARCHIVE_HEIGHT); + const publicDataWitness = PublicDataWitness.random(); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(blockWitness); + aztecNode.getPublicDataWitness.mockResolvedValue(publicDataWitness); + + await expect(cache.getBlockHashMembershipWitness(blockHash, blockHash)).resolves.toBe(blockWitness); + await expect(cache.getPublicDataWitness(blockHash, leafSlot)).resolves.toBe(publicDataWitness); + + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + expect(aztecNode.getPublicDataWitness).toHaveBeenCalledTimes(1); + }); + + it('caches successful undefined results', async () => { + const referenceBlockHash = BlockHash.random(); + const blockHash = BlockHash.random(); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(undefined); + + await expect(cache.getBlockHashMembershipWitness(referenceBlockHash, blockHash)).resolves.toBeUndefined(); + await expect(cache.getBlockHashMembershipWitness(referenceBlockHash, blockHash)).resolves.toBeUndefined(); + + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + }); + + it('reuses cached slots across overlapping public storage ranges', async () => { + const blockHash = BlockHash.random(); + const contractAddress = await AztecAddress.random(); + const startStorageSlot = new Fr(100); + aztecNode.getPublicStorageAt.mockImplementation((_block, _contract, slot) => + Promise.resolve(new Fr(slot.value + 1n)), + ); + + await expect(cache.getPublicStorageRange(blockHash, contractAddress, startStorageSlot, 2)).resolves.toEqual([ + new Fr(101), + new Fr(102), + ]); + await expect(cache.getPublicStorageRange(blockHash, contractAddress, new Fr(101), 2)).resolves.toEqual([ + new Fr(102), + new Fr(103), + ]); + + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(3); + }); +}); diff --git a/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts new file mode 100644 index 000000000000..ce7ff940fe61 --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/aztec_node_read_cache.ts @@ -0,0 +1,89 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { BlockHash, BlockParameter } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import type { TxHash } from '@aztec/stdlib/tx'; + +/** + * Per-execution cache for immutable Aztec node reads. + */ +export class AztecNodeReadCache { + private readonly cache = new Map>(); + + constructor(private readonly aztecNode: AztecNode) {} + + /** Fetches a block without reissuing the same node request. */ + public getBlock(block: BlockParameter) { + return this.#cachedRead(`block:${this.#keyPart(block)}`, () => this.aztecNode.getBlock(block)); + } + + /** Fetches a transaction receipt with its effect attached. */ + public getTxReceiptWithEffect(txHash: TxHash) { + return this.#cachedRead(`tx-receipt-with-effect:${txHash.toString()}`, () => + this.aztecNode.getTxReceipt(txHash, { includeTxEffect: true } as const), + ); + } + + /** Fetches an archive-tree witness for a block hash. */ + public getBlockHashMembershipWitness(referenceBlock: BlockParameter, blockHash: BlockHash) { + return this.#cachedRead( + `block-hash-membership-witness:${this.#keyPart(referenceBlock)}:${blockHash.toString()}`, + () => this.aztecNode.getBlockHashMembershipWitness(referenceBlock, blockHash), + ); + } + + /** Fetches a public-data-tree witness for a leaf slot. */ + public getPublicDataWitness(referenceBlock: BlockParameter, leafSlot: Fr) { + return this.#cachedRead(`public-data-witness:${this.#keyPart(referenceBlock)}:${leafSlot.toString()}`, () => + this.aztecNode.getPublicDataWitness(referenceBlock, leafSlot), + ); + } + + /** Fetches public storage for a single slot. */ + public getPublicStorageAt(referenceBlock: BlockParameter, contractAddress: AztecAddress, storageSlot: Fr) { + return this.#cachedRead( + `public-storage:${this.#keyPart(referenceBlock)}:${contractAddress.toString()}:${storageSlot.toString()}`, + () => this.aztecNode.getPublicStorageAt(referenceBlock, contractAddress, storageSlot), + ); + } + + /** Fetches a contiguous public storage range, reusing cached reads for overlapping slots. */ + public getPublicStorageRange( + referenceBlock: BlockParameter, + contractAddress: AztecAddress, + startStorageSlot: Fr, + numberOfElements: number, + ) { + const slots = Array(numberOfElements) + .fill(0) + .map((_, i) => new Fr(startStorageSlot.value + BigInt(i))); + + return Promise.all(slots.map(storageSlot => this.getPublicStorageAt(referenceBlock, contractAddress, storageSlot))); + } + + #cachedRead(key: string, fetch: () => Promise): Promise { + const cached = this.cache.get(key); + if (cached) { + return cached as Promise; + } + + const promise = fetch(); + promise.catch(() => this.cache.delete(key)); + this.cache.set(key, promise); + return promise; + } + + #keyPart(value: unknown): string { + if (['string', 'number', 'bigint', 'boolean'].includes(typeof value)) { + return String(value); + } + if (value && typeof value === 'object') { + const toString = (value as { toString?: () => string }).toString; + if (toString && toString !== Object.prototype.toString) { + return toString.call(value); + } + return JSON.stringify(value, (_key, nested) => (typeof nested === 'bigint' ? nested.toString() : nested)); + } + return String(value); + } +} diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 1453126d9d15..64d3cde6661f 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -6,6 +6,7 @@ export { type ParamTypes, } from './oracle/oracle_registry.js'; export { + ALIAS, ARRAY, AZTEC_ADDRESS, BIGINT, @@ -13,8 +14,7 @@ export { BLOCK_NUMBER, BOOL, BOUNDED_VEC, - BUFFER, - BYTE, + U8, CONTRACT_INSTANCE, DELIVERY_MODE, EPHEMERAL_ARRAY, @@ -23,6 +23,8 @@ export { FIELD, FIXED_ARRAY, FUNCTION_SELECTOR, + LEAF, + LEAF_INDEX, LOG_RETRIEVAL_REQUEST, LOG_RETRIEVAL_RESPONSE, MEMBERSHIP_WITNESS, @@ -32,16 +34,34 @@ export { PENDING_TAGGED_LOG, POINT, PROVIDED_SECRET, + SCALAR, SLOT_NUMBER, STR, STRUCT, U32, + U64, + U128, tryFieldWidth, + isArrayMapping, + isBoundedVecMapping, + isEphemeralArrayMapping, + isFixedArrayMapping, + isFixedBoundedVecMapping, + isOptionMapping, + isStructMapping, + type ArrayMapping, + type BoundedVecMapping, + type CompositeMapping, + type EphemeralArrayMapping, + type FixedArrayMapping, + type FixedBoundedVecMapping, type InputSlot, type MaybePromise, + type OptionMapping, type OutputSlot, type SlotShape, type StructField, + type StructMapping, type TypeMapping, } from './oracle/oracle_type_mappings.js'; export { ExecutionNoteCache } from './execution_note_cache.js'; @@ -74,5 +94,5 @@ export { NoteValidationRequest } from './noir-structs/note_validation_request.js export type { PendingTaggedLog } from './noir-structs/pending_tagged_log.js'; export type { TxEffectData } from './noir-structs/tx_effect_data.js'; export type { ProvidedSecret } from './noir-structs/provided_secret.js'; -export { ResolvedTx } from './noir-structs/resolved_tx.js'; +export type { ResolvedTx } from './noir-structs/resolved_tx.js'; export { TransientArrayService } from './transient_array_service.js'; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts index 8b1930281d3e..578ec805c6dd 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tagging_strategy.ts @@ -47,8 +47,10 @@ export function resolvedTaggingStrategyToFields(resolved: ResolvedTaggingStrateg export function resolvedTaggingStrategyFromFields(kind: number, secret: Fr): ResolvedTaggingStrategy { switch (kind) { case NON_INTERACTIVE_HANDSHAKE: + assertAbsentSecret(kind, secret); return { type: 'non-interactive-handshake' }; case INTERACTIVE_HANDSHAKE: + assertAbsentSecret(kind, secret); return { type: 'interactive-handshake' }; case UNCONSTRAINED_SECRET: return { type: 'unconstrained-secret', secret }; @@ -56,3 +58,9 @@ export function resolvedTaggingStrategyFromFields(kind: number, secret: Fr): Res throw new Error(`Unrecognized resolved tagging strategy kind: ${kind}`); } } + +function assertAbsentSecret(kind: number, secret: Fr): void { + if (!secret.isZero()) { + throw new Error(`Resolved tagging strategy ${kind} must not include a secret`); + } +} diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts deleted file mode 100644 index ce4aba29c4cc..000000000000 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Fr } from '@aztec/foundation/curves/bn254'; -import { updateInlineTestData } from '@aztec/foundation/testing/files'; -import { TxHash } from '@aztec/stdlib/tx'; - -import { ResolvedTx } from './resolved_tx.js'; - -describe('ResolvedTx', () => { - it('serialization matches snapshots and output of Noir serialization', () => { - // Setup test data - const txHash = new TxHash(new Fr(123n)); - const uniqueNoteHashes = [new Fr(4n), new Fr(5n)]; - const firstNullifier = new Fr(6n); - const blockNumber = 7; - const blockHash = new Fr(8n); - - // Create a ResolvedTx instance - const resolvedTx = new ResolvedTx(txHash, uniqueNoteHashes, firstNullifier, blockNumber, blockHash); - - // Serialize the resolved tx - const serialized = resolvedTx.toFields(); - - // Compare with snapshot - expect(serialized.map(f => f.toString())).toMatchInlineSnapshot(` - [ - "0x000000000000000000000000000000000000000000000000000000000000007b", - "0x0000000000000000000000000000000000000000000000000000000000000004", - "0x0000000000000000000000000000000000000000000000000000000000000005", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000000", - "0x0000000000000000000000000000000000000000000000000000000000000002", - "0x0000000000000000000000000000000000000000000000000000000000000006", - "0x0000000000000000000000000000000000000000000000000000000000000007", - "0x0000000000000000000000000000000000000000000000000000000000000008", - ] - `); - - // Run with AZTEC_GENERATE_TEST_DATA=1 to update noir test data - const fieldArrayStr = `[${serialized.map(f => f.toString()).join(',')}]`; - updateInlineTestData( - 'noir-projects/aztec-nr/aztec/src/oracle/tx_resolution.nr', - 'serialized_resolved_tx_from_typescript', - fieldArrayStr, - ); - }); -}); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts index 0da90f0a157b..b3997b60d2af 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/resolved_tx.ts @@ -1,7 +1,5 @@ -import { MAX_NOTE_HASHES_PER_TX } from '@aztec/constants'; -import { padArrayEnd } from '@aztec/foundation/collection'; -import { Fr } from '@aztec/foundation/curves/bn254'; -import { TxHash } from '@aztec/stdlib/tx'; +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { TxHash } from '@aztec/stdlib/tx'; /** * The resolved on-chain context of a transaction. @@ -9,45 +7,13 @@ import { TxHash } from '@aztec/stdlib/tx'; * Carries the note hashes and first nullifier needed to discover notes that originated from the transaction, plus the * number and hash of the block in which it was mined. * - * A TS version of the `ResolvedTx` struct in `oracle/tx_resolution.nr`. + * A TS version of the `ResolvedTx` struct in `oracle/tx_resolution.nr`; its wire layout lives in the `RESOLVED_TX` + * type mapping. */ -export class ResolvedTx { - constructor( - public txHash: TxHash, - public uniqueNoteHashesInTx: Fr[], - public firstNullifierInTx: Fr, - public blockNumber: number, - public blockHash: Fr, - ) {} - - toFields(): Fr[] { - return [ - this.txHash.hash, - ...serializeBoundedVec(this.uniqueNoteHashesInTx, MAX_NOTE_HASHES_PER_TX), - this.firstNullifierInTx, - new Fr(this.blockNumber), - this.blockHash, - ]; - } - - static empty(): ResolvedTx { - return new ResolvedTx(TxHash.zero(), [], Fr.ZERO, 0, Fr.ZERO); - } -} - -/** - * Helper function to serialize a bounded vector according to Noir's BoundedVec format: the storage array padded to - * `maxLength`, followed by the actual length. - * @param values - The values to serialize - * @param maxLength - The maximum length of the bounded vector - * @returns The serialized bounded vector as Fr[] - */ -function serializeBoundedVec(values: Fr[], maxLength: number): Fr[] { - const storage = padArrayEnd( - values, - Fr.ZERO, - maxLength, - `Attempted to serialize ${values} values into a BoundedVec with max length ${maxLength}`, - ); - return [...storage, new Fr(values.length)]; -} +export type ResolvedTx = { + txHash: TxHash; + uniqueNoteHashesInTx: Fr[]; + firstNullifierInTx: Fr; + blockNumber: number; + blockHash: Fr; +}; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts index 11b131d97894..d814fbe50957 100644 --- a/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/tx_effect_data.ts @@ -6,10 +6,11 @@ import type { ContractClassLogData } from './contract_class_log_data.js'; /** * Wire form of a {@link TxEffect} as the `getTxEffect` oracle returns it: identical to the domain type except its logs - * are flattened to their Noir layout. Keeping the conversion in the handler lets the `TX_EFFECT` mapping stay purely - * structural. + * are flattened to their Noir layout and its revert code is the plain `u8` Noir declares. Keeping the conversion in + * the handler lets the `TX_EFFECT` mapping stay purely structural. */ -export type TxEffectData = Omit, 'publicLogs' | 'contractClassLogs'> & { +export type TxEffectData = Omit, 'revertCode' | 'publicLogs' | 'contractClassLogs'> & { + revertCode: number; publicLogs: FlatPublicLogs; contractClassLogs: ContractClassLogData[]; }; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index b9bba4a8efc5..cff2615f8a6b 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -13,8 +13,6 @@ import { BLOCK_NUMBER, BOOL, BOUNDED_VEC, - BUFFER, - BYTE, CALL_PRIVATE_RESULT, CONTRACT_CLASS_LOG, CONTRACT_INSTANCE, @@ -23,6 +21,7 @@ import { EVENT_VALIDATION_REQUEST, FACT_COLLECTION, FIELD, + FIXED_ARRAY, FUNCTION_SELECTOR, type InputSlot, KEY_VALIDATION_REQUEST, @@ -50,6 +49,7 @@ import { TX_EFFECT, TX_HASH, type TypeMapping, + U8, U32, UTILITY_CONTEXT, assertReadersConsumed, @@ -63,8 +63,7 @@ export { BLOCK_NUMBER, BOOL, BOUNDED_VEC, - BUFFER, - BYTE, + U8, CONTRACT_CLASS_LOG, DELIVERY_MODE, RESOLVED_TAGGING_STRATEGY, @@ -141,6 +140,14 @@ export const ORACLE_REGISTRY = { returnType: OPTION(MEMBERSHIP_WITNESS(ARCHIVE_HEIGHT)), }), + aztec_utl_areBlockHashesInArchive: makeEntry({ + params: [ + { name: 'anchorBlockHash', type: BLOCK_HASH }, + { name: 'blockHashes', type: EPHEMERAL_ARRAY(BLOCK_HASH) }, + ], + returnType: EPHEMERAL_ARRAY(BOOL), + }), + aztec_utl_getNullifierMembershipWitness: makeEntry({ params: [ { name: 'blockHash', type: BLOCK_HASH }, @@ -265,6 +272,11 @@ export const ORACLE_REGISTRY = { returnType: OPTION(TX_EFFECT), }), + aztec_utl_getTxEffects: makeEntry({ + params: [{ name: 'txHashes', type: EPHEMERAL_ARRAY(TX_HASH) }], + returnType: EPHEMERAL_ARRAY(OPTION(TX_EFFECT)), + }), + aztec_utl_setCapsule: makeEntry({ params: [ { name: 'contractAddress', type: AZTEC_ADDRESS }, @@ -304,11 +316,11 @@ export const ORACLE_REGISTRY = { aztec_utl_decryptAes128: makeEntry({ params: [ - { name: 'ciphertext', type: BOUNDED_VEC(BYTE) }, - { name: 'iv', type: BUFFER(8, 16) }, - { name: 'symKey', type: BUFFER(8, 16) }, + { name: 'ciphertext', type: BOUNDED_VEC(U8) }, + { name: 'iv', type: FIXED_ARRAY(U8, 16) }, + { name: 'symKey', type: FIXED_ARRAY(U8, 16) }, ], - returnType: OPTION(BOUNDED_VEC(BYTE)), + returnType: OPTION(BOUNDED_VEC(U8)), }), aztec_utl_getSharedSecrets: makeEntry({ diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts index ee6405fd1b3f..56cbc494e5ac 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.test.ts @@ -8,11 +8,11 @@ import { EphemeralArrayService } from '../ephemeral_array_service.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import { type LogRetrievalRequest, LogSource } from '../noir-structs/log_retrieval_request.js'; import { Option } from '../noir-structs/option.js'; +import type { ResolvedTaggingStrategy } from '../noir-structs/resolved_tagging_strategy.js'; import { ARRAY, AZTEC_ADDRESS, BOUNDED_VEC, - BYTE, DELIVERY_MODE, EPHEMERAL_ARRAY, FIELD, @@ -20,13 +20,15 @@ import { MEMBERSHIP_WITNESS, OPTION, POINT, - PROVIDED_SECRET, + RESOLVED_TAGGING_STRATEGY, type SlotShape, type TypeMapping, + U8, U32, makeEntry, slotsOf, } from './oracle_registry.js'; +import { FIXED_ARRAY, FIXED_BOUNDED_VEC, LEAF, SCALAR, STRUCT, TX_HASH } from './oracle_type_mappings.js'; /** * Tests for the oracle type mappings: how the PXE encodes values to, and decodes them from, the flat field arrays that @@ -61,21 +63,21 @@ describe('oracle type mappings', () => { }); it('rejects a value above the maximum', () => { - expect(() => deserialize(U32, new Fr(0x100000000n))).toThrow('U32 overflow'); + expect(() => deserialize(U32, new Fr(0x100000000n))).toThrow('u32 overflow'); }); }); - describe('BYTE', () => { + describe('U8', () => { it('deserializes a value in range', () => { - expect(deserialize(BYTE, new Fr(0))).toBe(0); + expect(deserialize(U8, new Fr(0))).toBe(0); }); it('deserializes the maximum value', () => { - expect(deserialize(BYTE, new Fr(255))).toBe(255); + expect(deserialize(U8, new Fr(255))).toBe(255); }); it('rejects a value above the maximum', () => { - expect(() => deserialize(BYTE, new Fr(256))).toThrow('BYTE overflow'); + expect(() => deserialize(U8, new Fr(256))).toThrow('u8 overflow'); }); }); @@ -95,16 +97,23 @@ describe('oracle type mappings', () => { }); }); - describe('PROVIDED_SECRET', () => { - it('deserializes a secret and delivery mode from a two-field slot', () => { - const provided = PROVIDED_SECRET.deserialization!.fn([new FieldReader([new Fr(42), new Fr(3)])]); - expect(provided.secret).toEqual(new Fr(42)); - expect(provided.mode).toBe(AppTaggingSecretKind.CONSTRAINED); + describe('RESOLVED_TAGGING_STRATEGY', () => { + const secret = new Fr(42); + + it('rejects an unknown strategy kind', () => { + expect(() => deserializeStrategy(new Fr(99), Fr.ZERO)).toThrow('Unrecognized resolved tagging strategy kind'); }); - it('declares the two-field wire shape', () => { - expect(PROVIDED_SECRET.shape).toEqual([{ len: 2 }]); + it.each([ + ['non-interactive handshake', new Fr(1)], + ['interactive handshake', new Fr(3)], + ])('rejects %s with a nonzero secret', (_name, kind) => { + expect(() => deserializeStrategy(kind, secret)).toThrow(`Resolved tagging strategy ${kind.toNumber()}`); }); + + function deserializeStrategy(kind: Fr, secret: Fr): ResolvedTaggingStrategy { + return RESOLVED_TAGGING_STRATEGY.deserialization!.fn([new FieldReader([kind]), new FieldReader([secret])]); + } }); describe('AZTEC_ADDRESS', () => { @@ -141,7 +150,7 @@ describe('oracle type mappings', () => { Fr.ZERO, [Fr.ZERO, Fr.ZERO, Fr.ZERO], ]); - expect(OPTION(BOUNDED_VEC(BYTE)).serialization!.fn(Option.none>({ maxLength: 2 }))).toEqual([ + expect(OPTION(BOUNDED_VEC(U8)).serialization!.fn(Option.none>({ maxLength: 2 }))).toEqual([ Fr.ZERO, [Fr.ZERO, Fr.ZERO], Fr.ZERO, @@ -203,8 +212,8 @@ describe('oracle type mappings', () => { it('fully consumes a partially-full vec as an oracle param', () => { // The registry rejects a param whose slots aren't fully read. A partially-full vec leaves zero padding in its // storage slot, so this checks the deserializer drains that padding instead of tripping the consumption check. - const entry = makeEntry({ params: [{ name: 'ciphertext', type: BOUNDED_VEC(BYTE) }] }); - const inputs = toInputSlots(BOUNDED_VEC(BYTE).serialization!.fn(BoundedVec.from({ data: [1, 2], maxLength: 4 }))); + const entry = makeEntry({ params: [{ name: 'ciphertext', type: BOUNDED_VEC(U8) }] }); + const inputs = toInputSlots(BOUNDED_VEC(U8).serialization!.fn(BoundedVec.from({ data: [1, 2], maxLength: 4 }))); const [{ value }] = entry.deserializeParams(inputs); expect(value.data).toEqual([1, 2]); expect(value.maxLength).toBe(4); @@ -300,10 +309,36 @@ describe('oracle type mappings', () => { expect(shapeOf(witness.serialization!.fn(MembershipWitness.random(4)))).toEqual(witness.shape); }); }); + + describe('label', () => { + it('renders a scalar leaf as its kind', () => { + expect(FIELD.label).toBe('field'); + expect(TX_HASH.label).toBe('field'); + expect(AZTEC_ADDRESS.label).toBe('aztec-address'); + }); + + it('recurses through composites, including numeric generics', () => { + expect(OPTION(ARRAY(FIELD)).label).toBe('option(array(field))'); + expect(FIXED_ARRAY(FIELD, 4).label).toBe('array(field,4)'); + expect(FIXED_BOUNDED_VEC(AZTEC_ADDRESS, 8).label).toBe('bounded-vec(aztec-address,8)'); + expect(EPHEMERAL_ARRAY(FIELD).label).toBe('ephemeral-array(field)'); + }); + + it('renders a struct namelessly, splicing nested structs into the parent', () => { + const inner = STRUCT([{ name: 'blockNumber', type: U32 }]); + const struct = STRUCT([ + { name: 'txHash', type: TX_HASH }, + { name: 'origin', type: inner }, + { name: 'wrapped', type: OPTION(inner) }, + ]); + expect(struct.label).toBe('{field,u32,option({u32})}'); + }); + }); }); /** A field mapping that throws on a zero, proving a None skips its zero-padded inner instead of parsing it. */ -const REJECTS_ZERO_FIELD_TYPE: TypeMapping = { +const REJECTS_ZERO_FIELD_TYPE: TypeMapping = SCALAR({ + kind: 'field', serialization: { fn: v => [v] }, deserialization: { fn: ([reader]) => { @@ -314,14 +349,14 @@ const REJECTS_ZERO_FIELD_TYPE: TypeMapping = { return field; }, }, - shape: ['scalar'], -}; +}); /** A mapping whose shape claims two fields but whose fn reads only one, leaving a trailing field unread. */ -const UNDER_READS_SLOT_TYPE: TypeMapping = { +const UNDER_READS_SLOT_TYPE: TypeMapping = LEAF({ + kind: 'field', deserialization: { fn: ([reader]) => reader.readField() }, shape: [{ len: 2 }], -}; +}); /** Round-trips a value through a bidirectional mapping: serialize to wire slots, then deserialize back. */ function roundTrip(mapping: TypeMapping, value: T): T { @@ -335,7 +370,7 @@ function toInputSlots(slots: (Fr | Fr[])[]): string[][] { return slots.map(slot => (Array.isArray(slot) ? slot : [slot]).map(f => f.toString())); } -/** Deserializes a single-slot scalar mapping (U32, BYTE, ...) from one field. */ +/** Deserializes a single-slot scalar mapping (U32, U8, ...) from one field. */ function deserialize(mapping: TypeMapping, value: Fr): T { return mapping.deserialization!.fn([new FieldReader([value])]); } diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index 2dc5ea63d7fd..7de4b4bff2ca 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -19,9 +19,9 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { FieldReader } from '@aztec/foundation/serialize'; import { MembershipWitness, type SiblingPath } from '@aztec/foundation/trees'; -import { type ACVMField, fromUintArray } from '@aztec/simulator/client'; +import type { ACVMField } from '@aztec/simulator/client'; import { FunctionSelector, NoteSelector } from '@aztec/stdlib/abi'; -import { PublicDataWrite, RevertCode } from '@aztec/stdlib/avm'; +import { PublicDataWrite } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; import type { ContractInstancePreimage, PartialAddress } from '@aztec/stdlib/contract'; @@ -52,7 +52,12 @@ import { TxHash, } from '@aztec/stdlib/tx'; -import type { OriginBlock, RetractableFactOrigin } from '../../storage/fact_store/index.js'; +import { + type OriginBlock, + type OriginBlockState, + type RetractableFactOrigin, + originBlockStateFromNumber, +} from '../../storage/fact_store/index.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import type { ContractClassLogData } from '../noir-structs/contract_class_log_data.js'; import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js'; @@ -118,6 +123,23 @@ export type SlotShape = 'scalar' | { len: number } | 'variable' | { lenFrom: (si * Either side is optional — output-only types omit `deserialization`, input-only types omit `serialization`. */ export interface TypeMapping { + /** + * Discriminant for this mapping's shape: which scalar or combinator it is. The `is*Mapping` guards switch on it, and + * the combinators tag it (`'array'`, `'option'`, `'struct'`, ...). For a scalar it also names the Noir-declared type + * (`'field'`, `'u32'`), which is then its {@link label}. + */ + kind: string; + /** + * The wire-structural Noir type string the oracle interface hash is computed from: + * - a scalar's is its bare `kind` (`'field'`); + * - a combinator's is built from its inner mappings' labels (`'array(field,4)'`, `'option(u32)'`); + * - a struct's is a nameless `'{field,u32}'`, with nested struct labels spliced into the parent. + * + * The label encodes exactly what the wire does, so anything the wire tolerates (field names, struct-in-struct + * nesting, a TS wrapper over a Noir primitive like `TX_HASH` over `Field`) shares a label — and mappings sharing a + * label must be wire-equivalent. + */ + label: string; serialization?: { /** Convert a typed value to ACVM output slot(s). */ fn: (value: T) => (Fr | Fr[])[]; @@ -154,78 +176,91 @@ export function assertReadersConsumed(readers: FieldReader[]): void { // ─── Scalar Type Mappings ──────────────────────────────────────────────────── -export const FIELD: TypeMapping = { +/** + * A leaf mapping whose {@link TypeMapping.label} is just its bare {@link TypeMapping.kind}. Its wire shape is arbitrary + * (several fields, or a variable-width run); a single-field scalar uses the shorter {@link SCALAR} instead. + */ +export function LEAF(mapping: Omit, 'label'>): TypeMapping { + return { ...mapping, label: mapping.kind }; +} + +/** A {@link LEAF} occupying a single `'scalar'` wire slot, so callers omit the shape. */ +export function SCALAR(mapping: Omit, 'label' | 'shape'>): TypeMapping { + return LEAF({ ...mapping, shape: ['scalar'] }); +} + +export const FIELD: TypeMapping = SCALAR({ + kind: 'field', serialization: { fn: v => [v] }, deserialization: { fn: ([reader]) => reader.readField() }, - shape: ['scalar'], -}; +}); -export const BOOL: TypeMapping = { +export const BOOL: TypeMapping = SCALAR({ + kind: 'bool', serialization: { fn: v => [new Fr(v ? 1n : 0n)] }, deserialization: { fn: ([reader]) => !reader.readField().isZero() }, - shape: ['scalar'], -}; +}); -export const U32: TypeMapping = { +export const U32: TypeMapping = SCALAR({ + kind: 'u32', serialization: { fn: v => [new Fr(v)] }, - deserialization: { - fn: ([reader]) => { - const value = reader.readField().toBigInt(); - if (value > 0xffffffffn) { - throw new Error(`U32 overflow: value ${value} exceeds u32 max (${0xffffffffn})`); - } - return Number(value); - }, - }, - shape: ['scalar'], -}; + deserialization: { fn: ([reader]) => Number(uintFromField(reader.readField(), 32)) }, +}); -export const BLOCK_NUMBER: TypeMapping = { - serialization: { fn: v => [new Fr(v)] }, - deserialization: { fn: ([reader]) => BlockNumber(reader.readField().toNumber()) }, - shape: ['scalar'], -}; +export const BLOCK_NUMBER: TypeMapping = ALIAS(U32, { + wrap: v => BlockNumber(v), + unwrap: v => v, +}); -/** A u8 byte: serializes to a single Fr; deserializes from a single Fr to a number in [0, 255]. */ -export const BYTE: TypeMapping = { +export const U8: TypeMapping = SCALAR({ + kind: 'u8', serialization: { fn: byte => [new Fr(byte)] }, - deserialization: { - fn: ([reader]) => { - const value = reader.readField().toBigInt(); - if (value > 0xffn) { - throw new Error(`BYTE overflow: value ${value} exceeds u8 max (255)`); - } - return Number(value); - }, - }, - shape: ['scalar'], -}; + deserialization: { fn: ([reader]) => Number(uintFromField(reader.readField(), 8)) }, +}); // Noir passes `MessageDelivery` onchain variants here. -export const DELIVERY_MODE: TypeMapping = { +export const DELIVERY_MODE: TypeMapping = SCALAR({ + kind: 'onchain-delivery-mode', deserialization: { - fn: readers => appTaggingSecretKindFromDeliveryMode(BYTE.deserialization!.fn(readers)), + fn: readers => appTaggingSecretKindFromDeliveryMode(U8.deserialization!.fn(readers)), }, - shape: BYTE.shape, -}; +}); -export const RESOLVED_TAGGING_STRATEGY: TypeMapping = { +export const RESOLVED_TAGGING_STRATEGY: TypeMapping = LEAF({ + kind: 'resolved-tagging-strategy', serialization: { fn: resolved => resolvedTaggingStrategyToFields(resolved) }, deserialization: { fn: ([kindReader, secretReader]) => resolvedTaggingStrategyFromFields(kindReader.readField().toNumber(), secretReader.readField()), }, shape: ['scalar', 'scalar'], -}; +}); -export const BIGINT: TypeMapping = { +export const BIGINT: TypeMapping = ALIAS(FIELD, { + wrap: f => f.toBigInt(), + unwrap: v => new Fr(v), +}); + +export const U64: TypeMapping = SCALAR({ + kind: 'u64', serialization: { fn: v => [new Fr(v)] }, - deserialization: { fn: ([reader]) => reader.readField().toBigInt() }, - shape: ['scalar'], -}; + deserialization: { fn: ([reader]) => uintFromField(reader.readField(), 64) }, +}); + +export const U128: TypeMapping = SCALAR({ + kind: 'u128', + serialization: { fn: v => [new Fr(v)] }, + deserialization: { fn: ([reader]) => uintFromField(reader.readField(), 128) }, +}); + +export const LEAF_INDEX: TypeMapping = ALIAS(FIELD, { + wrap: f => f.toNumber(), + unwrap: v => new Fr(v), +}); /** Reads every field in the slot as a UTF-8 character code. */ -export const STR: TypeMapping = { +export const STR: TypeMapping = LEAF({ + kind: 'str', serialization: { fn: str => [Array.from(Buffer.from(str, 'utf-8')).map(b => new Fr(b))] }, deserialization: { fn: ([reader]) => { @@ -237,69 +272,64 @@ export const STR: TypeMapping = { }, }, shape: ['variable'], -}; +}); -export const AZTEC_ADDRESS: TypeMapping = { +export const AZTEC_ADDRESS: TypeMapping = SCALAR({ + kind: 'aztec-address', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => AztecAddress.fromFieldUnsafe(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const BLOCK_HASH: TypeMapping = { - serialization: { fn: v => [new Fr(v.toBuffer())] }, - deserialization: { fn: ([reader]) => new BlockHash(reader.readField()) }, - shape: ['scalar'], -}; +export const BLOCK_HASH: TypeMapping = ALIAS(FIELD, { + wrap: f => new BlockHash(f), + unwrap: v => new Fr(v.toBuffer()), +}); -export const FUNCTION_SELECTOR: TypeMapping = { +export const FUNCTION_SELECTOR: TypeMapping = SCALAR({ + kind: 'function-selector', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => FunctionSelector.fromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const NOTE_SELECTOR: TypeMapping = { - serialization: { fn: v => [v.toField()] }, - deserialization: { fn: ([reader]) => NoteSelector.fromField(reader.readField()) }, - shape: ['scalar'], -}; +export const NOTE_SELECTOR: TypeMapping = ALIAS(FIELD, { + wrap: f => NoteSelector.fromField(f), + unwrap: v => v.toField(), +}); -export const TX_HASH: TypeMapping = { - serialization: { fn: v => [v.hash] }, - deserialization: { fn: ([reader]) => TxHash.fromField(reader.readField()) }, - shape: ['scalar'], -}; +export const TX_HASH: TypeMapping = ALIAS(FIELD, { + wrap: f => TxHash.fromField(f), + unwrap: v => v.hash, +}); -const TAG: TypeMapping = { - serialization: { fn: v => [v.value] }, - deserialization: { fn: ([reader]) => new Tag(reader.readField()) }, - shape: ['scalar'], -}; +const TAG: TypeMapping = ALIAS(FIELD, { + wrap: f => new Tag(f), + unwrap: v => v.value, +}); export const POINT: TypeMapping = STRUCT([ { name: 'x', type: FIELD }, { name: 'y', type: FIELD }, ]); -const LOG_SOURCE: TypeMapping = { +const LOG_SOURCE: TypeMapping = SCALAR({ + kind: 'log-source', serialization: { fn: v => [new Fr(v)] }, deserialization: { fn: ([reader]) => logSourceFromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const ETH_ADDRESS: TypeMapping = { +export const ETH_ADDRESS: TypeMapping = SCALAR({ + kind: 'eth-address', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => EthAddress.fromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -export const SLOT_NUMBER: TypeMapping = { - serialization: { fn: v => [new Fr(v)] }, - shape: ['scalar'], -}; +export const SLOT_NUMBER: TypeMapping = ALIAS(FIELD, { + unwrap: v => new Fr(v), +}); const APPEND_ONLY_TREE_SNAPSHOT: TypeMapping = STRUCT([ { name: 'root', type: FIELD }, - { name: 'nextAvailableLeafIndex', type: U32 }, + { name: 'nextAvailableLeafIndex', type: LEAF_INDEX }, ]); const PARTIAL_STATE_REFERENCE: TypeMapping = STRUCT([ @@ -314,8 +344,8 @@ const STATE_REFERENCE: TypeMapping = STRUCT([ ]); const GAS_FEES: TypeMapping = STRUCT([ - { name: 'feePerDaGas', type: BIGINT }, - { name: 'feePerL2Gas', type: BIGINT }, + { name: 'feePerDaGas', type: U128 }, + { name: 'feePerL2Gas', type: U128 }, ]); const GLOBAL_VARIABLES: TypeMapping = STRUCT([ @@ -323,7 +353,7 @@ const GLOBAL_VARIABLES: TypeMapping = STRUCT([ { name: 'version', type: FIELD }, { name: 'blockNumber', type: BLOCK_NUMBER }, { name: 'slotNumber', type: SLOT_NUMBER }, - { name: 'timestamp', type: BIGINT }, + { name: 'timestamp', type: U64 }, { name: 'coinbase', type: ETH_ADDRESS }, { name: 'feeRecipient', type: AZTEC_ADDRESS }, { name: 'gasFees', type: GAS_FEES }, @@ -427,11 +457,6 @@ export const CONTRACT_CLASS_LOG: TypeMapping = STRUCT([ { name: 'emittedLength', type: U32 }, ]); -const REVERT_CODE: TypeMapping = { - serialization: { fn: rc => [rc.toField()] }, - shape: ['scalar'], -}; - const PUBLIC_DATA_WRITE: TypeMapping = STRUCT([ { name: 'leafSlot', type: FIELD }, { name: 'value', type: FIELD }, @@ -459,7 +484,7 @@ const CONTRACT_CLASS_LOGS: TypeMapping = FIXED_ARRAY( ); export const TX_EFFECT: TypeMapping = STRUCT([ - { name: 'revertCode', type: REVERT_CODE }, + { name: 'revertCode', type: U8 }, { name: 'txHash', type: TX_HASH }, { name: 'transactionFee', type: FIELD }, { name: 'noteHashes', type: FIXED_ARRAY(FIELD, MAX_NOTE_HASHES_PER_TX) }, @@ -471,7 +496,8 @@ export const TX_EFFECT: TypeMapping = STRUCT([ { name: 'contractClassLogs', type: CONTRACT_CLASS_LOGS }, ]); -export const NOTE: TypeMapping = { +export const NOTE: TypeMapping = LEAF({ + kind: 'note', serialization: { fn: noteData => packAsHintedNote({ @@ -487,21 +513,23 @@ export const NOTE: TypeMapping = { // A packed note is the note's (variable-count) field items followed by 6 metadata scalars, emitted as one field // output per element. Its length depends on the note, so it is described as a single variable-width run. shape: ['variable'], -}; +}); -export const NOTE_VALIDATION_REQUEST: TypeMapping = { +export const NOTE_VALIDATION_REQUEST: TypeMapping = LEAF({ + kind: 'note-validation-request', deserialization: { fn: ([reader]) => NoteValidationRequest.fromFields(reader), }, shape: ['variable'], -}; +}); -export const EVENT_VALIDATION_REQUEST: TypeMapping = { +export const EVENT_VALIDATION_REQUEST: TypeMapping = LEAF({ + kind: 'event-validation-request', deserialization: { fn: ([reader]) => EventValidationRequest.fromFields(reader), }, shape: ['variable'], -}; +}); export const LOG_RETRIEVAL_REQUEST: TypeMapping = STRUCT([ { name: 'contractAddress', type: AZTEC_ADDRESS }, @@ -517,82 +545,88 @@ export const LOG_RETRIEVAL_RESPONSE: TypeMapping = STRUCT< { name: 'uniqueNoteHashesInTx', type: FIXED_BOUNDED_VEC(FIELD, MAX_NOTE_HASHES_PER_TX) }, { name: 'firstNullifierInTx', type: FIELD }, { name: 'blockNumber', type: BLOCK_NUMBER }, - { name: 'blockTimestamp', type: BIGINT }, + { name: 'blockTimestamp', type: U64 }, { name: 'blockHash', type: BLOCK_HASH }, ]); -// `ResolvedTx.toFields()` packs the whole struct into a single slot: txHash, the uniqueNoteHashesInTx BoundedVec -// (MAX_NOTE_HASHES_PER_TX storage fields + length), firstNullifierInTx, blockNumber and blockHash. -export const RESOLVED_TX: TypeMapping = { - serialization: { fn: resolved => [resolved.toFields()] }, - shape: [{ len: MAX_NOTE_HASHES_PER_TX + 5 }], -}; +export const RESOLVED_TX: TypeMapping = STRUCT([ + { name: 'txHash', type: TX_HASH }, + { name: 'uniqueNoteHashesInTx', type: FIXED_BOUNDED_VEC(FIELD, MAX_NOTE_HASHES_PER_TX) }, + { name: 'firstNullifierInTx', type: FIELD }, + { name: 'blockNumber', type: U32 }, + { name: 'blockHash', type: FIELD }, +]); export const PENDING_TAGGED_LOG: TypeMapping = STRUCT([ { name: 'log', type: FIXED_BOUNDED_VEC(FIELD, PRIVATE_LOG_SIZE_IN_FIELDS) }, { name: 'context', type: RESOLVED_TX }, ]); -export const ORIGIN_BLOCK: TypeMapping = { - serialization: { fn: ob => [new Fr(ob.blockNumber), ob.blockHash] }, - deserialization: { - fn: ([blockNumberReader, blockHashReader]) => ({ - blockNumber: blockNumberReader.readField().toNumber(), - blockHash: blockHashReader.readField(), - }), - }, - shape: ['scalar', 'scalar'], -}; +export const ORIGIN_BLOCK: TypeMapping = STRUCT([ + { name: 'blockNumber', type: U32 }, + { name: 'blockHash', type: FIELD }, +]); + +const ORIGIN_BLOCK_STATE: TypeMapping = SCALAR({ + kind: 'origin-block-state', + serialization: { fn: v => [new Fr(v)] }, + deserialization: { fn: ([reader]) => originBlockStateFromNumber(reader.readField().toNumber()) }, +}); /** Read-side origin of a retractable fact, carrying the chain state of its origin block. */ -export const RETRACTABLE_FACT_ORIGIN: TypeMapping = { - serialization: { fn: o => [new Fr(o.blockNumber), o.blockHash, new Fr(o.blockState)] }, - shape: ['scalar', 'scalar', 'scalar'], -}; +export const RETRACTABLE_FACT_ORIGIN: TypeMapping = STRUCT([ + { name: 'blockNumber', type: U32 }, + { name: 'blockHash', type: FIELD }, + { name: 'blockState', type: ORIGIN_BLOCK_STATE }, +]); -// `facts` and `payload` each materialize to a single service-slot id, so a Fact occupies: factTypeId, the payload -// array slot, and `OPTION(RETRACTABLE_FACT_ORIGIN)` (its discriminant plus RETRACTABLE_FACT_ORIGIN's three slots). -export const FACT: TypeMapping = { - serialization: { - fn: f => [ - f.factTypeId, - f.payload.materializeSlot(v => FIELD.serialization!.fn(v).flat() as Fr[]), - ...OPTION(RETRACTABLE_FACT_ORIGIN).serialization!.fn(f.originBlock), - ], - }, - shape: ['scalar', 'scalar', 'scalar', 'scalar', 'scalar', 'scalar'], -}; +export const FACT: TypeMapping = STRUCT([ + { name: 'factTypeId', type: FIELD }, + { name: 'payload', type: EPHEMERAL_ARRAY(FIELD) }, + { name: 'originBlock', type: OPTION(RETRACTABLE_FACT_ORIGIN) }, +]); -export const FACT_COLLECTION: TypeMapping = { - serialization: { - fn: c => [ - c.contractAddress.toField(), - c.scope.toField(), - c.factCollectionTypeId, - c.factCollectionId, - c.facts.materializeSlot(v => FACT.serialization!.fn(v).flat() as Fr[]), - ], - }, - shape: ['scalar', 'scalar', 'scalar', 'scalar', 'scalar'], -}; +export const FACT_COLLECTION: TypeMapping = STRUCT([ + { name: 'contractAddress', type: AZTEC_ADDRESS }, + { name: 'scope', type: AZTEC_ADDRESS }, + { name: 'factCollectionTypeId', type: FIELD }, + { name: 'factCollectionId', type: FIELD }, + { name: 'facts', type: EPHEMERAL_ARRAY(FACT) }, +]); -export const PROVIDED_SECRET: TypeMapping = { - deserialization: { - fn: ([reader]) => ({ - secret: reader.readField(), - mode: appTaggingSecretKindFromDeliveryMode(BYTE.deserialization!.fn([reader])), - }), - }, - shape: [{ len: 2 }], -}; +export const PROVIDED_SECRET: TypeMapping = STRUCT([ + { name: 'secret', type: FIELD }, + { name: 'mode', type: DELIVERY_MODE }, +]); // ─── Combinator Type Mappings ──────────────────────────────────────────────── -function SIBLING_PATH(height: N): TypeMapping> { +/** + * A TS-side alias of `base`: the same Noir type on the wire (it inherits `base`'s `kind`, `label`, and `shape`) but a + * richer TS value. `wrap` maps a base value to the alias, `unwrap` back; a serialize-only alias passes only `unwrap`. + */ +export function ALIAS( + base: TypeMapping, + conversions: { wrap?: (value: B) => T; unwrap?: (value: T) => B }, +): TypeMapping { + const { wrap, unwrap } = conversions; return { + kind: base.kind, + label: base.label, + serialization: base.serialization && unwrap ? { fn: value => base.serialization!.fn(unwrap(value)) } : undefined, + deserialization: + base.deserialization && wrap ? { fn: readers => wrap(base.deserialization!.fn(readers)) } : undefined, + shape: base.shape, + }; +} + +function SIBLING_PATH(height: N): TypeMapping> { + return LEAF({ + // On the wire (and in Noir) a sibling path is a plain `[Field; height]`, so it shares the fixed-array kind. + kind: `array(field,${height})`, serialization: { fn: sp => [sp.toFields()] }, shape: [{ len: height }], - }; + }); } export function MEMBERSHIP_WITNESS(height: N): TypeMapping> { @@ -602,9 +636,10 @@ export function MEMBERSHIP_WITNESS(height: N): TypeMapping(inner: TypeMapping): TypeMapping & { kind: 'array'; inner: TypeMapping } { +export function ARRAY(inner: TypeMapping): ArrayMapping { return { kind: 'array', + label: `array(${inner.label})`, inner, serialization: inner.serialization ? { fn: values => [packElements(inner, values)] } : undefined, deserialization: inner.deserialization @@ -623,13 +658,11 @@ export function ARRAY(inner: TypeMapping): TypeMapping & { kind: 'arr * zero-padded to exactly `length * elementWidth` fields, and deserializes all `length` elements back. An absent * element is the zero encoding, so the padding is derived from the shape. */ -export function FIXED_ARRAY( - element: TypeMapping, - length: number, -): TypeMapping & { kind: 'fixed-array'; inner: TypeMapping; length: number } { +export function FIXED_ARRAY(element: TypeMapping, length: number): FixedArrayMapping { const elementWidth = fieldWidth(element.shape); return { kind: 'fixed-array', + label: `array(${element.label},${length})`, inner: element, length, serialization: element.serialization @@ -649,17 +682,16 @@ export function FIXED_ARRAY( * * Both directions are derived from `element`: bidirectional iff `element` has both serialization and deserialization. * - * @example Serializing `BoundedVec.from({ data: [0x41, 0x42], maxLength: 4 })` with `BOUNDED_VEC(BYTE)`: + * @example Serializing `BoundedVec.from({ data: [0x41, 0x42], maxLength: 4 })` with `BOUNDED_VEC(U8)`: * ``` * slot 0: [Fr(0x41), Fr(0x42), Fr(0), Fr(0)] // data padded to maxLength * slot 1: Fr(2) // actual length * ``` */ -export function BOUNDED_VEC( - inner: TypeMapping, -): TypeMapping> & { kind: 'bounded-vec'; inner: TypeMapping } { +export function BOUNDED_VEC(inner: TypeMapping): BoundedVecMapping { return { kind: 'bounded-vec', + label: `bounded-vec(${inner.label})`, inner, serialization: inner.serialization ? { @@ -698,13 +730,11 @@ export function BOUNDED_VEC( * (zero-padded) followed by the actual length, with no length prefix, so the width is statically known. Serialize-only. * Throws if the input exceeds `maxLength`. */ -export function FIXED_BOUNDED_VEC( - element: TypeMapping, - maxLength: number, -): TypeMapping & { kind: 'fixed-bounded-vec'; inner: TypeMapping; maxLength: number } { +export function FIXED_BOUNDED_VEC(element: TypeMapping, maxLength: number): FixedBoundedVecMapping { const width = fieldWidth(element.shape); return { kind: 'fixed-bounded-vec', + label: `bounded-vec(${element.label},${maxLength})`, inner: element, maxLength, serialization: element.serialization @@ -740,9 +770,10 @@ export function FIXED_BOUNDED_VEC( * slot 1: Fr(0) // zero-filled from AZTEC_ADDRESS.shape * ``` */ -export function OPTION(inner: TypeMapping): TypeMapping> & { kind: 'option'; inner: TypeMapping } { +export function OPTION(inner: TypeMapping): OptionMapping { return { kind: 'option', + label: `option(${inner.label})`, inner, serialization: inner.serialization ? { @@ -770,32 +801,14 @@ export function OPTION(inner: TypeMapping): TypeMapping> & { kin }; } -/** - * A fixed packed uint buffer (Noir `[u8; length]` at `bitSize` 8): one slot of `length` packed uint values ↔ `Buffer`. - * `length` is the field count, which equals the byte count at `bitSize` 8. - */ -export function BUFFER(bitSize: number, length: number): TypeMapping { - return { - serialization: { - fn: buf => [Array.from(buf).map(b => new Fr(b))], - }, - deserialization: { - fn: ([reader]) => - fromUintArray( - reader.readFieldArray(length).map(f => f.toString()), - bitSize, - ), - }, - shape: [{ len: length }], - }; -} - -export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping> { +export function EPHEMERAL_ARRAY(element: TypeMapping): EphemeralArrayMapping { // EphemeralArray.readAll hands each row's flat fields in as a single reader; reconstruct the element's per-slot // readers from its shape, deserialize, and assert the row was fully consumed so a row with trailing fields is // rejected. const rowElement: TypeMapping | undefined = element.deserialization ? { + kind: element.kind, + label: element.label, deserialization: { fn: ([rowReader]) => deserializeElement(element, rowReader.readFieldArray(rowReader.remainingFields())), }, @@ -805,6 +818,9 @@ export function EPHEMERAL_ARRAY(element: TypeMapping): TypeMapping [ea.materializeSlot(v => serializeElement(element, v))] } : undefined, @@ -824,11 +840,10 @@ export type StructField = { name: TName; * `shape`. `T` is the struct's TS value type and must match the field layout — serialization reads each field by name * off the value, deserialization returns the decoded bag as `T`; convert in the handler, not here, when `T` differs. */ -export function STRUCT( - fields: readonly StructField[], -): TypeMapping & { kind: 'struct'; fields: readonly StructField[] } { +export function STRUCT(fields: readonly StructField[]): StructMapping { return { kind: 'struct', + label: `{${structFieldLabels(fields)}}`, fields, serialization: fields.every(f => f.type.serialization) ? { @@ -856,6 +871,79 @@ export function STRUCT( }; } +/** + * The composite `TypeMapping`s (`ARRAY`/`BOUNDED_VEC`/`OPTION`/`STRUCT`/`FIXED_ARRAY`/`FIXED_BOUNDED_VEC`/ + * `EPHEMERAL_ARRAY`), discriminated by `kind`. The combinators attach `kind` plus the inner mapping(s) at construction; + * the registry erases params to the base `TypeMapping`, so the guards below recover the structure for recursion. + */ +export type ArrayMapping = TypeMapping & { kind: 'array'; inner: TypeMapping }; +export type BoundedVecMapping = TypeMapping> & { kind: 'bounded-vec'; inner: TypeMapping }; +export type OptionMapping = TypeMapping> & { kind: 'option'; inner: TypeMapping }; +export type StructMapping = TypeMapping & { kind: 'struct'; fields: readonly StructField[] }; +export type FixedArrayMapping = TypeMapping & { + kind: 'fixed-array'; + inner: TypeMapping; + length: number; +}; +export type FixedBoundedVecMapping = TypeMapping & { + kind: 'fixed-bounded-vec'; + inner: TypeMapping; + maxLength: number; +}; +export type EphemeralArrayMapping = TypeMapping> & { + kind: 'ephemeral-array'; + inner: TypeMapping; +}; +export type CompositeMapping = + | ArrayMapping + | BoundedVecMapping + | OptionMapping + | StructMapping + | FixedArrayMapping + | FixedBoundedVecMapping + | EphemeralArrayMapping; + +export function isArrayMapping(type: TypeMapping): type is ArrayMapping { + return type.kind === 'array'; +} +export function isBoundedVecMapping(type: TypeMapping): type is BoundedVecMapping { + return type.kind === 'bounded-vec'; +} +export function isOptionMapping(type: TypeMapping): type is OptionMapping { + return type.kind === 'option'; +} +export function isStructMapping(type: TypeMapping): type is StructMapping { + return type.kind === 'struct'; +} +export function isFixedArrayMapping(type: TypeMapping): type is FixedArrayMapping { + return type.kind === 'fixed-array'; +} +export function isFixedBoundedVecMapping(type: TypeMapping): type is FixedBoundedVecMapping { + return type.kind === 'fixed-bounded-vec'; +} +export function isEphemeralArrayMapping(type: TypeMapping): type is EphemeralArrayMapping { + return type.kind === 'ephemeral-array'; +} + +/** + * The comma-joined labels of a struct's fields, with nested struct labels spliced into the parent: nesting affects + * neither the wire (struct slots concatenate) nor the interface, so the label treats nested and flat declarations as + * the same type. + */ +function structFieldLabels(fields: readonly StructField[]): string { + return fields.map(f => (isStructMapping(f.type) ? structFieldLabels(f.type.fields) : f.type.label)).join(','); +} + +/** The field's value as a bigint, asserted to fit a `bits`-wide uint. */ +function uintFromField(field: Fr, bits: number): bigint { + const max = 2n ** BigInt(bits) - 1n; + const value = field.toBigInt(); + if (value > max) { + throw new Error(`u${bits} overflow: value ${value} exceeds max (${max})`); + } + return value; +} + /** Number of InputSlots a deserializable type spans, derived from its {@link TypeMapping.shape}. */ export function slotsOf(mapping: TypeMapping): number { return mapping.shape.length; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index 5d2de605b03d..5c36893a0895 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -1,12 +1,13 @@ import { MAX_PROCESSABLE_L2_GAS, MAX_TX_DA_GAS } from '@aztec/constants'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { Point } from '@aztec/foundation/curves/grumpkin'; +import { GrumpkinScalar, Point } from '@aztec/foundation/curves/grumpkin'; import type { KeyStore } from '@aztec/key-store'; import { WASMSimulator } from '@aztec/simulator/client'; import { FunctionSelector } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; +import { CompleteAddress } from '@aztec/stdlib/contract'; import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { AppTaggingSecret, AppTaggingSecretKind } from '@aztec/stdlib/logs'; @@ -80,6 +81,35 @@ describe('PrivateExecutionOracle', () => { }); }); + describe('getAppTaggingSecret', () => { + it('rejects a sender outside the execution scopes', async () => { + const inScopeSender = await AztecAddress.random(); + const outOfScopeSender = await AztecAddress.random(); + const recipient = await AztecAddress.random(); + const oracle = makeOracle({ scopes: [inScopeSender] }); + + await expect(oracle.getAppTaggingSecret(outOfScopeSender, recipient)).rejects.toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('returns a secret for a sender inside the execution scopes', async () => { + const senderCompleteAddress = await CompleteAddress.random(); + const sender = senderCompleteAddress.address; + const recipient = (await CompleteAddress.random()).address; + + const addressStore = mock(); + addressStore.getCompleteAddress.mockResolvedValue(senderCompleteAddress); + const keyStore = mock(); + keyStore.getMasterIncomingViewingSecretKey.mockResolvedValue(GrumpkinScalar.random()); + + const oracle = makeOracle({ scopes: [sender], addressStore, keyStore }); + + const result = await oracle.getAppTaggingSecret(sender, recipient); + expect(result.isSome()).toBe(true); + }); + }); + describe('resolveTaggingStrategy', () => { let sender: AztecAddress; let recipient: AztecAddress; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index f9b615e8aceb..43642589f3f9 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -30,11 +30,13 @@ import { } from '@aztec/stdlib/tx'; import type { ResolveCustomRequest } from '../../hooks/resolve_custom_request.js'; -import type { - ResolveTaggingSecretStrategy, - TaggingSecretStrategy, +import { + DEFAULT_TAGGING_SECRET_STRATEGY, + type ResolveTaggingSecretStrategy, + type TaggingSecretStrategy, } from '../../hooks/resolve_tagging_secret_strategy.js'; import { NoteService } from '../../notes/note_service.js'; +import { assertAllowedScope } from '../../storage/allowed_scopes.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { syncSenderTaggingIndexes } from '../../tagging/index.js'; import type { ExecutionNoteCache } from '../execution_note_cache.js'; @@ -237,8 +239,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP ): Promise { const hook: ResolveTaggingSecretStrategy | undefined = this.hooks?.resolveTaggingSecretStrategy; if (!hook) { - // With no hook, both delivery modes default to a non-interactive handshake - return { type: 'non-interactive-handshake' }; + return DEFAULT_TAGGING_SECRET_STRATEGY; } const contractClassId = await this.#getCurrentContractClassId(this.contractAddress); @@ -321,7 +322,17 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP * @returns The app tagging secret, or `None` if the recipient is invalid. */ public async getAppTaggingSecret(sender: AztecAddress, recipient: AztecAddress): Promise> { - const extendedSecret = await this.#calculateAppTaggingSecret(this.contractAddress, sender, recipient); + assertAllowedScope(sender, this.scopes); + + const senderCompleteAddress = await this.getCompleteAddressOrFail(sender); + const senderIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(sender); + const extendedSecret = await AppTaggingSecret.computeViaEcdh( + senderCompleteAddress, + senderIvsk, + recipient, + this.contractAddress, + recipient, + ); if (!extendedSecret) { this.logger.warn(`Computing a tagging secret for invalid recipient ${recipient} - returning no secret`, { @@ -354,12 +365,6 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP return index; } - async #calculateAppTaggingSecret(contractAddress: AztecAddress, sender: AztecAddress, recipient: AztecAddress) { - const senderCompleteAddress = await this.getCompleteAddressOrFail(sender); - const senderIvsk = await this.keyStore.getMasterIncomingViewingSecretKey(sender); - return AppTaggingSecret.computeViaEcdh(senderCompleteAddress, senderIvsk, recipient, contractAddress, recipient); - } - async #getIndexToUseForSecret(secret: AppTaggingSecret): Promise { // If we have the tagging index in the cache, we use it. If not we obtain it from the execution data provider. const lastUsedIndexInTx = this.taggingIndexCache.getLastUsedIndex(secret); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 0e0c439e9f89..3150ab06beb1 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -1,7 +1,9 @@ -import { BlockNumber } from '@aztec/foundation/branded-types'; +import { ARCHIVE_HEIGHT } from '@aztec/constants'; +import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Grumpkin } from '@aztec/foundation/crypto/grumpkin'; import { Fr } from '@aztec/foundation/curves/bn254'; import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import { MembershipWitness } from '@aztec/foundation/trees'; import type { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { StatefulTestContractArtifact } from '@aztec/noir-test-contracts.js/StatefulTest'; @@ -28,7 +30,18 @@ import { PublicKeys, deriveKeys, hashPublicKey } from '@aztec/stdlib/keys'; import { AppTaggingSecret, AppTaggingSecretKind, SiloedTag } from '@aztec/stdlib/logs'; import { Note, NoteDao } from '@aztec/stdlib/note'; import { makeL2Tips, randomContractInstanceWithAddress } from '@aztec/stdlib/testing'; -import { BlockHeader, CallContext, Capsule, GlobalVariables, TxHash } from '@aztec/stdlib/tx'; +import { + BlockHeader, + CallContext, + Capsule, + DroppedTxReceipt, + GlobalVariables, + MinedTxReceipt, + TxEffect, + TxExecutionResult, + TxHash, + TxStatus, +} from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; import type { _MockProxy } from 'jest-mock-extended/lib/Mock.js'; @@ -55,7 +68,6 @@ import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js import { EphemeralArray } from '../noir-structs/ephemeral_array.js'; import { Option } from '../noir-structs/option.js'; import type { ProvidedSecret } from '../noir-structs/provided_secret.js'; -import { ResolvedTx } from '../noir-structs/resolved_tx.js'; import { TransientArrayService } from '../transient_array_service.js'; import { UtilityExecutionOracle, type UtilityExecutionOracleArgs } from './utility_execution_oracle.js'; @@ -552,8 +564,8 @@ describe('Utility Execution test suite', () => { const contractAddressA = await AztecAddress.random(); const contractAddressB = await AztecAddress.random(); - const oracleA = makeOracle({ contractAddress: contractAddressA }); - const oracleB = makeOracle({ contractAddress: contractAddressB }); + const oracleA = makeOracle({ contractAddress: contractAddressA, scopes: [owner] }); + const oracleB = makeOracle({ contractAddress: contractAddressB, scopes: [owner] }); const ephPksArray = EphemeralArray.fromValues(service, [ephPk]); const responseA = await oracleA.getSharedSecrets(owner, ephPksArray, contractAddressA); @@ -581,15 +593,14 @@ describe('Utility Execution test suite', () => { ); }); - it('returns no secrets when the PXE does not hold the keys for the address', async () => { + it('rejects a recipient outside the allowed scopes', async () => { const ephSk = GrumpkinScalar.random(); const ephPk = await Grumpkin.mul(Grumpkin.generator, ephSk); - const foreignAddress = await AztecAddress.random(); const ephPksArray = EphemeralArray.fromValues(service, [ephPk]); - const response = await utilityExecutionOracle.getSharedSecrets(foreignAddress, ephPksArray, contractAddress); - - expect(response.readAll(service)).toEqual([]); + await expect(utilityExecutionOracle.getSharedSecrets(owner, ephPksArray, contractAddress)).rejects.toThrow( + /not in the allowed scopes/, + ); }); }); @@ -631,10 +642,8 @@ describe('Utility Execution test suite', () => { ); }); - const result = await utilityExecutionOracle.getPendingTaggedLogsV2( - owner, - EphemeralArray.fromValues(service, providedSecrets), - ); + const oracle = makeOracle({ scopes: [owner] }); + const result = await oracle.getPendingTaggedLogsV2(owner, EphemeralArray.fromValues(service, providedSecrets)); const queried = aztecNode.getPrivateLogsByTags.mock.calls.flatMap(([query]) => query.tags.map(entry => ('tag' in entry ? entry.tag.value.toString() : entry.value.toString())), @@ -645,16 +654,181 @@ describe('Utility Execution test suite', () => { expect(resultLogs).toEqual([ { log: log.logData, - context: new ResolvedTx( - log.txHash, - log.noteHashes, - log.nullifiers[0], - log.blockNumber, - log.blockHash.toFr(), - ), + context: { + txHash: log.txHash, + uniqueNoteHashesInTx: log.noteHashes, + firstNullifierInTx: log.nullifiers[0], + blockNumber: log.blockNumber, + blockHash: log.blockHash.toFr(), + }, }, ]); }); + + it('rejects a scope outside the allowed scopes', async () => { + const outOfScope = await AztecAddress.random(); + await expect( + utilityExecutionOracle.getPendingTaggedLogsV2( + outOfScope, + EphemeralArray.fromValues(service, []), + ), + ).rejects.toThrow(/not in the allowed scopes/); + }); + }); + + describe('node read cache', () => { + const makeTxEffect = (txHash: TxHash) => TxEffect.from({ ...TxEffect.empty(), txHash }); + const makeMinedReceipt = ( + txHash: TxHash, + txEffect = makeTxEffect(txHash), + blockNumber = BlockNumber(syncedBlockNumber), + ) => + new MinedTxReceipt( + txHash, + TxStatus.FINALIZED, + TxExecutionResult.SUCCESS, + 0n, + BlockHash.random(), + blockNumber, + SlotNumber(1), + 0, + EpochNumber(1), + txEffect, + ); + + it('reuses tx-effect reads within a utility execution', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const first = await oracle.getTxEffect(txHash); + const second = await oracle.getTxEffect(txHash); + + expect(first).toEqual(second); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + }); + + it('does not share cached reads across utility executions', async () => { + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const firstOracle = makeOracle({ scopes: [scope] }); + const secondOracle = makeOracle({ scopes: [scope] }); + + // Cache works as usual + await firstOracle.getTxEffect(txHash); + await firstOracle.getTxEffect(txHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + + // Same call in new oracle, goes to actual node since cache is clean + await secondOracle.getTxEffect(txHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('does not cache failed tx-effect reads', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockRejectedValueOnce(new Error('temporary failure')); + aztecNode.getTxReceipt.mockResolvedValueOnce(makeMinedReceipt(txHash)); + + await expect(oracle.getTxEffect(txHash)).rejects.toThrow('temporary failure'); + + const result = await oracle.getTxEffect(txHash); + + expect(result.isSome()).toBe(true); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('returns aligned tx-effect options for tx hash batches', async () => { + const service = new EphemeralArrayService(); + const oracle = makeOracle({ scopes: [scope] }); + const presentTxHash = TxHash.random(); + const pendingTxHash = TxHash.random(); + const futureTxHash = TxHash.random(); + + aztecNode.getTxReceipt.mockImplementation(txHash => { + if (txHash.equals(presentTxHash)) { + return Promise.resolve(makeMinedReceipt(txHash)); + } + if (txHash.equals(futureTxHash)) { + return Promise.resolve(makeMinedReceipt(txHash, makeTxEffect(txHash), BlockNumber(syncedBlockNumber + 1))); + } + return Promise.resolve(new DroppedTxReceipt(txHash)); + }); + + const result = await oracle.getTxEffects( + EphemeralArray.fromValues(service, [presentTxHash, pendingTxHash, futureTxHash, presentTxHash]), + ); + const options = result.readAll(service); + + expect(options.map(option => option.isSome())).toEqual([true, false, false, true]); + expect(options[0].value?.txHash).toEqual(presentTxHash); + expect(options[3].value?.txHash).toEqual(presentTxHash); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(3); + }); + + it('does not share batched tx-effect reads across utility executions', async () => { + const service = new EphemeralArrayService(); + const txHash = TxHash.random(); + aztecNode.getTxReceipt.mockResolvedValue(makeMinedReceipt(txHash)); + + const firstOracle = makeOracle({ scopes: [scope] }); + const secondOracle = makeOracle({ scopes: [scope] }); + + await firstOracle.getTxEffects(EphemeralArray.fromValues(service, [txHash, txHash])); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); + + await secondOracle.getTxEffects(EphemeralArray.fromValues(service, [txHash])); + expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(2); + }); + + it('reuses archive witness reads within a utility execution', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const referenceBlockHash = await anchorBlockHeader.hash(); + const blockHash = BlockHash.random(); + const witness = MembershipWitness.empty(ARCHIVE_HEIGHT); + aztecNode.getBlockHashMembershipWitness.mockResolvedValue(witness); + + const first = await oracle.getBlockHashMembershipWitness(referenceBlockHash, blockHash); + const second = await oracle.getBlockHashMembershipWitness(referenceBlockHash, blockHash); + + expect(first).toEqual(second); + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(1); + }); + + it('returns aligned archive-membership booleans for block hash batches', async () => { + const service = new EphemeralArrayService(); + const oracle = makeOracle({ scopes: [scope] }); + const referenceBlockHash = await anchorBlockHeader.hash(); + const presentBlockHash = BlockHash.random(); + const missingBlockHash = BlockHash.random(); + const witness = MembershipWitness.empty(ARCHIVE_HEIGHT); + + aztecNode.getBlockHashMembershipWitness.mockImplementation((_referenceBlockHash, blockHash) => + Promise.resolve(blockHash.equals(presentBlockHash) ? witness : undefined), + ); + + const result = await oracle.areBlockHashesInArchive( + referenceBlockHash, + EphemeralArray.fromValues(service, [presentBlockHash, missingBlockHash, presentBlockHash]), + ); + + expect(result.readAll(service)).toEqual([true, false, true]); + expect(aztecNode.getBlockHashMembershipWitness).toHaveBeenCalledTimes(2); + }); + + it('reuses public storage reads within a utility execution', async () => { + const oracle = makeOracle({ scopes: [scope] }); + const blockHash = await anchorBlockHeader.hash(); + const startStorageSlot = Fr.random(); + aztecNode.getPublicStorageAt.mockResolvedValue(new Fr(7)); + + const first = await oracle.getFromPublicStorage(blockHash, contractAddress, startStorageSlot, 2); + const second = await oracle.getFromPublicStorage(blockHash, contractAddress, startStorageSlot, 2); + + expect(first).toEqual(second); + expect(aztecNode.getPublicStorageAt).toHaveBeenCalledTimes(2); + }); }); describe('fact store', () => { diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 9055e2864ea5..d23247aefe54 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -35,6 +35,7 @@ import { type Capsule, type IndexedTxEffect, type OffchainEffect, + type TxEffect, type TxHash, } from '@aztec/stdlib/tx'; @@ -48,6 +49,7 @@ import { TxResolverService } from '../../messages/tx_resolver_service.js'; import { NoteService } from '../../notes/note_service.js'; import { ORACLE_VERSION_MAJOR } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; +import { assertAllowedScope } from '../../storage/allowed_scopes.js'; import type { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; import { FactCollectionKey, FactCollectionTypeKey, anchoredTipBlockNumbers } from '../../storage/fact_store/index.js'; import type { FactService, OriginBlock } from '../../storage/fact_store/index.js'; @@ -56,6 +58,7 @@ import type { PrivateEventStore } from '../../storage/private_event_store/privat import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; import type { AnchoredContractData } from '../anchored_contract_data.js'; +import { AztecNodeReadCache } from '../aztec_node_read_cache.js'; import { EphemeralArrayService } from '../ephemeral_array_service.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import type { EmbeddedCurvePoint } from '../noir-structs/embedded_curve_point.js'; @@ -119,6 +122,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra private offchainEffects: OffchainEffect[] = []; private readonly ephemeralArrayService = new EphemeralArrayService(); protected readonly transientArrayService: TransientArrayService; + private readonly aztecNodeReadCache: AztecNodeReadCache; // We store oracle version to be able to show a nice error message when an oracle handler is missing. private contractOracleVersion: { major: number; minor: number } | undefined; @@ -172,6 +176,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.hooks = args.hooks; this.utilityExecutor = args.utilityExecutor; this.transientArrayService = args.transientArrayService; + this.aztecNodeReadCache = new AztecNodeReadCache(args.aztecNode); } public assertCompatibleOracleVersion(major: number, minor: number): void { @@ -265,11 +270,27 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // hash at all. If the block hash did not exist by the reference block hash, then the node will not return the // membership witness as there is none. const witness = await this.#queryWithBlockHashNotAfterAnchor(referenceBlockHash, () => - this.aztecNode.getBlockHashMembershipWitness(referenceBlockHash, blockHash), + this.aztecNodeReadCache.getBlockHashMembershipWitness(referenceBlockHash, blockHash), ); return witness ? Option.some(witness) : Option.none(); } + /** Returns whether each block hash is present in the archive tree at the referenced block. */ + public async areBlockHashesInArchive( + referenceBlockHash: BlockHash, + blockHashes: EphemeralArray, + ): Promise> { + const hashes = blockHashes.readAll(this.ephemeralArrayService); + const memberships = await this.#queryWithBlockHashNotAfterAnchor(referenceBlockHash, () => + Promise.all( + hashes.map(blockHash => + this.aztecNodeReadCache.getBlockHashMembershipWitness(referenceBlockHash, blockHash).then(Boolean), + ), + ), + ); + return EphemeralArray.fromValues(this.ephemeralArrayService, memberships); + } + /** * Returns a nullifier membership witness for a given nullifier at a given block. * @param blockHash - The block hash at which to get the index. @@ -318,7 +339,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra */ public async getPublicDataWitness(blockHash: BlockHash, leafSlot: Fr): Promise { const witness = await this.#queryWithBlockHashNotAfterAnchor(blockHash, () => - this.aztecNode.getPublicDataWitness(blockHash, leafSlot), + this.aztecNodeReadCache.getPublicDataWitness(blockHash, leafSlot), ); if (!witness) { throw new Error(`Public data witness not found for slot ${leafSlot} at block hash ${blockHash.toString()}.`); @@ -509,16 +530,16 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra numberOfElements: number, ) { return this.#queryWithBlockHashNotAfterAnchor(blockHash, async () => { - const slots = Array(numberOfElements) - .fill(0) - .map((_, i) => new Fr(startStorageSlot.value + BigInt(i))); - - const values = await Promise.all( - slots.map(storageSlot => this.aztecNode.getPublicStorageAt(blockHash, contractAddress, storageSlot)), + const values = await this.aztecNodeReadCache.getPublicStorageRange( + blockHash, + contractAddress, + startStorageSlot, + numberOfElements, ); this.logger.debug( - `Oracle storage read: slots=[${slots.map(slot => slot.toString()).join(', ')}] address=${contractAddress.toString()} values=[${values.join(', ')}]`, + `Oracle storage read: start=${startStorageSlot.toString()} count=${numberOfElements} ` + + `address=${contractAddress.toString()} values=[${values.join(', ')}]`, ); return values; @@ -593,6 +614,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.recipientTaggingStore, this.taggingSecretSourcesStore, this.addressStore, + this.scopes, this.jobId, this.logger.getBindings(), ); @@ -664,21 +686,25 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra throw new Error('Invalid tx hash passed into aztec_utl_getTxEffect oracle handler'); } - const receipt = await this.aztecNode.getTxReceipt(txHash, { includeTxEffect: true }); - if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { - return Option.none(); + return await this.#getTxEffectOption(txHash); + } + + /** Fetches transaction effects for all hashes, preserving request order. */ + public async getTxEffects(txHashes: EphemeralArray): Promise>> { + const hashes = txHashes.readAll(this.ephemeralArrayService); + const invalidHash = hashes.find(txHash => txHash.hash.isZero()); + if (invalidHash) { + throw new Error('Invalid tx hash passed into aztec_utl_getTxEffects oracle handler'); } - const txEffect = receipt.txEffect; - return Option.some({ - ...txEffect, - publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), - contractClassLogs: txEffect.contractClassLogs.map(log => ({ - contractAddress: log.contractAddress, - fields: log.fields.toFields(), - emittedLength: log.emittedLength, - })), - }); + const uniqueTxHashes = uniqueBy(hashes, h => h.toString()); + const options = await Promise.all(uniqueTxHashes.map(txHash => this.#getTxEffectOption(txHash))); + const optionsByHash = new Map(uniqueTxHashes.map((txHash, i) => [txHash.toString(), options[i]])); + + return EphemeralArray.fromValues( + this.ephemeralArrayService, + hashes.map(txHash => optionsByHash.get(txHash.toString())!), + ); } public setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], scope: AztecAddress): void { @@ -833,13 +859,17 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // TODO(#11849): consider replacing this oracle with a pure Noir implementation of aes decryption. public async decryptAes128( ciphertext: BoundedVec, - iv: Buffer, - symKey: Buffer, + iv: number[], + symKey: number[], ): Promise>> { const capacity = ciphertext.maxLength; try { const aes128 = new Aes128(); - const plaintext = await aes128.decryptBufferCBC(Buffer.from(ciphertext.data), iv, symKey); + const plaintext = await aes128.decryptBufferCBC( + Buffer.from(ciphertext.data), + Buffer.from(iv), + Buffer.from(symKey), + ); return Option.some(BoundedVec.from({ data: [...plaintext], maxLength: capacity })); } catch { return Option.none({ maxLength: capacity }); @@ -852,7 +882,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * @param ephPks - Ephemeral array containing the serialized Points. * @param contractAddress - The contract address for app-siloing (validated against execution context). * @returns A new ephemeral array containing the computed shared secrets, or an empty array when the PXE does not - * hold the keys for `address`, signaling that no secrets can be derived for it. + * hold the keys for `address`. + * @throws If `address` is not in the execution's allowed scopes. */ public async getSharedSecrets( address: AztecAddress, @@ -864,6 +895,11 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra `getSharedSecrets called with contract address ${contractAddress}, expected ${this.contractAddress}`, ); } + + assertAllowedScope(address, this.scopes); + + // An address can be in scope without the PXE holding its keys (e.g. syncing a registered but non-owned account), + // in which case no secrets can be derived and we return an empty array rather than failing. const recipientCompleteAddress = await this.addressStore.getCompleteAddress(address); if (!recipientCompleteAddress) { this.logger.warn( @@ -1077,9 +1113,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra */ async #fetchTxEffects(txHashes: TxHash[]): Promise> { const uniqueTxHashes = uniqueBy(txHashes, h => h.toString()); - const fetched = await Promise.all( - uniqueTxHashes.map(h => this.aztecNode.getTxReceipt(h, { includeTxEffect: true })), - ); + const fetched = await Promise.all(uniqueTxHashes.map(h => this.aztecNodeReadCache.getTxReceiptWithEffect(h))); return new Map( uniqueTxHashes .map((h, i): [string, IndexedTxEffect | undefined] => { @@ -1102,6 +1136,27 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ); } + async #getTxEffectOption(txHash: TxHash): Promise> { + const receipt = await this.aztecNodeReadCache.getTxReceiptWithEffect(txHash); + if (!receipt.isMined() || !receipt.txEffect || receipt.blockNumber > this.anchorBlockHeader.getBlockNumber()) { + return Option.none(); + } + return Option.some(this.#toTxEffectData(receipt.txEffect)); + } + + #toTxEffectData(txEffect: TxEffect): TxEffectData { + return { + ...txEffect, + revertCode: txEffect.revertCode.getCode(), + publicLogs: FlatPublicLogs.fromLogs(txEffect.publicLogs), + contractClassLogs: txEffect.contractClassLogs.map(log => ({ + contractAddress: log.contractAddress, + fields: log.fields.toFields(), + emittedLength: log.emittedLength, + })), + }; + } + /** Runs a query concurrently with a validation that the block hash is not ahead of the anchor block. */ async #queryWithBlockHashNotAfterAnchor(blockHash: BlockHash, query: () => Promise): Promise { // Most contracts query state at the "current" block, which is the anchor. Skip the validation when we can. @@ -1113,7 +1168,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const [response] = await Promise.all([ query(), (async () => { - const block = await this.aztecNode.getBlock(blockHash); + const block = await this.aztecNodeReadCache.getBlock(blockHash); const header = block?.header; if (!header) { throw new Error(`Could not find block header for block hash ${blockHash}`); diff --git a/yarn-project/pxe/src/hooks/index.ts b/yarn-project/pxe/src/hooks/index.ts index 360cae3e904f..029b85c5e000 100644 --- a/yarn-project/pxe/src/hooks/index.ts +++ b/yarn-project/pxe/src/hooks/index.ts @@ -6,6 +6,7 @@ export type { export { type ExecutionHooks, composeHooks } from './execution_hooks.js'; export { type CustomRequest, type ResolveCustomRequest } from './resolve_custom_request.js'; export { + DEFAULT_TAGGING_SECRET_STRATEGY, type ResolveTaggingSecretStrategy, type TaggingSecretStrategy, type TaggingSecretStrategyRequest, diff --git a/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts b/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts index 6ca37cc05e68..fdfd3e8e0f25 100644 --- a/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts +++ b/yarn-project/pxe/src/hooks/resolve_tagging_secret_strategy.ts @@ -39,6 +39,9 @@ export type TaggingSecretStrategy = secret: Point; }; +/** The strategy PXE applies to both delivery modes when no `resolveTaggingSecretStrategy` hook is configured. */ +export const DEFAULT_TAGGING_SECRET_STRATEGY: TaggingSecretStrategy = { type: 'non-interactive-handshake' }; + /** Information about the message delivery requesting a tagging secret strategy. */ export type TaggingSecretStrategyRequest = { contractAddress: AztecAddress; diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 39b5ff298001..b3dc03c6005b 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -321,13 +321,17 @@ describe('LogService', () => { contractAddress = await AztecAddress.random(); const l2TipsProvider = mock(); - ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = - await createTestLogService(l2TipsProvider)); + const scopes: AztecAddress[] = []; + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = await createTestLogService( + l2TipsProvider, + scopes, + )); l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(0)); const completeAddress = await keyStore.addAccount(await deriveKeys(Fr.random()), Fr.random()); await addressStore.addCompleteAddress(completeAddress); recipient = completeAddress.address; + scopes.push(recipient); sharedSecret = await Point.random(); }); @@ -396,6 +400,13 @@ describe('LogService', () => { expect(txHashes).not.toContainEqual(directionalLog.txHash); }); + it('rejects a recipient outside the allowed scopes', async () => { + const outOfScope = await AztecAddress.random(); + await expect(logService.fetchTaggedLogs(contractAddress, outOfScope, [])).rejects.toThrow( + /not in the allowed scopes/, + ); + }); + function handshakeTags(secret: Point, app: AztecAddress): Promise { return Promise.all( [AppTaggingSecretKind.UNCONSTRAINED, AppTaggingSecretKind.CONSTRAINED].map(async kind => @@ -421,7 +432,8 @@ describe('LogService', () => { contractAddress = await AztecAddress.random(); const l2TipsProvider = mock(); - const testContext = await createTestLogService(l2TipsProvider); + const scopes: AztecAddress[] = []; + const testContext = await createTestLogService(l2TipsProvider, scopes); ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = testContext); l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(testContext.anchorBlockHeader.globalVariables.blockNumber)); @@ -429,6 +441,7 @@ describe('LogService', () => { recipientCompleteAddress = await keyStore.addAccount(await deriveKeys(new Fr(1)), Fr.random()); recipient = recipientCompleteAddress.address; await addressStore.addCompleteAddress(recipientCompleteAddress); + scopes.push(recipient); sender = await AztecAddress.random(); @@ -469,7 +482,10 @@ describe('LogService', () => { }); }); -async function createTestLogService(l2TipsProvider: MockProxy = mock()) { +async function createTestLogService( + l2TipsProvider: MockProxy = mock(), + scopes: AztecAddress[] = [], +) { const keyStore = new KeyStore(await openTmpStore('test')); const recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); const taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); @@ -486,6 +502,7 @@ async function createTestLogService(l2TipsProvider: MockProxy = recipientTaggingStore, taggingSecretSourcesStore, addressStore, + scopes, 'test', ); diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index 18dd7d2d2a55..797458555059 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -22,8 +22,8 @@ import { } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; import type { LogRetrievalResponse } from '../contract_function_simulator/noir-structs/log_retrieval_response.js'; import type { PendingTaggedLog } from '../contract_function_simulator/noir-structs/pending_tagged_log.js'; -import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; import { AddressStore } from '../storage/address_store/address_store.js'; +import { assertAllowedScope } from '../storage/allowed_scopes.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../storage/tagging_store/tagging_secret_sources_store.js'; import { @@ -47,6 +47,7 @@ export class LogService { private readonly recipientTaggingStore: RecipientTaggingStore, private readonly taggingSecretSourcesStore: TaggingSecretSourcesStore, private readonly addressStore: AddressStore, + private readonly scopes: AztecAddress[], private readonly jobId: string, bindings?: LoggerBindings, ) { @@ -189,6 +190,8 @@ export class LogService { recipient: AztecAddress, providedSecrets: AppTaggingSecret[], ): Promise { + assertAllowedScope(recipient, this.scopes); + this.log.verbose( `Fetching tagged logs for contract ${contractAddress.toString()} and recipient ${recipient.toString()}`, ); @@ -221,7 +224,13 @@ export class LogService { } return { log: log.logData, - context: new ResolvedTx(log.txHash, noteHashes, nullifiers[0], log.blockNumber, log.blockHash.toFr()), + context: { + txHash: log.txHash, + uniqueNoteHashesInTx: noteHashes, + firstNullifierInTx: nullifiers[0], + blockNumber: log.blockNumber, + blockHash: log.blockHash.toFr(), + }, }; }); } diff --git a/yarn-project/pxe/src/messages/tx_resolver_service.test.ts b/yarn-project/pxe/src/messages/tx_resolver_service.test.ts index 26d7fdbf9020..f703fc286a56 100644 --- a/yarn-project/pxe/src/messages/tx_resolver_service.test.ts +++ b/yarn-project/pxe/src/messages/tx_resolver_service.test.ts @@ -6,7 +6,6 @@ import { DroppedTxReceipt, MinedTxReceipt, TxEffect, TxExecutionResult, TxHash, import { mock } from 'jest-mock-extended'; -import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; import { TxResolverService } from './tx_resolver_service.js'; describe('TxResolverService', () => { @@ -118,7 +117,15 @@ describe('TxResolverService', () => { const results = await service.resolveTxs([txHash.hash], anchorBlockNumber); - expect(results).toEqual([new ResolvedTx(txHash, noteHashes, firstNullifier, blockNumber, blockHash.toFr())]); + expect(results).toEqual([ + { + txHash, + uniqueNoteHashesInTx: noteHashes, + firstNullifierInTx: firstNullifier, + blockNumber, + blockHash: blockHash.toFr(), + }, + ]); }); it('resolves tx hashes in different situations', async () => { @@ -166,7 +173,13 @@ describe('TxResolverService', () => { expect(results).toEqual([ null, - new ResolvedTx(validTxHash, validNoteHashes, validNullifier, anchorBlockNumber, validBlockHash.toFr()), + { + txHash: validTxHash, + uniqueNoteHashesInTx: validNoteHashes, + firstNullifierInTx: validNullifier, + blockNumber: anchorBlockNumber, + blockHash: validBlockHash.toFr(), + }, null, null, ]); @@ -202,13 +215,13 @@ describe('TxResolverService', () => { anchorBlockNumber, ); - const expected = new ResolvedTx( - txEffect.txHash, - txEffect.noteHashes, - txEffect.nullifiers[0], + const expected = { + txHash: txEffect.txHash, + uniqueNoteHashesInTx: txEffect.noteHashes, + firstNullifierInTx: txEffect.nullifiers[0], blockNumber, - blockHash.toFr(), - ); + blockHash: blockHash.toFr(), + }; expect(results).toEqual([expected, expected, expected]); expect(aztecNode.getTxReceipt).toHaveBeenCalledTimes(1); }); diff --git a/yarn-project/pxe/src/messages/tx_resolver_service.ts b/yarn-project/pxe/src/messages/tx_resolver_service.ts index bb733ddff4ff..da5033b27c14 100644 --- a/yarn-project/pxe/src/messages/tx_resolver_service.ts +++ b/yarn-project/pxe/src/messages/tx_resolver_service.ts @@ -3,7 +3,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { type IndexedTxEffect, TxHash } from '@aztec/stdlib/tx'; -import { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; +import type { ResolvedTx } from '../contract_function_simulator/noir-structs/resolved_tx.js'; /** Resolves transaction hashes into their on-chain context (note hashes, first nullifier, and mined block). */ export class TxResolverService { @@ -57,13 +57,13 @@ export class TxResolverService { throw new Error(`Tx effect for ${txHash} has no nullifiers`); } - return new ResolvedTx( - data.txHash, - data.noteHashes, - data.nullifiers[0], - txEffect.l2BlockNumber, - txEffect.l2BlockHash.toFr(), - ); + return { + txHash: data.txHash, + uniqueNoteHashesInTx: data.noteHashes, + firstNullifierInTx: data.nullifiers[0], + blockNumber: txEffect.l2BlockNumber, + blockHash: txEffect.l2BlockHash.toFr(), + }; }); } } diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index cbe1ff82624b..b84ef27739f2 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -26,7 +26,7 @@ export class NoteService { * * @param owner - The owner of the notes. If undefined, returns notes for all known owners. * @param status - The status of notes to fetch. - * @param scopes - The accounts whose notes we can access in this call. Currently optional and will default to all. + * @param scopes - The accounts whose notes we can access in this call. An empty list matches no notes. */ public async getNotes( contractAddress: AztecAddress, diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 2072d50d221d..b40b9932e3c2 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -11,12 +11,12 @@ /// if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency without actually /// using any of the new oracles then there is no reason to throw. export const ORACLE_VERSION_MAJOR = 30; -export const ORACLE_VERSION_MINOR = 6; +export const ORACLE_VERSION_MINOR = 8; -/// This hash is computed from the `ORACLE_REGISTRY` declaration (each oracle's name, ordered parameter names and +/// This hash is computed from `ORACLE_REGISTRY` (each oracle's name, ordered parameter names and /// types, and return type) and is used to detect when the oracle interface changes. When it does, you need to either: /// - increment `ORACLE_VERSION_MAJOR` and reset `ORACLE_VERSION_MINOR` to zero if the change is breaking, or /// - increment only `ORACLE_VERSION_MINOR` if the change is additive (a new oracle was added). /// /// These constants must be kept in sync between this file and `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = '6094994f539407001d2adce5b6a8792c08ef645ee39813e32390f6208e78bc16'; +export const ORACLE_INTERFACE_HASH = 'fec4d3a95fc57a62a20bfeb659bb3bfd91b31cd93e07f8cf97b368f9f55cc67b'; diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json index 86234edfe1fc..8b9271d749e3 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/RecipientTaggingStore.json @@ -1,17 +1,25 @@ { "highest_aged_index": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "num:13" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "num:13" } ], "highest_finalized_index": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "num:11" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "num:11" }, { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", "value": "num:17" } ] diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json index 487674b3cde4..dcf8654b3ecf 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/SenderTaggingStore.json @@ -1,21 +1,29 @@ { "pending_indexes": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "[{\"lowestIndex\":4,\"highestIndex\":7,\"txHash\":\"0x0000000000000000000000000000000000000000000000000000000000000025\"}]" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "[{\"lowestIndex\":4,\"highestIndex\":7,\"txHash\":\"0x0000000000000000000000000000000000000000000000000000000000000013\"},{\"lowestIndex\":8,\"highestIndex\":11,\"txHash\":\"0x0000000000000000000000000000000000000000000000000000000000000017\"}]" }, { - "key": "utf8:0x000000000000000000000000000000000000000000000000000000000000000b:0x000000000000000000000000000000000000000000000000000000000000000d", + "key": "utf8:unconstrained:0x000000000000000000000000000000000000000000000000000000000000000b:0x000000000000000000000000000000000000000000000000000000000000000d", "value": "[{\"lowestIndex\":1,\"highestIndex\":9,\"txHash\":\"0x000000000000000000000000000000000000000000000000000000000000001d\"}]" } ], "last_finalized_indexes": [ { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", + "key": "utf8:constrained:0x0000000000000000000000000000000000000000000000000000000000000013:0x0000000000000000000000000000000000000000000000000000000000000017", + "value": "num:3" + }, + { + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000002:0x0000000000000000000000000000000000000000000000000000000000000003", "value": "num:3" }, { - "key": "utf8:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", + "key": "utf8:unconstrained:0x0000000000000000000000000000000000000000000000000000000000000005:0x0000000000000000000000000000000000000000000000000000000000000007", "value": "num:5" } ] diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json index 5afea8e276cf..fe8a7f6856ea 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/__snapshots__/opened_stores.json @@ -1,5 +1,5 @@ { - "schemaVersion": 12, + "schemaVersion": 13, "stores": [ { "name": "capsules", diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts index d7713d47a622..c92a6c4eb87a 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/pxe_db_compatibility.test.ts @@ -1,3 +1,4 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { KeyStore } from '@aztec/key-store'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; @@ -25,6 +26,9 @@ expect.extend({ toMatchFile }); const __dirname = dirname(fileURLToPath(import.meta.url)); // The last schema in which the key store still persisted the message-signing and fallback secret keys. const PRE_MESSAGE_AND_FALLBACK_SECRET_KEY_REMOVAL_PXE_SCHEMA_VERSION = 10; +// The last schema in which the tagging stores keyed unconstrained entries by the legacy two-part AppTaggingSecret +// format (`:`), before every key moved to the self-describing `::` form. +const PRE_KIND_PREFIXED_TAGGING_KEY_PXE_SCHEMA_VERSION = 12; /** * Asserts that `value` matches the per-store snapshot file `__snapshots__/.json`. Each store gets its own file @@ -222,6 +226,24 @@ describe('PXE storage compatibility test suite', () => { ); }); + it('never reads tagging-store rows written under the legacy two-part AppTaggingSecret key format, and leaves them intact', async () => { + // The current toString() only emits the three-part `::` form, so build the legacy two-part + // `:` key by hand. + const legacyKey = `${new Fr(2n).toString()}:${AztecAddress.fromBigIntUnsafe(3n).toString()}`; + await expectFreshStoreSelectedOnUpgradeFrom( + PRE_KIND_PREFIXED_TAGGING_KEY_PXE_SCHEMA_VERSION, + oldStore => oldStore.openMap('highest_aged_index').set(legacyKey, 13), + async currentStore => { + // Assert on the raw map, not a high-level getter: the new three-part toString() never reconstructs the + // legacy two-part key, so a getter would report it absent (false-pass) regardless of which store was opened. + await expect(currentStore.openMap('highest_aged_index').sizeAsync()).resolves.toBe(0); + }, + async oldStore => { + expect(await oldStore.openMap('highest_aged_index').getAsync(legacyKey)).toBe(13); + }, + ); + }); + it('opens the expected set of stores', async () => { const openedStores = await collectOpenedStores(); expectMatchesSnapshot({ schemaVersion: PXE_DATA_SCHEMA_VERSION, stores: openedStores }, 'opened_stores'); diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index 196f97b4cd67..a8561507d41d 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -18,6 +18,7 @@ import { GasFees } from '@aztec/stdlib/gas'; import { PublicKey, PublicKeys, deriveKeys } from '@aztec/stdlib/keys'; import { AppTaggingSecret, + AppTaggingSecretKind, ContractClassLog, ContractClassLogFields, PrivateLog, @@ -530,10 +531,18 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ const jobId = 'fixture-job'; const secretA = new AppTaggingSecret(new Fr(2n), AztecAddress.fromBigIntUnsafe(3n)); const secretB = new AppTaggingSecret(new Fr(5n), AztecAddress.fromBigIntUnsafe(7n)); + // A constrained secret keys under the `constrained:` prefix, so the snapshot pins both kinds side by side. + const secretConstrained = new AppTaggingSecret( + new Fr(19n), + AztecAddress.fromBigIntUnsafe(23n), + AppTaggingSecretKind.CONSTRAINED, + ); await recipientTaggingStore.updateHighestFinalizedIndex(secretA, 11, jobId); await recipientTaggingStore.updateHighestAgedIndex(secretA, 13, jobId); await recipientTaggingStore.updateHighestFinalizedIndex(secretB, 17, jobId); + await recipientTaggingStore.updateHighestFinalizedIndex(secretConstrained, 11, jobId); + await recipientTaggingStore.updateHighestAgedIndex(secretConstrained, 13, jobId); await kvStore.transactionAsync(() => recipientTaggingStore.commit(jobId)); }, snapshotStore: async kvStore => ({ @@ -584,10 +593,17 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ const secretA = new AppTaggingSecret(new Fr(2n), AztecAddress.fromBigIntUnsafe(3n)); const secretB = new AppTaggingSecret(new Fr(5n), AztecAddress.fromBigIntUnsafe(7n)); const secretC = new AppTaggingSecret(new Fr(11n), AztecAddress.fromBigIntUnsafe(13n)); + const secretConstrained = new AppTaggingSecret( + new Fr(19n), + AztecAddress.fromBigIntUnsafe(23n), + AppTaggingSecretKind.CONSTRAINED, + ); const txHashA = TxHash.fromBigInt(17n); const txHashB = TxHash.fromBigInt(19n); const txHashC = TxHash.fromBigInt(23n); const txHashD = TxHash.fromBigInt(29n); + const txHashE = TxHash.fromBigInt(31n); + const txHashF = TxHash.fromBigInt(37n); // secretA receives three pending ranges (one per tx); secretB receives one. After finalizing txHashA below, // secretA's array shrinks to two elements (the txHashB and txHashC ranges, both with highestIndex > 3) which @@ -626,7 +642,20 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ jobId, ); - await senderTaggingStore.finalizePendingIndexes([txHashA], jobId); + // secretConstrained gets a finalized range (txHashE) plus a surviving higher pending range (txHashF), so the + // `constrained:` key lands in both pending_indexes and last_finalized_indexes. + await senderTaggingStore.storePendingIndexes( + [{ extendedSecret: secretConstrained, lowestIndex: 1, highestIndex: 3 }], + txHashE, + jobId, + ); + await senderTaggingStore.storePendingIndexes( + [{ extendedSecret: secretConstrained, lowestIndex: 4, highestIndex: 7 }], + txHashF, + jobId, + ); + + await senderTaggingStore.finalizePendingIndexes([txHashA, txHashE], jobId); await kvStore.transactionAsync(() => senderTaggingStore.commit(jobId)); }, diff --git a/yarn-project/pxe/src/storage/fact_store/index.ts b/yarn-project/pxe/src/storage/fact_store/index.ts index f4cb3b9269fa..aa65ed5e4ca2 100644 --- a/yarn-project/pxe/src/storage/fact_store/index.ts +++ b/yarn-project/pxe/src/storage/fact_store/index.ts @@ -5,6 +5,7 @@ export type { Fact } from './stored_fact.js'; export { OriginBlockState, classifyOriginBlockState, + originBlockStateFromNumber, anchoredTipBlockNumbers, toFactWithOriginState, type TipBlockNumbers, diff --git a/yarn-project/pxe/src/storage/fact_store/origin_state.ts b/yarn-project/pxe/src/storage/fact_store/origin_state.ts index 38eca7574c47..d498e8f184d5 100644 --- a/yarn-project/pxe/src/storage/fact_store/origin_state.ts +++ b/yarn-project/pxe/src/storage/fact_store/origin_state.ts @@ -21,6 +21,15 @@ export enum OriginBlockState { Finalized = 3, } +/** Parses a numeric origin-block-state discriminant, rejecting unknown values. */ +export function originBlockStateFromNumber(value: number): OriginBlockState { + if (!(value in OriginBlockState)) { + const validNames = Object.keys(OriginBlockState).filter(k => isNaN(Number(k))); + throw new Error(`Invalid OriginBlockState value ${value}, expected one of ${validNames.join(', ')}`); + } + return value as OriginBlockState; +} + /** The two chain-tip block numbers needed to classify an origin block (`finalized <= proven` always holds). */ export type TipBlockNumbers = { provenBlockNumber: number; finalizedBlockNumber: number }; diff --git a/yarn-project/pxe/src/storage/metadata.ts b/yarn-project/pxe/src/storage/metadata.ts index ca02f2aa5901..71cc183a6299 100644 --- a/yarn-project/pxe/src/storage/metadata.ts +++ b/yarn-project/pxe/src/storage/metadata.ts @@ -1 +1 @@ -export const PXE_DATA_SCHEMA_VERSION = 12; +export const PXE_DATA_SCHEMA_VERSION = 13; diff --git a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts index 59a456dc1c32..a84403c49995 100644 --- a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts +++ b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts @@ -31,14 +31,17 @@ describe('SenderTaggingStore', () => { }); describe('storePendingIndexes', () => { - it('stores a single pending index range', async () => { + it.each([ + ['', 'storePendingIndexes'], + [' when merging', 'mergePendingIndexes'], + ] as const)('stores a single pending index range for an untracked tx%s', async (_name, method) => { const txHash = TxHash.random(); - await taggingStore.storePendingIndexes([range(secret1, 5)], txHash, 'test'); + await taggingStore[method]([range(secret1, 5)], txHash, 'test'); const txHashes = await taggingStore.getTxHashesOfPendingIndexes(secret1, 0, 10, 'test'); - expect(txHashes).toHaveLength(1); - expect(txHashes[0]).toEqual(txHash); + expect(txHashes).toEqual([txHash]); + expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(5); }); it('stores multiple pending index ranges for different secrets', async () => { @@ -107,6 +110,32 @@ describe('SenderTaggingStore', () => { ); }); + it('keeps the existing range when merging in a sub-range for the same tx', async () => { + const txHash = TxHash.random(); + + // Prove-time entry spanning setup and app-logic phase logs. + await taggingStore.storePendingIndexes([range(secret1, 4, 6)], txHash, 'test'); + + // Discovery of the surviving sub-range of a partially reverted tx must not throw nor shrink the entry. + await taggingStore.mergePendingIndexes([range(secret1, 4)], txHash, 'test'); + + expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(6); + }); + + it('widens the existing range to the union when merging in a range beyond it', async () => { + const txHash = TxHash.random(); + + // A prior window discovered only part of the tx's range. + await taggingStore.storePendingIndexes([range(secret1, 4, 6)], txHash, 'test'); + + // Discovery evidences further onchain indexes for the same tx — the entry must grow to cover them. + await taggingStore.mergePendingIndexes([range(secret1, 7, 8)], txHash, 'test'); + + const txHashes = await taggingStore.getTxHashesOfPendingIndexes(secret1, 0, 10, 'test'); + expect(txHashes).toEqual([txHash]); + expect(await taggingStore.getLastUsedIndex(secret1, 'test')).toBe(8); + }); + it('throws when storing a pending index range lower than the last finalized index', async () => { const txHash1 = TxHash.random(); const txHash2 = TxHash.random(); diff --git a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts index f288387d38b2..c0446fef8b74 100644 --- a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts +++ b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts @@ -128,10 +128,11 @@ export class SenderTaggingStore implements StagedStore { } /** - * Stores pending index ranges. + * Stores pending index ranges, rejecting any range that disagrees with an already-stored one. * @remarks If the same (secret, txHash) pair already exists in the db with an equal range, it's a no-op. This is * expected to happen because whenever we start sync we start from the last finalized index and we can have pending - * ranges already stored from previous syncs. If the ranges differ, it throws an error as that indicates a bug. + * ranges already stored from previous syncs. If the ranges differ, it throws an error as that indicates a bug in + * callers that record indexes at prove time. Discovery from onchain logs must use `mergePendingIndexes` instead. * @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are * to be stored in the db. * @param txHash - The tx in which the tagging indexes were used in private logs. @@ -141,6 +142,33 @@ export class SenderTaggingStore implements StagedStore { * @throws If a different range already exists for the same (secret, txHash) pair. */ storePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise { + return this.#storePendingIndexes(ranges, txHash, jobId, false); + } + + /** + * Stores pending index ranges, widening an existing entry for the same (secret, txHash) pair to the union of the + * stored and incoming ranges instead of throwing on a mismatch. Discovery from onchain logs needs this: it may see + * only the surviving (non-revertible phase) sub-range of a partially reverted tx recorded at prove time (the + * finalized receipt step of the sync resolves that difference), or indexes beyond a partially discovered entry + * when a tx from another PXE straddles a sync window boundary. Callers that record indexes at prove time must use + * `storePendingIndexes` instead, so that a range disagreement surfaces as a bug rather than being absorbed. + * @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are + * to be stored in the db. + * @param txHash - The tx in which the tagging indexes were used in private logs. + * @param jobId - job context for staged writes to this store. See `JobCoordinator` for more details. + * @throws If the highestIndex is further than window length from the highest finalized index for the same secret. + * @throws If the lowestIndex is lower than or equal to the last finalized index for the same secret. + */ + mergePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise { + return this.#storePendingIndexes(ranges, txHash, jobId, true); + } + + #storePendingIndexes( + ranges: TaggingIndexRange[], + txHash: TxHash, + jobId: string, + mergeExisting: boolean, + ): Promise { if (ranges.length === 0) { return Promise.resolve(); } @@ -187,21 +215,40 @@ export class SenderTaggingStore implements StagedStore { // Check if an entry with the same txHash already exists const existingEntry = pendingData.find(entry => entry.txHash === txHashStr); - if (existingEntry) { - // Assert that the ranges are equal — different ranges for the same (secret, txHash) indicates a bug - if (existingEntry.lowestIndex !== range.lowestIndex || existingEntry.highestIndex !== range.highestIndex) { - throw new Error( - `Conflicting range for secret ${secretStr} and txHash ${txHashStr}: ` + - `existing [${existingEntry.lowestIndex}, ${existingEntry.highestIndex}] vs ` + - `new [${range.lowestIndex}, ${range.highestIndex}]`, - ); - } - // Exact duplicate — skip - } else { - this.#writePendingIndexes(jobId, secretStr, [ + let updatedPending: PendingIndexesEntry[] | undefined; + if (!existingEntry) { + updatedPending = [ ...pendingData, { lowestIndex: range.lowestIndex, highestIndex: range.highestIndex, txHash: txHashStr }, - ]); + ]; + } else if (mergeExisting) { + // Widen the entry to the union of both ranges, never shrink it: replacing would drop prove-time + // indexes the chain doesn't show (partially reverted tx), and skipping would drop onchain indexes + // discovered in a later sync window (tx straddling the window boundary). + const lowestIndex = Math.min(existingEntry.lowestIndex, range.lowestIndex); + const highestIndex = Math.max(existingEntry.highestIndex, range.highestIndex); + if (lowestIndex !== existingEntry.lowestIndex || highestIndex !== existingEntry.highestIndex) { + updatedPending = pendingData.map(entry => + entry === existingEntry ? { lowestIndex, highestIndex, txHash: entry.txHash } : entry, + ); + } + } else if ( + existingEntry.lowestIndex !== range.lowestIndex || + existingEntry.highestIndex !== range.highestIndex + ) { + // Different ranges for the same (secret, txHash) indicate a bug in callers that record indexes at prove + // time. + throw new Error( + `Conflicting range for secret ${secretStr} and txHash ${txHashStr}: ` + + `existing [${existingEntry.lowestIndex}, ${existingEntry.highestIndex}] vs ` + + `new [${range.lowestIndex}, ${range.highestIndex}]`, + ); + } + // Remaining cases (a merge whose union equals the stored range, or an identical range without + // mergeExisting): duplicate evidence, nothing to write. + + if (updatedPending) { + this.#writePendingIndexes(jobId, secretStr, updatedPending); } } }); diff --git a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts index 534c47c7d450..81cf4c8867cb 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.test.ts @@ -394,26 +394,34 @@ describe('syncSenderTaggingIndexes', () => { expect(aztecNode.getTxReceipt).toHaveBeenCalledWith(pendingTxHash); }); - it('handles a partially reverted transaction', async () => { + /** + * Same-PXE partial revert: the pending range was recorded at prove time and spans both the non-revertible (setup) + * and revertible (app logic) phases. After the tx mines with reverted app logic, only the setup-phase logs are + * onchain, so discovery re-derives a narrower range for the same (secret, txHash). That narrower range must not + * conflict with the prove-time entry — the finalized receipt step of the sync owns resolving the difference. + */ + it('handles a partially reverted tx whose pending range was recorded at prove time', async () => { await setUp(); const revertedTxHash = TxHash.random(); - // Create logs at indexes 4 and 6 for the same (reverted) tx + // Prove-time persist: logs at indexes 4 (setup phase) through 6 (app logic phase) under the same secret. + await taggingStore.storePendingIndexes( + [{ extendedSecret: secret, lowestIndex: 4, highestIndex: 6 }], + revertedTxHash, + 'test', + ); + + // Only the setup-phase log survived the revert, so the node only knows the tag at index 4. const tag4 = await computeSiloedTagForIndex(4); + // The app-logic tags at indexes 5 and 6 were squashed by the revert and never reached the chain. + const tag5 = await computeSiloedTagForIndex(5); const tag6 = await computeSiloedTagForIndex(6); aztecNode.getPrivateLogsByTags.mockImplementation(query => { const tags = query.tags as SiloedTag[]; return Promise.resolve( - tags.map((tag: SiloedTag) => { - if (tag.equals(tag4)) { - return [makeLog(revertedTxHash, tag4.value)]; - } else if (tag.equals(tag6)) { - return [makeLog(revertedTxHash, tag6.value)]; - } - return []; - }), + tags.map((tag: SiloedTag) => (tag.equals(tag4) ? [makeLog(revertedTxHash, tag4.value)] : [])), ); }); @@ -432,17 +440,157 @@ describe('syncSenderTaggingIndexes', () => { [], // contractClassLogs ); - // Mock getTxReceipt to return a FINALIZED + REVERTED mined receipt carrying the tx effect. The same receipt - // satisfies both the status-classification call and the includeTxEffect follow-up call. aztecNode.getTxReceipt.mockResolvedValue( mined(revertedTxHash, TxStatus.FINALIZED, 14, TxExecutionResult.REVERTED, txEffect), ); await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - // Index 4 should be finalized (it survived the partial revert) + // The surviving index is finalized and the squashed indexes 5-6 are freed for reuse. expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(4); - // No pending indexes should remain for this secret expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(4); + // Reconciliation must remove the pending entry entirely — a stale entry would keep resurfacing in later syncs. + const pendingAfterSync = await taggingStore.getTxHashesOfPendingIndexes( + secret, + 0, + UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, + 'test', + ); + expect(pendingAfterSync).toEqual([]); + + // Premise guard: discovery blindly probes every index in the window, so the first sync must have queried the full + // prove-time range [4, 6], with only the setup-phase tag getting an onchain answer. If the sync ever stopped + // probing these indexes, the discovery merge this test exists to exercise would silently stop happening. + const queriedTags = aztecNode.getPrivateLogsByTags.mock.calls.flatMap(([query]) => query.tags as SiloedTag[]); + expect(queriedTags.some(tag => tag.equals(tag4))).toBe(true); + expect(queriedTags.some(tag => tag.equals(tag5))).toBe(true); + expect(queriedTags.some(tag => tag.equals(tag6))).toBe(true); + + // A repeat sync must be a clean no-op: the behavior being pinned here is that the secret will not throw on every + // subsequent sync. + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(4); + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(4); + }); + + /** + * Cross-device straddle: another PXE sharing this directional secret sent a tx, and an earlier window discovered + * only part of its index range, so the store already tracks a narrower entry for the same (secret, txHash). + * Discovery must widen the entry to cover every index evidenced onchain, so that the next index choice accounts + * for them. + */ + it('widens a tracked pending range when discovery evidences further indexes for the same tx', async () => { + await setUp(); + + const foreignTxHash = TxHash.random(); + + // An earlier window discovered only the first index of the foreign tx. + await taggingStore.storePendingIndexes( + [{ extendedSecret: secret, lowestIndex: 10, highestIndex: 10 }], + foreignTxHash, + 'test', + ); + + // The chain shows the tx actually used indexes 10 and 11. + const tag10 = await computeSiloedTagForIndex(10); + const tag11 = await computeSiloedTagForIndex(11); + + aztecNode.getPrivateLogsByTags.mockImplementation(query => { + const tags = query.tags as SiloedTag[]; + return Promise.resolve( + tags.map((tag: SiloedTag) => { + if (tag.equals(tag10)) { + return [makeLog(foreignTxHash, tag10.value)]; + } else if (tag.equals(tag11)) { + return [makeLog(foreignTxHash, tag11.value)]; + } + return []; + }), + ); + }); + + // The tx is mined but not yet finalized, so no receipt status change resolves the entry during this sync. + aztecNode.getTxReceipt.mockResolvedValue(mined(foreignTxHash, TxStatus.PROPOSED, 14)); + + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + // The next index choice must account for the onchain tag at index 11. + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(11); + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBeUndefined(); + }); + + /** + * Single-sync window straddle: a foreign tx's tags span the boundary between two consecutive sync windows, so the + * window loop assembles the tx's range piecewise — window 1 stores the lower index, window 2 evidences the higher + * one for the same (secret, txHash). The second write must widen the entry from window 1 rather than conflict with + * it, and the final index choice must cover the full onchain range. + */ + it('assembles a pending range piecewise when a tx straddles the sync window boundary', async () => { + await setUp(); + + // A tx finalized at index 0 makes the finalized index advance during window 1, so the loop proceeds to window 2. + const finalizedTxHash = TxHash.random(); + const finalizedTag = await computeSiloedTagForIndex(0); + + // The straddling tx used the last index of window 1 and the first index of window 2. + const straddlingTxHash = TxHash.random(); + const lowerStraddleIndex = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN - 1; + const upperStraddleIndex = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN; + const lowerStraddleTag = await computeSiloedTagForIndex(lowerStraddleIndex); + const upperStraddleTag = await computeSiloedTagForIndex(upperStraddleIndex); + + aztecNode.getPrivateLogsByTags.mockImplementation(query => { + const tags = query.tags as SiloedTag[]; + return Promise.resolve( + tags.map((tag: SiloedTag) => { + if (tag.equals(finalizedTag)) { + return [makeLog(finalizedTxHash, finalizedTag.value)]; + } else if (tag.equals(lowerStraddleTag)) { + return [makeLog(straddlingTxHash, lowerStraddleTag.value)]; + } else if (tag.equals(upperStraddleTag)) { + return [makeLog(straddlingTxHash, upperStraddleTag.value)]; + } + return []; + }), + ); + }); + + aztecNode.getTxReceipt.mockImplementation((hash: TxHash) => { + if (hash.equals(finalizedTxHash)) { + return Promise.resolve(mined(hash, TxStatus.FINALIZED, 14)); + } else if (hash.equals(straddlingTxHash)) { + // Mined but not finalized, so no receipt status change resolves the straddling entry during this sync. + return Promise.resolve(mined(hash, TxStatus.PROPOSED, 16)); + } + throw new Error(`Unexpected tx hash: ${hash.toString()}`); + }); + + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + // The straddled range must have been assembled piecewise: window 1's logs query covers the lower straddle index + // and window 2's the upper. (Each window fits in a single RPC page, so there is one logs call per window.) + expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); + const queriedTags = aztecNode.getPrivateLogsByTags.mock.calls.map(([query]) => query.tags as SiloedTag[]); + expect(queriedTags[0].some(tag => tag.equals(lowerStraddleTag))).toBe(true); + expect(queriedTags[1].some(tag => tag.equals(upperStraddleTag))).toBe(true); + + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(0); + // The next index choice must account for both straddled onchain tags. + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(upperStraddleIndex); + + // The straddling tx later finalizes. A single widened entry finalizes cleanly at the upper index — a duplicate + // entry for the same txHash would instead trip the multiple-pending-entries guard during finalization. + aztecNode.getTxReceipt.mockImplementation((hash: TxHash) => { + if (hash.equals(straddlingTxHash)) { + return Promise.resolve(mined(hash, TxStatus.FINALIZED, 18)); + } + throw new Error(`Unexpected tx hash: ${hash.toString()}`); + }); + + await syncSenderTaggingIndexes(secret, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); + + expect(await taggingStore.getLastFinalizedIndex(secret, 'test')).toBe(upperStraddleIndex); + expect(await taggingStore.getLastUsedIndex(secret, 'test')).toBe(upperStraddleIndex); }); }); diff --git a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts index 2b93d497787f..c3263fcf592e 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts @@ -74,7 +74,7 @@ export async function syncSenderTaggingIndexes( } // Receipts for pending tx hashes that the logs query just surfaced still need a sequential follow-up call. - // `storePendingIndexes` is idempotent on (secret, txHash), so a re-discovered hash stays classified as known + // `mergePendingIndexes` is idempotent on (secret, txHash), so a re-discovered hash stays classified as known // and is not re-fetched here. const knownSet = new Set(knownPendingTxHashes.map(h => h.toString())); const newPendingTxHashes = allPendingTxHashes.filter(h => !knownSet.has(h.toString())); diff --git a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts index accc15e25667..2f64205750c9 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.test.ts @@ -57,7 +57,7 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).not.toHaveBeenCalled(); + expect(taggingStore.mergePendingIndexes).not.toHaveBeenCalled(); }); it('single log found at a specific index', async () => { @@ -72,8 +72,8 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(1); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index, highestIndex: index }], txHash, 'test', @@ -103,8 +103,8 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(1); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index1, highestIndex: index2 }], txHash, 'test', @@ -135,13 +135,13 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(2); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(2); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index1, highestIndex: index1 }], txHash1, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index2, highestIndex: index2 }], txHash2, 'test', @@ -164,13 +164,13 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(2); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(2); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index, highestIndex: index }], txHash1, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: index, highestIndex: index }], txHash2, 'test', @@ -216,18 +216,18 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, 0, 10, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(3); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(3); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: 1, highestIndex: 8 }], txHash1, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: 3, highestIndex: 5 }], txHash2, 'test', ); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: 9, highestIndex: 9 }], txHash3, 'test', @@ -261,8 +261,8 @@ describe('loadAndStoreNewTaggingIndexes', () => { await loadAndStoreNewTaggingIndexes(secret, start, end, aztecNode, taggingStore, MOCK_ANCHOR_BLOCK_HASH, 'test'); // Only the log at start should be stored; end is exclusive - expect(taggingStore.storePendingIndexes).toHaveBeenCalledTimes(1); - expect(taggingStore.storePendingIndexes).toHaveBeenCalledWith( + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledTimes(1); + expect(taggingStore.mergePendingIndexes).toHaveBeenCalledWith( [{ extendedSecret: secret, lowestIndex: start, highestIndex: start }], txHashAtStart, 'test', diff --git a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts index 88e32f7fca10..7080a8d509e4 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts @@ -36,11 +36,15 @@ export async function loadAndStoreNewTaggingIndexes( const txsForTags = await getTxsContainingTags(siloedTagsForWindow, aztecNode, anchorBlockHash); const txIndexesMap = getTxIndexesMap(txsForTags, start, siloedTagsForWindow.length); - // Now we iterate over the map, construct the tagging index ranges and store them in the db. + // Now we iterate over the map, construct the tagging index ranges and store them in the db. A tx already tracked + // in the store is merged rather than range-checked: if this PXE sent the tx and it partially reverted, the chain + // only shows the surviving sub-range of the prove-time entry (the finalized receipt step of the sync owns + // resolving that difference), and a tx from another PXE may straddle a sync window boundary, in which case the + // entry is widened so the next index choice covers the full onchain range. for (const [txHashStr, indexes] of txIndexesMap.entries()) { const txHash = TxHash.fromString(txHashStr); const ranges = [{ extendedSecret, lowestIndex: Math.min(...indexes), highestIndex: Math.max(...indexes) }]; - await taggingStore.storePendingIndexes(ranges, txHash, jobId); + await taggingStore.mergePendingIndexes(ranges, txHash, jobId); } } diff --git a/yarn-project/stdlib/src/gas/README.md b/yarn-project/stdlib/src/gas/README.md index 982252ce703b..1bce97106cdb 100644 --- a/yarn-project/stdlib/src/gas/README.md +++ b/yarn-project/stdlib/src/gas/README.md @@ -1,47 +1,69 @@ # Aztec Gas and Fee Model The minimum fee per mana and its components are computed on L1 in -`l1-contracts/src/core/libraries/rollup/FeeLib.sol`. This document describes the -formulas, the oracle lag/lifetime mechanism, and the TypeScript types in this directory. +`l1-contracts/src/core/libraries/rollup/FeeLib.sol` (`fee_math.ts` in this directory is a +TypeScript port of those formulas, used to predict fees a few slots ahead). This document +describes the formulas, the oracle lag/lifetime mechanism, how a transaction's fee is +derived from them, the gas and data limits, and the TypeScript types in this directory. ## Mana -Aztec uses **mana** as its unit of work (analogous to Ethereum gas). Transactions consume -mana in two dimensions: **DA** (data availability) and **L2** (execution). The total fee -is `gasUsed * feePerMana` summed across both dimensions. +Aztec meters work as gas in two dimensions: **DA** (data availability, i.e. blob data +published to L1) and **L2** (execution). The L2 dimension is called **mana** (analogous to +Ethereum gas): block headers track `totalManaUsed`, and the fee model below prices one unit +of mana. The total fee is `gasUsed * feePerGas` summed across both dimensions. + +### The DA dimension is priced at zero + +Only the L2 dimension currently carries a price. When building checkpoint global variables, +the sequencer sets `feePerDaGas = 0` and sets `feePerL2Gas` to the L1-computed minimum fee +per mana (`sequencer-client/src/global_variable_builder/global_builder.ts` and +`fee_provider.ts` in the same directory). The cost of publishing data is still recovered: +the blob gas a checkpoint pays on L1 is part of the *sequencer cost* component of the mana +fee below. DA gas remains fully metered and limited (see +[Gas and Data Limits](#gas-and-data-limits)) — it bounds how much data a tx or checkpoint +may publish — it just contributes nothing to the fee today. ## Fee Components -The minimum fee per mana has four components: +The minimum fee per mana has four components. All are computed in ETH (wei) per mana and +converted to the fee asset at the end (see [Fee Asset Price](#fee-asset-price)). ### Sequencer Cost L1 cost to propose a checkpoint (calldata gas + blob data), amortized over `manaTarget`: ``` -sequencerCost = ((L1_GAS_PER_CHECKPOINT_PROPOSED * baseFee) +sequencerCost = ceil(((L1_GAS_PER_CHECKPOINT_PROPOSED * baseFee) + (BLOBS_PER_CHECKPOINT * BLOB_GAS_PER_BLOB * blobFee)) - / manaTarget + / manaTarget) ``` +Note that `BLOBS_PER_CHECKPOINT` here is FeeLib's own constant (3), not the protocol blob +capacity of the same name (6) — see the note under [Key Constants](#key-constants). + ### Prover Cost L1 cost to verify an epoch proof, amortized over epoch duration and `manaTarget`, plus a governance-set proving cost that compensates for off-chain proof generation: ``` -proverCost = (L1_GAS_PER_EPOCH_VERIFIED * baseFee / epochDuration) / manaTarget +proverCost = ceil(ceil((L1_GAS_PER_EPOCH_VERIFIED * baseFee) / epochDuration) / manaTarget) + provingCostPerMana ``` +Updates to `provingCostPerMana` are rate-limited on L1 (`FeeLib.updateProvingCostPerMana`): +at most one update every 30 days, each moving the value by at most ×1.5 (or ÷1.5), with a +floor of 2 wei per mana. + ### Congestion Cost An exponential surcharge when the network is congested (inspired by EIP-1559; the implementation uses the `fakeExponential` Taylor series approximation from EIP-4844): ``` -baseCost = sequencerCost + proverCost -congestionCost = baseCost * congestionMultiplier / MINIMUM_CONGESTION_MULTIPLIER - baseCost +baseCost = sequencerCost + proverCost +congestionCost = floor(baseCost * congestionMultiplier / MINIMUM_CONGESTION_MULTIPLIER) - baseCost ``` When there is no congestion the multiplier equals `MINIMUM_CONGESTION_MULTIPLIER` (1e9) @@ -50,11 +72,15 @@ and congestion cost is zero. ### Congestion Multiplier ``` -excessMana = max(0, prevExcessMana + prevManaUsed - manaTarget) -congestionMultiplier = fakeExponential(MINIMUM_CONGESTION_MULTIPLIER, excessMana, denominator) +excessMana = max(0, prevExcessMana + prevManaUsed - manaTarget) +denominator = manaTarget * 854,700,854 / 1e8 ≈ 8.547 * manaTarget +congestionMultiplier = fakeExponential(MINIMUM_CONGESTION_MULTIPLIER, + min(excessMana, 100 * denominator), denominator) ``` -Each additional `manaTarget` of excess mana increases the multiplier by ~12.5%. +Each additional `manaTarget` of excess mana multiplies the fee by `e^(1/8.547) ≈ 1.124`, +i.e. ~12.5%. The exponent is capped at 100 (multiplier ≤ ~2.7e43 × the minimum) to keep +the Taylor series from overflowing. ### Total @@ -62,6 +88,11 @@ Each additional `manaTarget` of excess mana increases the multiplier by ~12.5%. minFeePerMana = sequencerCost + proverCost + congestionCost ``` +Each component is converted from ETH to the fee asset individually (rounding up) before +summing. The sum is capped at `type(uint128).max` (`FeeLib.summedMinFee`) so it always fits +the proposal header's `feePerL2Gas` field — without the cap, extreme congestion could +produce a fee no valid header can represent, halting the chain. + ## L1 Gas Oracle: Lag and Lifetime The oracle feeds Ethereum's `baseFee` and `blobFee` into the fee model using a two-phase @@ -70,9 +101,9 @@ The oracle feeds Ethereum's `baseFee` and `blobFee` into the fee model using a t - **LAG = 2 slots** — when new L1 fees are observed, they activate `LAG` slots later (`slotOfChange = currentSlot + LAG`). This gives mempool transactions time to land before fees change. -- **LIFETIME = 5 slots** — after an oracle update, the next update is rejected until - `slotOfChange + (LIFETIME - LAG)` = 3 more slots have passed. This rate-limits how - frequently L1 fee data can change. +- **LIFETIME = 5 slots** — after an oracle update, further updates are ignored + (`updateL1GasFeeOracle` returns without effect) until `slotOfChange + (LIFETIME - LAG)` + = 3 more slots have passed. This rate-limits how frequently L1 fee data can change. Fee resolution at a given timestamp: @@ -84,6 +115,12 @@ else → use post (new fees) **Net effect**: L1 fee changes reach L2 with a 2-slot delay and can update at most once every 5 slots. +Because queued values only activate `LAG` slots later, the min fee for the next `LAG` +slots is fully determined by current on-chain state. `fee_math.ts` ports the FeeLib +formulas so the sequencer can predict min fees over that window (`FeePredictor` in +`sequencer-client/src/global_variable_builder`), under a configurable mana-usage +assumption (`ManaUsageEstimate`: none / target / limit). + ### Worked Example Suppose the oracle is updated at slot 10 with new L1 fees. Here is the timeline: @@ -117,34 +154,68 @@ Key observations: ## Fee Asset Price -Fees are computed in ETH internally but converted to the fee asset (Fee Juice) via -`ethPerFeeAsset` (1e12 precision). The price updates at most ±1% (±100 bps) per -checkpoint: +Fees are computed in ETH (wei) internally and converted to the fee asset (Fee Juice) via +`ethPerFeeAsset` (1e12 precision), rounding up (`PriceLib.toFeeAsset` in +`l1-contracts/src/core/libraries/compressed-data/fees/FeeConfig.sol`). + +Each checkpoint proposal carries a fee-asset price modifier (`OracleInput`) chosen by the +proposer, bounded to ±1% (±100 bps) per checkpoint: ``` newPrice = currentPrice * (10000 + modifierBps) / 10000 ``` +The result is clamped to [`MIN_ETH_PER_FEE_ASSET` = 100, `MAX_ETH_PER_FEE_ASSET` = 1e14], +i.e. 1e-10 to 100 ETH per fee asset. The floor of 100 guarantees a ±1% step always moves +the integer price by at least 1. + +## Transaction Fees + +The checkpoint's `gasFees` (the L1-computed min fee per mana, with DA priced at zero) act +as a base fee. Senders declare `GasSettings`: gas limits, teardown gas limits, +`maxFeesPerGas`, and `maxPriorityFeesPerGas`. A tx is only includable if `maxFeesPerGas` +covers the checkpoint's `gasFees` in both dimensions. The effective fee adds an +EIP-1559-style priority fee on top of the base, capped by the max +(`computeEffectiveGasFees` in `stdlib/src/fees/transaction_fee.ts`): + +``` +effectiveFeePerGas = gasFees + min(maxPriorityFeePerGas, maxFeesPerGas - gasFees) (per dimension) +transactionFee = billedGas.daGas * effectiveFeePerDaGas + billedGas.l2Gas * effectiveFeePerL2Gas +``` + +`billedGas` is actual consumption except for the teardown phase, which is billed at the +declared `teardownGasLimits` rather than actual usage — the fee is charged during teardown +itself, before actual teardown consumption is known. + ## Maximum Fee Change Rate -| Component | Bound | -| ---------------------- | ------------------------------------------------------- | -| L1 base fee / blob fee | At most once every 5 slots (oracle LIFETIME) | -| Fee asset price | ±1% per checkpoint | +| Component | Bound | +| ---------------------- | -------------------------------------------------------- | +| L1 base fee / blob fee | At most once every 5 slots (oracle LIFETIME) | +| Fee asset price | ±1% per checkpoint | +| Proving cost per mana | At most ×1.5 (or ÷1.5) per update, one update per 30 days | | Congestion multiplier | Depends on excess mana accumulation/drain per checkpoint | -| Sequencer/prover costs | Scale linearly with L1 fees | +| Sequencer/prover costs | Scale linearly with L1 fees | ## Key Constants -| Constant | Value | -| ------------------------------ | -------------- | -| `L1_GAS_PER_CHECKPOINT_PROPOSED` | 300,000 | -| `L1_GAS_PER_EPOCH_VERIFIED` | 3,600,000 | -| `BLOBS_PER_CHECKPOINT` | 3 | -| `BLOB_GAS_PER_BLOB` | 2^17 | -| `MINIMUM_CONGESTION_MULTIPLIER` | 1e9 | -| `LAG` | 2 slots | -| `LIFETIME` | 5 slots | +| Constant | Value | +| ------------------------------- | -------------- | +| `L1_GAS_PER_CHECKPOINT_PROPOSED` | 300,000 | +| `L1_GAS_PER_EPOCH_VERIFIED` | 3,600,000 | +| `BLOBS_PER_CHECKPOINT` (FeeLib) | 3 | +| `BLOB_GAS_PER_BLOB` | 2^17 | +| `MINIMUM_CONGESTION_MULTIPLIER` | 1e9 | +| `LAG` | 2 slots | +| `LIFETIME` | 5 slots | +| `MIN_ETH_PER_FEE_ASSET` | 100 (1e-10 ETH) | +| `MAX_ETH_PER_FEE_ASSET` | 1e14 (100 ETH) | + +⚠️ Name clash: `FeeLib.sol` (and its port `fee_math.ts`) defines `BLOBS_PER_CHECKPOINT = 3`, +used only to price the sequencer's blob costs. The protocol constant of the same name in +`@aztec/constants` is **6** — the actual blob capacity of a checkpoint, used throughout the +limits section below. The FeeLib value is a holdover from the pre-checkpoint +`BLOBS_PER_BLOCK`, so the fee model prices half of a full checkpoint's blobs. ## Gas and Data Limits @@ -196,7 +267,7 @@ advertises them in `NodeInfo.txsLimits` (a required field); wallets read it and `GasSettings.fallback` as the default gas limits when sending without explicit limits, and they are enforced by `GasLimitsValidator` (clamped to the per-tx protocol maxima) at three points: RPC tx acceptance (`aztec-node/src/aztec-node/server.ts`), gossip validation (`p2p/src/services/libp2p/libp2p_service.ts`), -and pending-pool admission (`p2p/src/client/factory.ts`). They are deliberately *not* enforced at reqresp or +and pending-pool admission (`p2p/src/client/factory.ts`). They are deliberately *not* enforced at req/resp or block-proposal validation — admission is relay policy, not block validity. ### Per-block builder budgets @@ -240,7 +311,8 @@ The outermost limits, enforced as proposal validity in `validateCheckpointLimits ## TypeScript Types -- **`Gas`** — mana quantity in two dimensions (`daGas`, `l2Gas`). +- **`Gas`** — gas quantity in two dimensions (`daGas`, `l2Gas`). - **`GasFees`** — per-unit price in each dimension (`feePerDaGas`, `feePerL2Gas`). - **`GasSettings`** — sender-chosen fee parameters: gas limits, teardown limits, max fees, priority fees. - **`GasUsed`** — actual consumption after execution. Note: `billedGas` uses the teardown gas *limit*, not actual usage. +- **`fee_math.ts`** — TypeScript port of the FeeLib formulas, used for fee prediction over the oracle LAG window. diff --git a/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts b/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts index e20729703003..04b6a28a7f48 100644 --- a/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts +++ b/yarn-project/stdlib/src/logs/app_tagging_secret.test.ts @@ -128,15 +128,12 @@ describe('AppTaggingSecret', () => { expect(parsed.toString()).toBe(original.toString()); }); - // TODO(F-680): Remove once unconstrained `toString()` always emits the kind-prefixed format. - it('parses kind-prefixed unconstrained secrets', async () => { + it('rejects a string that is not three-part', async () => { const original = await randomAppTaggingSecret(AppTaggingSecretKind.UNCONSTRAINED); - const parsed = AppTaggingSecret.fromString( - `${AppTaggingSecretKind.UNCONSTRAINED}:${original.secret.toString()}:${original.app.toString()}`, - ); - expect(parsed.kind).toBe(AppTaggingSecretKind.UNCONSTRAINED); - expect(parsed.toString()).toBe(original.toString()); + expect(() => AppTaggingSecret.fromString(`${original.secret.toString()}:${original.app.toString()}`)).toThrow( + /Invalid AppTaggingSecret string/, + ); }); it('rejects unknown kind prefixes', async () => { diff --git a/yarn-project/stdlib/src/logs/app_tagging_secret.ts b/yarn-project/stdlib/src/logs/app_tagging_secret.ts index ad263cee3b06..779618a2461b 100644 --- a/yarn-project/stdlib/src/logs/app_tagging_secret.ts +++ b/yarn-project/stdlib/src/logs/app_tagging_secret.ts @@ -89,29 +89,20 @@ export class AppTaggingSecret { } toString(): string { - // TODO(F-680): Migrate stored tagging keys and remove the legacy unconstrained format. - if (this.kind === AppTaggingSecretKind.UNCONSTRAINED) { - return `${this.secret.toString()}:${this.app.toString()}`; - } return `${this.kind}:${this.secret.toString()}:${this.app.toString()}`; } static fromString(str: string): AppTaggingSecret { const parts = str.split(':'); - if (parts.length === 2) { - // TODO(F-680): Remove legacy two-part parsing after stored tagging keys are migrated. - const [secretStr, appStr] = parts; - return new AppTaggingSecret(Fr.fromString(secretStr), AztecAddress.fromStringUnsafe(appStr)); - } - if (parts.length === 3) { - const [kindStr, secretStr, appStr] = parts; - return new AppTaggingSecret( - Fr.fromString(secretStr), - AztecAddress.fromStringUnsafe(appStr), - appTaggingSecretKindFromString(kindStr), - ); + if (parts.length !== 3) { + throw new Error(`Invalid AppTaggingSecret string: ${str}`); } - throw new Error(`Invalid AppTaggingSecret string: ${str}`); + const [kindStr, secretStr, appStr] = parts; + return new AppTaggingSecret( + Fr.fromString(secretStr), + AztecAddress.fromStringUnsafe(appStr), + appTaggingSecretKindFromString(kindStr), + ); } } diff --git a/yarn-project/txe/src/bin/check_txe_oracle_version.ts b/yarn-project/txe/src/bin/check_txe_oracle_version.ts index 5a4a9ee02ad9..9e54da8552d7 100644 --- a/yarn-project/txe/src/bin/check_txe_oracle_version.ts +++ b/yarn-project/txe/src/bin/check_txe_oracle_version.ts @@ -1,32 +1,29 @@ import { keccak256String } from '@aztec/foundation/crypto/keccak'; -import { getOracleInterfaceSignature, readNumericGlobal } from '@aztec/pxe/bin'; +import { getOracleRegistrySignature, readNumericGlobal } from '@aztec/pxe/bin'; +import { ORACLE_REGISTRY, type OracleRegistryEntry } from '@aztec/pxe/simulator'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { TXE_ORACLE_REGISTRY } from '../oracle/txe_oracle_registry.js'; import { TXE_ORACLE_INTERFACE_HASH, TXE_ORACLE_VERSION_MAJOR } from '../oracle/txe_oracle_version.js'; /** * Verifies that the TXE oracle interfaces match the expected interface hash. * * The TXE oracle interfaces need to be versioned to ensure compatibility between Aztec.nr tests and TXE. This function - * computes a hash of the TXE oracle interfaces and compares it against a known hash. If they don't match, it means an + * computes a hash of the TXE-specific entries in `TXE_ORACLE_REGISTRY` (the shared PXE entries are covered by the PXE + * oracle version check) and compares it against a known hash. If they don't match, it means an * interface has changed and the TXE oracle version needs to be bumped: * - If the change is backward-breaking (e.g. removing/renaming an oracle), bump TXE_ORACLE_VERSION_MAJOR. * - If the change is an oracle addition (non-breaking), bump TXE_ORACLE_VERSION_MINOR. */ function assertTxeOracleInterfaceMatches(): void { - const currentDir = dirname(fileURLToPath(import.meta.url)); - const packageRoot = dirname(dirname(currentDir)); - const interfacesSourcePath = join(packageRoot, 'src/oracle/interfaces.ts'); - - const targets = ['IAvmExecutionOracle', 'ITxeExecutionOracle']; - // Not an oracle foreign call handler (see TODO(F-335) in interfaces.ts). - const excludedMembers = ['syncContractNonOracleMethod']; - - const txeOracleInterfaceSignature = getOracleInterfaceSignature(interfacesSourcePath, targets, excludedMembers); + const txeOnlyEntries: Record = Object.fromEntries( + Object.entries(TXE_ORACLE_REGISTRY).filter(([name]) => !(name in ORACLE_REGISTRY)), + ); - const txeOracleInterfaceHash = keccak256String(txeOracleInterfaceSignature); + const txeOracleInterfaceHash = keccak256String(getOracleRegistrySignature(txeOnlyEntries)); if (txeOracleInterfaceHash !== TXE_ORACLE_INTERFACE_HASH) { throw new Error( `The TXE oracle interface has changed. Update TXE_ORACLE_INTERFACE_HASH to ${txeOracleInterfaceHash} in txe/src/oracle/txe_oracle_version.ts and bump the TXE oracle version (TXE_ORACLE_VERSION_MAJOR for breaking changes, TXE_ORACLE_VERSION_MINOR for oracle additions).`, diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index 1f48c9a3753b..90e2881308aa 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -73,7 +73,18 @@ export interface ITxeExecutionOracle { addAccount(secret: Fr): Promise; addAuthWitness(address: AztecAddress, messageHash: Fr): Promise; sendL1ToL2Message(content: Fr, secretHash: Fr, sender: EthAddress, recipient: AztecAddress): Promise; - setTaggingSecretStrategy(strategy: Option): void; + /** + * Configures the tagging secret strategy the test's simulated wallet resolves for each delivery mode. A `none` + * clears that mode, so it falls back to the default strategy (or, when both modes end up unset, to no hook at all). + */ + setTaggingSecretStrategies( + unconstrainedStrategy: Option, + constrainedStrategy: Option, + ): void; + /** + * Configures whether the test's simulated wallet authorizes every cross-contract utility call target. + */ + setAuthorizeAllUtilityCallTargets(authorizeAll: boolean): void; getLastBlockTimestamp(): Promise; getLastTxEffects(): Promise<{ txHash: TxHash; diff --git a/yarn-project/txe/src/oracle/oracle_kinds.test.ts b/yarn-project/txe/src/oracle/oracle_kinds.test.ts new file mode 100644 index 000000000000..7edfd6432a52 --- /dev/null +++ b/yarn-project/txe/src/oracle/oracle_kinds.test.ts @@ -0,0 +1,33 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { TypeMapping } from '@aztec/pxe/simulator'; + +import { SCALAR_MAPPINGS, testValueFor } from './test-resolver/default_fixtures.js'; + +describe('oracle type-mapping labels', () => { + it('mappings sharing a label are wire-equivalent', () => { + // The interface hash treats mappings with equal labels as the same wire type, so two scalar mappings sharing a + // label (e.g. FIELD and TX_HASH, both a Noir `Field`) must serialize the same canonical value for the same seed. + const byLabel = new Map[]>(); + // A deserialize-only mapping has no wire form to compare. + for (const mapping of SCALAR_MAPPINGS.filter(m => m.serialization !== undefined)) { + byLabel.set(mapping.label, [...(byLabel.get(mapping.label) ?? []), mapping]); + } + + for (const [label, mappings] of byLabel.entries()) { + const serialized = mappings.map(mapping => canonicalRows(mapping)); + for (const rows of serialized.slice(1)) { + expect({ label, rows }).toEqual({ label, rows: serialized[0] }); + } + } + }); +}); + +/** Serialized canonical values at a few seeds. */ +function canonicalRows(mapping: TypeMapping): string[][] { + return [10, 11, 12].map(seed => + mapping + .serialization!.fn(testValueFor(mapping, seed)) + .flat() + .map((f: Fr) => f.toString()), + ); +} diff --git a/yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts b/yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts new file mode 100644 index 000000000000..ef2f03e2b97a --- /dev/null +++ b/yarn-project/txe/src/oracle/tagging_secret_strategy.test.ts @@ -0,0 +1,47 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { Point } from '@aztec/foundation/curves/grumpkin'; +import { DEFAULT_TAGGING_SECRET_STRATEGY, type TaggingSecretStrategy } from '@aztec/pxe/server'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +import { makeResolveTaggingSecretStrategyHook } from './tagging_secret_strategy.js'; + +describe('makeResolveTaggingSecretStrategyHook', () => { + it('returns undefined when no TXE strategy is configured', () => { + expect(makeResolveTaggingSecretStrategyHook(new Map())).toBeUndefined(); + }); + + it('selects a strategy by delivery mode', async () => { + const unconstrained: TaggingSecretStrategy = { type: 'arbitrary-secret', secret: await Point.random() }; + const constrained: TaggingSecretStrategy = { type: 'interactive-handshake' }; + const hook = makeResolveTaggingSecretStrategyHook( + new Map([ + [AppTaggingSecretKind.UNCONSTRAINED, unconstrained], + [AppTaggingSecretKind.CONSTRAINED, constrained], + ]), + ); + + await expect(hook?.(makeRequest(AppTaggingSecretKind.UNCONSTRAINED))).resolves.toBe(unconstrained); + await expect(hook?.(makeRequest(AppTaggingSecretKind.CONSTRAINED))).resolves.toBe(constrained); + }); + + it('defaults an unset mode to PXE default strategy when another mode is configured', async () => { + const unconstrained = { type: 'address-derived' as const }; + const hook = makeResolveTaggingSecretStrategyHook(new Map([[AppTaggingSecretKind.UNCONSTRAINED, unconstrained]])); + + await expect(hook?.(makeRequest(AppTaggingSecretKind.UNCONSTRAINED))).resolves.toBe(unconstrained); + await expect(hook?.(makeRequest(AppTaggingSecretKind.CONSTRAINED))).resolves.toEqual( + DEFAULT_TAGGING_SECRET_STRATEGY, + ); + }); +}); + +function makeRequest(deliveryMode: AppTaggingSecretKind) { + return { + contractAddress: AztecAddress.ZERO, + contractClassId: Fr.ZERO, + sender: AztecAddress.ZERO, + recipient: AztecAddress.ZERO, + deliveryMode, + }; +} diff --git a/yarn-project/txe/src/oracle/tagging_secret_strategy.ts b/yarn-project/txe/src/oracle/tagging_secret_strategy.ts new file mode 100644 index 000000000000..089ae0d78ff8 --- /dev/null +++ b/yarn-project/txe/src/oracle/tagging_secret_strategy.ts @@ -0,0 +1,25 @@ +import { + DEFAULT_TAGGING_SECRET_STRATEGY, + type ResolveTaggingSecretStrategy, + type TaggingSecretStrategy, +} from '@aztec/pxe/server'; +import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +/** The tagging secret strategies a TXE test has configured, keyed by delivery mode. Absence means "not configured". */ +export type TXETaggingSecretStrategies = Map; + +/** + * Builds the `resolveTaggingSecretStrategy` hook backing the `aztec_txe_setTaggingSecretStrategies` oracle. Returns + * `undefined` when no strategy is configured, so PXE's own no-hook default path is exercised. When at least one mode + * is configured, modes without an entry resolve to {@link DEFAULT_TAGGING_SECRET_STRATEGY}, matching what PXE would + * apply without a hook. + */ +export function makeResolveTaggingSecretStrategyHook( + strategies: TXETaggingSecretStrategies, +): ResolveTaggingSecretStrategy | undefined { + if (strategies.size === 0) { + return undefined; + } + + return ({ deliveryMode }) => Promise.resolve(strategies.get(deliveryMode) ?? DEFAULT_TAGGING_SECRET_STRATEGY); +} diff --git a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts index 9d3ba9b77d03..1a418d415afc 100644 --- a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts +++ b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.test.ts @@ -1,12 +1,12 @@ /* eslint-disable camelcase */ import { BOUNDED_VEC, - BYTE, type BoundedVec, FIELD, OPTION, type Option, type OracleRegistryEntry, + U8, makeEntry, } from '@aztec/pxe/simulator'; @@ -16,7 +16,7 @@ describe('synthesizeDefaultFixtures', () => { it('synthesizes a BoundedVec param, padding data below capacity', () => { const registry: Record = { push_bytes: makeEntry({ - params: [{ name: 'ciphertext', type: BOUNDED_VEC(BYTE) }], + params: [{ name: 'ciphertext', type: BOUNDED_VEC(U8) }], }), }; @@ -31,7 +31,7 @@ describe('synthesizeDefaultFixtures', () => { it('synthesizes an Option> return as two cases named some/none', () => { const registry: Record = { decrypt_bytes: makeEntry({ - returnType: OPTION(BOUNDED_VEC(BYTE)), + returnType: OPTION(BOUNDED_VEC(U8)), }), }; diff --git a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts index 600ffbd980a4..53b4309409cf 100644 --- a/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts +++ b/yarn-project/txe/src/oracle/test-resolver/default_fixtures.ts @@ -8,19 +8,29 @@ import { BLOCK_HASH, BLOCK_NUMBER, BOOL, - BYTE, BoundedVec, + type CompositeMapping, DELIVERY_MODE, ETH_ADDRESS, FIELD, FUNCTION_SELECTOR, + LEAF_INDEX, NOTE_SELECTOR, Option, type OracleRegistryEntry, SLOT_NUMBER, - type StructField, + type StructMapping, type TypeMapping, + U8, U32, + U64, + U128, + isArrayMapping, + isBoundedVecMapping, + isFixedArrayMapping, + isFixedBoundedVecMapping, + isOptionMapping, + isStructMapping, tryFieldWidth, } from '@aztec/pxe/simulator'; import { FunctionSelector, NoteSelector } from '@aztec/stdlib/abi'; @@ -50,15 +60,18 @@ export function synthesizeDefaultFixtures( } /** - * The test-value implementations, one per oracle type, mirroring the Noir synthesizer tables (`scalars` and - * `synthesizers` in `noir-projects/aztec-nr/.../macros/oracle_testing.nr`) exactly. + * The scalar test-value implementations, mirroring the Noir synthesizer's `scalars` table in + * `noir-projects/aztec-nr/.../macros/oracle_testing.nr` exactly. */ -const TEST_VALUE_IMPLS: TestValueImpl[] = [ +const SCALAR_IMPLS: ScalarImpl[] = [ scalar(FIELD, seed => new Fr(seed)), scalar(U32, seed => seed), scalar(BLOCK_NUMBER, seed => BlockNumber(seed)), scalar(BIGINT, seed => BigInt(seed)), - scalar(BYTE, seed => seed), + scalar(U64, seed => BigInt(seed)), + scalar(U128, seed => BigInt(seed)), + scalar(LEAF_INDEX, seed => seed), + scalar(U8, seed => seed), scalar(BOOL, seed => seed % 2 !== 0), scalar(AZTEC_ADDRESS, seed => AztecAddress.fromNumberUnsafe(seed)), scalar(ETH_ADDRESS, seed => EthAddress.fromField(new Fr(seed))), @@ -70,12 +83,18 @@ const TEST_VALUE_IMPLS: TestValueImpl[] = [ scalar(DELIVERY_MODE, seed => seed % 2 === 0 ? AppTaggingSecretKind.UNCONSTRAINED : AppTaggingSecretKind.CONSTRAINED, ), - composite(isOption, (type, seed) => [ - named(Option.some(firstValue(type.inner, seed)), 'some'), - named(Option.none(firstValue(type.inner, seed)), 'none'), +]; + +/** + * The composite test-value implementations, one per combinator, mirroring the Noir synthesizer's `synthesizers` table. + */ +const COMPOSITE_IMPLS: CompositeImpl[] = [ + composite(isOptionMapping, (type, seed) => [ + named(Option.some(testValueFor(type.inner, seed)), 'some'), + named(Option.none(testValueFor(type.inner, seed)), 'none'), ]), - composite(isArray, (type, seed) => [unnamed(collectionData(type.inner, seed, DEFAULT_ARRAY_LENGTH))]), - composite(isBoundedVec, (type, seed) => [ + composite(isArrayMapping, (type, seed) => [unnamed(collectionData(type.inner, seed, DEFAULT_ARRAY_LENGTH))]), + composite(isBoundedVecMapping, (type, seed) => [ unnamed( BoundedVec.from({ data: collectionData(type.inner, seed, DEFAULT_ARRAY_LENGTH), @@ -83,17 +102,20 @@ const TEST_VALUE_IMPLS: TestValueImpl[] = [ }), ), ]), - composite(isStruct, (type, seed) => [unnamed(structValue(type, seed))]), + composite(isStructMapping, (type, seed) => [unnamed(structValue(type, seed))]), // A fixed-length array uses its real (signature) length, unlike the generic-length ARRAY which is pinned to // DEFAULT_ARRAY_LENGTH. - composite(isFixedArray, (type, seed) => [unnamed(collectionData(type.inner, seed, type.length))]), + composite(isFixedArrayMapping, (type, seed) => [unnamed(collectionData(type.inner, seed, type.length))]), // Same for a fixed-capacity bounded vec: real capacity, DEFAULT_ARRAY_LENGTH elements (its value type is a plain // element array, unlike the two-slot BOUNDED_VEC), clamped to the capacity so small vecs stay valid. - composite(isFixedBoundedVec, (type, seed) => [ + composite(isFixedBoundedVecMapping, (type, seed) => [ unnamed(collectionData(type.inner, seed, Math.min(DEFAULT_ARRAY_LENGTH, type.maxLength))), ]), ]; +/** The scalar mappings with a registered test-value impl. */ +export const SCALAR_MAPPINGS: TypeMapping[] = SCALAR_IMPLS.map(impl => impl.mapping); + /** * The base every seed is offset by, applied once where a parameter's position becomes its seed, so generated values * stay clear of 0. Must match `SEED_BASE` in the Noir `oracle_testing.nr`. @@ -125,32 +147,34 @@ function named(value: unknown, name: string): Scenario { return { value, name }; } -/** - * One type's test-value implementation, mirroring a Noir `TestValueSynthesizer` entry: `match` selects the type and - * `scenarios` yields its [`Scenario`]s for `seed`, one per serialization shape, like the Noir entry's `scenarios` - * function. - */ -interface TestValueImpl { +/** An impl for the single scalar `mapping` it synthesizes for. */ +type ScalarImpl = { + mapping: TypeMapping; + scenarios: (type: TypeMapping, seed: number) => Scenario[]; +}; + +/** An impl for a whole family of composite mappings, selected by its `kind` type guard. */ +type CompositeImpl = { match: (type: TypeMapping) => boolean; scenarios: (type: TypeMapping, seed: number) => Scenario[]; -} +}; -/** A scalar impl: matches the singleton `type` by identity and yields a single unnamed scenario. */ -function scalar(type: TypeMapping, value: (seed: number) => unknown): TestValueImpl { - return { match: t => t === type, scenarios: (_type, seed) => [unnamed(value(seed))] }; +/** A scalar impl yielding a single unnamed scenario. */ +function scalar(mapping: TypeMapping, value: (seed: number) => unknown): ScalarImpl { + return { mapping, scenarios: (_type, seed) => [unnamed(value(seed))] }; } -/** A composite impl: selected by the `kind` type guard, recursing through its element/inner mapping. */ +/** A composite impl, recursing through the matched mapping's element/inner mapping. */ function composite( guard: (type: TypeMapping) => type is K, scenarios: (type: K, seed: number) => Scenario[], -): TestValueImpl { +): CompositeImpl { return { match: guard, scenarios: (type, seed) => scenarios(type as K, seed) }; } /** Synthesized element values for a collection. */ function collectionData(element: TypeMapping, seed: number, length: number): unknown[] { - return Array.from({ length }, (_, i) => firstValue(element, seed + i)); + return Array.from({ length }, (_, i) => testValueFor(element, seed + i)); } /** @@ -161,7 +185,7 @@ function structValue(type: StructMapping, seed: number): Record const value: Record = {}; let offset = 0; for (const field of type.fields) { - value[field.name] = firstValue(field.type, seed + offset); + value[field.name] = testValueFor(field.type, seed + offset); const width = tryFieldWidth(field.type.shape); if (width === undefined) { // A variable-width field has no flat offset to seed the next field at, so only this oracle is unsynthesizable. @@ -177,15 +201,18 @@ function structValue(type: StructMapping, seed: number): Record * dispatcher. Throws if the type has no matching impl. */ function scenariosForType(type: TypeMapping, seed: number): Scenario[] { - const impl = TEST_VALUE_IMPLS.find(i => i.match(type)); + const impl = SCALAR_IMPLS.find(i => i.mapping === type) ?? COMPOSITE_IMPLS.find(i => i.match(type)); if (!impl) { throw new UnsynthesizableTypeError(type); } return impl.scenarios(type, seed); } -/** The first (representative) test value of `type`, used for collection elements and `Option` inners. */ -function firstValue(type: TypeMapping, seed: number): unknown { +/** + * The test value of `type` at `seed`, matching what the Noir synthesizer produces for the same type and seed. Throws + * for types without a test-value impl. + */ +export function testValueFor(type: TypeMapping, seed: number): unknown { return scenariosForType(type, seed)[0].value; } @@ -242,41 +269,3 @@ function synthesizeScenariosOrThrow(entry: OracleRegistryEntry): OracleTestScena } return scenarios; } - -/** - * The composite `TypeMapping`s (`ARRAY`/`BOUNDED_VEC`/`OPTION`/`STRUCT`/`FIXED_ARRAY`/`FIXED_BOUNDED_VEC`), - * discriminated by `kind`. The combinators attach `kind` plus the inner mapping(s) at construction; the registry - * erases params to the base `TypeMapping`, so the guards below recover the structure for recursion. - */ -type ArrayMapping = TypeMapping & { kind: 'array'; inner: TypeMapping }; -type BoundedVecMapping = TypeMapping & { kind: 'bounded-vec'; inner: TypeMapping }; -type OptionMapping = TypeMapping & { kind: 'option'; inner: TypeMapping }; -type StructMapping = TypeMapping & { kind: 'struct'; fields: readonly StructField[] }; -type FixedArrayMapping = TypeMapping & { kind: 'fixed-array'; inner: TypeMapping; length: number }; -type FixedBoundedVecMapping = TypeMapping & { kind: 'fixed-bounded-vec'; inner: TypeMapping; maxLength: number }; -type CompositeMapping = - | ArrayMapping - | BoundedVecMapping - | OptionMapping - | StructMapping - | FixedArrayMapping - | FixedBoundedVecMapping; - -function isArray(type: TypeMapping): type is ArrayMapping { - return 'kind' in type && type.kind === 'array'; -} -function isBoundedVec(type: TypeMapping): type is BoundedVecMapping { - return 'kind' in type && type.kind === 'bounded-vec'; -} -function isOption(type: TypeMapping): type is OptionMapping { - return 'kind' in type && type.kind === 'option'; -} -function isStruct(type: TypeMapping): type is StructMapping { - return 'kind' in type && type.kind === 'struct'; -} -function isFixedArray(type: TypeMapping): type is FixedArrayMapping { - return 'kind' in type && type.kind === 'fixed-array'; -} -function isFixedBoundedVec(type: TypeMapping): type is FixedBoundedVecMapping { - return 'kind' in type && type.kind === 'fixed-bounded-vec'; -} diff --git a/yarn-project/txe/src/oracle/test-resolver/resolver.ts b/yarn-project/txe/src/oracle/test-resolver/resolver.ts index a8d1c2393252..f74cb2742aab 100644 --- a/yarn-project/txe/src/oracle/test-resolver/resolver.ts +++ b/yarn-project/txe/src/oracle/test-resolver/resolver.ts @@ -2,7 +2,7 @@ import type { Logger } from '@aztec/foundation/log'; import { createLogger } from '@aztec/foundation/log'; import { withoutHexPrefix } from '@aztec/foundation/string'; -import { BOUNDED_VEC, BYTE, BoundedVec, Option, type OracleRegistryEntry, makeEntry } from '@aztec/pxe/simulator'; +import { BOUNDED_VEC, BoundedVec, Option, type OracleRegistryEntry, U8, makeEntry } from '@aztec/pxe/simulator'; import type { ForeignCallArgs, ForeignCallResult } from '../../utils/encoding.js'; import { toInputSlots } from '../txe_oracle_registry.js'; @@ -10,7 +10,7 @@ import { synthesizeDefaultFixtures } from './default_fixtures.js'; /** Name of the meta-oracle that Noir tests call to announce the next call's scenario by name. */ const SET_SCENARIO_ORACLE = 'aztec_oracle_test_set_scenario'; -export const SET_SCENARIO_ENTRY = makeEntry({ params: [{ name: 'name', type: BOUNDED_VEC(BYTE) }] }); +export const SET_SCENARIO_ENTRY = makeEntry({ params: [{ name: 'name', type: BOUNDED_VEC(U8) }] }); export type OracleTestCallInput = { session_id: number; diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index f3694549fc77..6af26eaedf93 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -14,7 +14,6 @@ import type { TaggingSecretStrategy } from '@aztec/pxe/server'; import { ARRAY, AZTEC_ADDRESS, - BIGINT, BLOCK_NUMBER, BOOL, ETH_ADDRESS, @@ -22,17 +21,20 @@ import { FIXED_ARRAY, FUNCTION_SELECTOR, type InputSlot, + LEAF, type MaybePromise, OPTION, ORACLE_REGISTRY, type OracleRegistryEntry, type OutputSlot, type ParamTypes, + SCALAR, STR, STRUCT, type SlotShape, type TypeMapping, U32, + U64, buildACIRCallback, makeEntry, } from '@aztec/pxe/simulator'; @@ -59,22 +61,24 @@ export type { BlockHash } from '@aztec/stdlib/block'; export type { MembershipWitness } from '@aztec/foundation/trees'; export type { NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; -const GAS_SETTINGS: TypeMapping = { +const GAS_SETTINGS: TypeMapping = LEAF({ + kind: 'gas-settings', deserialization: { fn: ([reader]) => GasSettings.fromFields(reader.readFieldArray(GAS_SETTINGS_LENGTH)), }, shape: [{ len: GAS_SETTINGS_LENGTH }], -}; +}); // Tagging secret strategy discriminants. Must match the Noir test helper `TaggingSecretStrategy` in -// aztec-nr `test/helpers/tagging_secret_strategy.nr`. This is a test-only oracle (only `set_tagging_secret_strategy` +// aztec-nr `test/helpers/tagging_secret_strategy.nr`. This is a test-only oracle (only `set_tagging_secret_strategies` // reads it), so the mapping lives here on the TXE side rather than in the production oracle type mappings. const STRATEGY_NON_INTERACTIVE_HANDSHAKE = 1; const STRATEGY_ARBITRARY_SECRET = 2; const STRATEGY_ADDRESS_DERIVED = 3; const STRATEGY_INTERACTIVE_HANDSHAKE = 4; -const TAGGING_SECRET_STRATEGY: TypeMapping = { +const TAGGING_SECRET_STRATEGY: TypeMapping = LEAF({ + kind: 'tagging-secret-strategy', serialization: { fn: strategy => { switch (strategy.type) { @@ -108,24 +112,27 @@ const TAGGING_SECRET_STRATEGY: TypeMapping = { }, }, shape: ['scalar', 'scalar', 'scalar'], -}; +}); -const PRIVATE_CONTEXT_INPUTS: TypeMapping = { +const PRIVATE_CONTEXT_INPUTS: TypeMapping = LEAF({ + kind: 'private-context-inputs', serialization: { fn: v => v.toFields() }, shape: Array(PRIVATE_CONTEXT_INPUTS_LENGTH).fill('scalar'), -}; +}); -const COMPLETE_ADDRESS: TypeMapping = { +const COMPLETE_ADDRESS: TypeMapping = LEAF({ + kind: 'complete-address', serialization: { fn: v => [v.address.toField(), ...v.publicKeys.toFields()] }, shape: Array(8).fill('scalar'), // address + 7 public-key fields -}; +}); const TXE_TX_EFFECTS: TypeMapping<{ txHash: TxHash; noteHashes: Fr[]; nullifiers: Fr[]; privateLogs: PrivateLog[]; -}> = { +}> = LEAF({ + kind: 'txe-tx-effects', serialization: { fn: ({ txHash, noteHashes, nullifiers, privateLogs }) => { const emittedLogs = privateLogs.map(log => log.getEmittedFields()); @@ -164,9 +171,10 @@ const TXE_TX_EFFECTS: TypeMapping<{ { len: MAX_PRIVATE_LOGS_PER_TX }, 'scalar', ], -}; +}); -const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = { +const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = LEAF({ + kind: 'txe-offchain-effects', serialization: { fn: ({ effects }) => { const rawArrayStorage = effects @@ -188,9 +196,10 @@ const TXE_OFFCHAIN_EFFECTS: TypeMapping<{ effects: Fr[][] }> = { { len: MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY }, 'scalar', ], -}; +}); -const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint }> = { +const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint }> = LEAF({ + kind: 'txe-call-context', serialization: { fn: ({ txHash, anchorBlockTimestamp }) => { const isSome = txHash.isZero() ? 0 : 1; @@ -198,7 +207,7 @@ const TXE_CALL_CONTEXT: TypeMapping<{ txHash: Fr; anchorBlockTimestamp: bigint } }, }, shape: ['scalar', 'scalar', 'scalar'], // discriminant, txHash, anchor block timestamp -}; +}); const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ exists: boolean; member: Fr }[]> = FIXED_ARRAY( STRUCT([ @@ -208,13 +217,14 @@ const CONTRACT_INSTANCE_MEMBER: TypeMapping<{ exists: boolean; member: Fr }[]> = 1, ); -const EVENT_SELECTOR: TypeMapping = { +const EVENT_SELECTOR: TypeMapping = SCALAR({ + kind: 'event-selector', serialization: { fn: v => [v.toField()] }, deserialization: { fn: ([reader]) => EventSelector.fromField(reader.readField()) }, - shape: ['scalar'], -}; +}); -const TXE_PRIVATE_EVENTS: TypeMapping = { +const TXE_PRIVATE_EVENTS: TypeMapping = LEAF({ + kind: 'txe-private-events', serialization: { fn: events => { const rawArrayStorage = events @@ -235,7 +245,7 @@ const TXE_PRIVATE_EVENTS: TypeMapping = { { len: MAX_PRIVATE_EVENTS_PER_TXE_QUERY }, 'scalar', ], -}; +}); export const TXE_ORACLE_REGISTRY = { ...ORACLE_REGISTRY, @@ -270,14 +280,14 @@ export const TXE_ORACLE_REGISTRY = { aztec_txe_getNextBlockNumber: makeEntry({ returnType: BLOCK_NUMBER }), - aztec_txe_getNextBlockTimestamp: makeEntry({ returnType: BIGINT }), + aztec_txe_getNextBlockTimestamp: makeEntry({ returnType: U64 }), aztec_txe_advanceBlocksBy: makeEntry({ params: [{ name: 'blocks', type: U32 }], }), aztec_txe_advanceTimestampBy: makeEntry({ - params: [{ name: 'duration', type: BIGINT }], + params: [{ name: 'duration', type: U64 }], }), aztec_txe_deploy: makeEntry({ @@ -323,12 +333,19 @@ export const TXE_ORACLE_REGISTRY = { returnType: FIELD, }), - aztec_txe_setTaggingSecretStrategy: makeEntry({ - params: [{ name: 'strategy', type: OPTION(TAGGING_SECRET_STRATEGY) }], + aztec_txe_setTaggingSecretStrategies: makeEntry({ + params: [ + { name: 'unconstrainedStrategy', type: OPTION(TAGGING_SECRET_STRATEGY) }, + { name: 'constrainedStrategy', type: OPTION(TAGGING_SECRET_STRATEGY) }, + ], + }), + + aztec_txe_setAuthorizeAllUtilityCallTargets: makeEntry({ + params: [{ name: 'authorizeAll', type: BOOL }], }), aztec_txe_getLastBlockTimestamp: makeEntry({ - returnType: BIGINT, + returnType: U64, }), aztec_txe_getLastTxEffects: makeEntry({ returnType: TXE_TX_EFFECTS }), @@ -387,7 +404,7 @@ export const TXE_ORACLE_REGISTRY = { aztec_avm_blockNumber: makeEntry({ returnType: BLOCK_NUMBER }), - aztec_avm_timestamp: makeEntry({ returnType: BIGINT }), + aztec_avm_timestamp: makeEntry({ returnType: U64 }), aztec_avm_isStaticCall: makeEntry({ returnType: BOOL }), diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index 47494e939aab..21af3d40f03c 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -73,6 +73,7 @@ import { } from '@aztec/stdlib/kernel'; import { deriveKeys, hashPublicKey } from '@aztec/stdlib/keys'; import type { PrivateLog } from '@aztec/stdlib/logs'; +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { L1Actor, L1ToL2Message, L2Actor } from '@aztec/stdlib/messaging'; import { ChonkProof } from '@aztec/stdlib/proofs'; import { makeGlobalVariables } from '@aztec/stdlib/testing'; @@ -99,6 +100,7 @@ import type { TXEAccountStore } from '../utils/txe_account_store.js'; import type { TXEArtifactResolver } from '../utils/txe_artifact_resolver.js'; import { TXEPublicContractDataSource } from '../utils/txe_public_contract_data_source.js'; import type { ITxeExecutionOracle } from './interfaces.js'; +import { type TXETaggingSecretStrategies, makeResolveTaggingSecretStrategyHook } from './tagging_secret_strategy.js'; export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracle { isMisc = true as const; @@ -123,7 +125,8 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private version: Fr, private chainId: Fr, private authwits: Map, - private taggingSecretStrategy: TaggingSecretStrategy | undefined, + private taggingSecretStrategies: TXETaggingSecretStrategies, + private authorizeAllUtilityCallTargets: boolean, private readonly artifactResolver: TXEArtifactResolver, private readonly rootPath: string, private readonly packageName: string, @@ -355,8 +358,23 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl this.authwits.set(authWitness.requestHash.toString(), authWitness); } - setTaggingSecretStrategy(strategy: Option): void { - this.taggingSecretStrategy = strategy.value; + setTaggingSecretStrategies( + unconstrainedStrategy: Option, + constrainedStrategy: Option, + ): void { + const apply = (mode: AppTaggingSecretKind, strategy: Option) => { + if (strategy.isSome()) { + this.taggingSecretStrategies.set(mode, strategy.value); + } else { + this.taggingSecretStrategies.delete(mode); + } + }; + apply(AppTaggingSecretKind.UNCONSTRAINED, unconstrainedStrategy); + apply(AppTaggingSecretKind.CONSTRAINED, constrainedStrategy); + } + + setAuthorizeAllUtilityCallTargets(authorizeAll: boolean): void { + this.authorizeAllUtilityCallTargets = authorizeAll; } async sendL1ToL2Message(content: Fr, secretHash: Fr, sender: EthAddress, recipient: AztecAddress): Promise { @@ -470,7 +488,6 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl const simulator = new WASMSimulator(); const transientArrayService = new TransientArrayService(); - const taggingSecretStrategy = this.taggingSecretStrategy; const privateExecutionOracle = new PrivateExecutionOracle({ argsHash, txContext, @@ -510,9 +527,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl isStaticCall ? 'private view' : 'private', authorizedUtilityCallTargets, ), - // Only configure the hook when a strategy was explicitly set, so that otherwise the default tagging secret - // strategy is exercised. - resolveTaggingSecretStrategy: taggingSecretStrategy ? () => Promise.resolve(taggingSecretStrategy) : undefined, + resolveTaggingSecretStrategy: makeResolveTaggingSecretStrategyHook(this.taggingSecretStrategies), }), transientArrayService, }); @@ -972,9 +987,19 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } } - close(): [bigint, Map, TaggingSecretStrategy | undefined] { + close(): { + nextBlockTimestamp: bigint; + authwits: Map; + taggingSecretStrategies: TXETaggingSecretStrategies; + authorizeAllUtilityCallTargets: boolean; + } { this.logger.debug('Exiting Top Level Context'); - return [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategy]; + return { + nextBlockTimestamp: this.nextBlockTimestamp, + authwits: this.authwits, + taggingSecretStrategies: this.taggingSecretStrategies, + authorizeAllUtilityCallTargets: this.authorizeAllUtilityCallTargets, + }; } private async getLastBlockNumber(): Promise { @@ -986,6 +1011,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl callerContext: 'private' | 'private view' | 'utility', authorizedTargets: AztecAddress[], ): ExecutionHooks['authorizeUtilityCall'] | undefined { + if (this.authorizeAllUtilityCallTargets) { + return authorizeAllUtilityCallsHook; + } if (authorizedTargets.length === 0) { return undefined; } @@ -995,3 +1023,11 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl }); } } + +/** + * An `authorizeUtilityCall` hook that authorizes every cross-contract utility call. + * + * Backs the `aztec_txe_setAuthorizeAllUtilityCallTargets` oracle. + */ +export const authorizeAllUtilityCallsHook: NonNullable = () => + Promise.resolve({ authorized: true }); diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index 5768821d902e..95d98d84fdad 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -5,13 +5,13 @@ * * The Noir counterparts are in `noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr`. */ -export const TXE_ORACLE_VERSION_MAJOR = 3; -export const TXE_ORACLE_VERSION_MINOR = 0; +export const TXE_ORACLE_VERSION_MAJOR = 4; +export const TXE_ORACLE_VERSION_MINOR = 1; /** - * This hash is computed from the TXE oracle interfaces (IAvmExecutionOracle and ITxeExecutionOracle) and is used to + * This hash is computed from the TXE-specific entries in `TXE_ORACLE_REGISTRY` and is used to * detect when those interfaces change. When it does, bump: * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '50bd2ce5971fb29adb6484318aa5ce8bb1a0c9cde10b554cd544c513f814c9ca'; +export const TXE_ORACLE_INTERFACE_HASH = '1a6c578d0a47cd8dd7dae07fd00abe781225a73920c596d873afcc061c0e4284'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 22712c254e16..d3f854e3bf67 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -221,11 +221,21 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - aztec_txe_setTaggingSecretStrategy(...inputs: ForeignCallArgs) { + aztec_txe_setTaggingSecretStrategies(...inputs: ForeignCallArgs) { return callTxeHandler({ - oracle: 'aztec_txe_setTaggingSecretStrategy', + oracle: 'aztec_txe_setTaggingSecretStrategies', inputs, - handler: ([strategy]) => this.handlerAsTxe().setTaggingSecretStrategy(strategy), + handler: ([unconstrainedStrategy, constrainedStrategy]) => + this.handlerAsTxe().setTaggingSecretStrategies(unconstrainedStrategy, constrainedStrategy), + }); + } + + // eslint-disable-next-line camelcase + aztec_txe_setAuthorizeAllUtilityCallTargets(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_txe_setAuthorizeAllUtilityCallTargets', + inputs, + handler: ([authorizeAll]) => this.handlerAsTxe().setAuthorizeAllUtilityCallTargets(authorizeAll), }); } @@ -571,6 +581,16 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_utl_areBlockHashesInArchive(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_utl_areBlockHashesInArchive', + inputs, + handler: ([anchorBlockHash, blockHashes]) => + this.handlerAsUtility().areBlockHashesInArchive(anchorBlockHash, blockHashes), + }); + } + // eslint-disable-next-line camelcase aztec_utl_getLowNullifierMembershipWitness(...inputs: ForeignCallArgs) { return callTxeHandler({ @@ -622,6 +642,15 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_utl_getTxEffects(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_utl_getTxEffects', + inputs, + handler: ([txHashes]) => this.handlerAsUtility().getTxEffects(txHashes), + }); + } + // eslint-disable-next-line camelcase aztec_utl_setCapsule(...inputs: ForeignCallArgs) { return callTxeHandler({ diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 77f2dcb045ed..ca027ecbaa17 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -20,7 +20,6 @@ import { RecipientTaggingStore, SenderTaggingStore, TaggingSecretSourcesStore, - type TaggingSecretStrategy, composeHooks, } from '@aztec/pxe/server'; import { @@ -58,9 +57,13 @@ import { z } from 'zod'; import { DEFAULT_ADDRESS, MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY, MAX_OFFCHAIN_EFFECT_LEN } from './constants.js'; import type { IAvmExecutionOracle, ITxeExecutionOracle } from './oracle/interfaces.js'; +import { + type TXETaggingSecretStrategies, + makeResolveTaggingSecretStrategyHook, +} from './oracle/tagging_secret_strategy.js'; import { TXEOraclePublicContext } from './oracle/txe_oracle_public_context.js'; import { callTxeLegacyHandler } from './oracle/txe_oracle_registry.js'; -import { TXEOracleTopLevelContext } from './oracle/txe_oracle_top_level_context.js'; +import { TXEOracleTopLevelContext, authorizeAllUtilityCallsHook } from './oracle/txe_oracle_top_level_context.js'; import { TXE_ORACLE_VERSION_MAJOR, TXE_ORACLE_VERSION_MINOR } from './oracle/txe_oracle_version.js'; import { TXEPrivateExecutionOracle } from './oracle/txe_private_execution_oracle.js'; import { RPCTranslator, UnavailableOracleError } from './rpc_translator.js'; @@ -237,7 +240,8 @@ function emptyLastCallState(): LastCallState { export class TXESession implements TXESessionStateHandler { private state: SessionState = { name: 'TOP_LEVEL' }; private authwits: Map = new Map(); - private taggingSecretStrategy: TaggingSecretStrategy | undefined = undefined; + private taggingSecretStrategies: TXETaggingSecretStrategies = new Map(); + private authorizeAllUtilityCallTargets = false; private lastCallInfo: LastCallState = emptyLastCallState(); private txeOracleVersion: { major: number; minor: number } | undefined; @@ -365,7 +369,8 @@ export class TXESession implements TXESessionStateHandler { version, chainId, new Map(), - undefined, + new Map(), + false, // authorizeAllUtilityCallTargets artifactResolver, rootPath, packageName, @@ -695,7 +700,8 @@ export class TXESession implements TXESessionStateHandler { this.version, this.chainId, this.authwits, - this.taggingSecretStrategy, + this.taggingSecretStrategies, + this.authorizeAllUtilityCallTargets, this.artifactResolver, this.rootPath, this.packageName, @@ -745,7 +751,6 @@ export class TXESession implements TXESessionStateHandler { this.stateMachine.contractClassService, anchorBlock!, ); - const taggingSecretStrategy = this.taggingSecretStrategy; this.oracleHandler = new TXEPrivateExecutionOracle({ argsHash: Fr.ZERO, txContext: new TxContext(this.chainId, this.version, gasSettings), @@ -776,7 +781,8 @@ export class TXESession implements TXESessionStateHandler { txResolver: this.stateMachine.txResolver, simulator: new WASMSimulator(), hooks: composeHooks({ - resolveTaggingSecretStrategy: taggingSecretStrategy ? () => Promise.resolve(taggingSecretStrategy) : undefined, + resolveTaggingSecretStrategy: makeResolveTaggingSecretStrategyHook(this.taggingSecretStrategies), + authorizeUtilityCall: this.authorizeAllUtilityCallTargets ? authorizeAllUtilityCallsHook : undefined, }), transientArrayService, }); @@ -877,6 +883,9 @@ export class TXESession implements TXESessionStateHandler { scopes: await this.keyStore.getAccounts(), simulator: new WASMSimulator(), utilityExecutor: this.utilityExecutorForContractSync(anchorBlockHeader), + hooks: composeHooks({ + authorizeUtilityCall: this.authorizeAllUtilityCallTargets ? authorizeAllUtilityCallsHook : undefined, + }), // Execution-tree root (top-level utility run): own store; nested frames inherit it. transientArrayService: new TransientArrayService(), }); @@ -899,15 +908,18 @@ export class TXESession implements TXESessionStateHandler { // accounts to PXE (via `addAccount`), etc. This is a slight inconsistency in the working model of this class, but // is not too bad. The `close` call below therefore only hands back the session-scoped values that a test // sets directly at the top level, outside any contract execution (e.g. via `advanceTimestampBy`, - // `addAuthWitness`, `setTaggingSecretStrategy`). The oracle handler is discarded on every state transition, + // `addAuthWitness`, `setTaggingSecretStrategies`). The oracle handler is discarded on every state transition, // so the session must seed these values into the contexts it creates later. // TODO: persisting authwits this way is quite unfortunate: they create a temporary utility context that would // otherwise reset them, so we'd not be able to pass more than one per execution. Ideally authwits would be passed // alongside a contract call instead of pre-seeded. - [this.nextBlockTimestamp, this.authwits, this.taggingSecretStrategy] = ( - this.oracleHandler as TXEOracleTopLevelContext - ).close(); + ({ + nextBlockTimestamp: this.nextBlockTimestamp, + authwits: this.authwits, + taggingSecretStrategies: this.taggingSecretStrategies, + authorizeAllUtilityCallTargets: this.authorizeAllUtilityCallTargets, + } = (this.oracleHandler as TXEOracleTopLevelContext).close()); } private async exitPrivateState() { diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 8ddf690bbb3e..7d1e942b2c41 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -125,8 +125,15 @@ export abstract class BaseWallet implements Wallet { protected log = createLogger('wallet-sdk:base_wallet'), ) {} - protected scopesFrom(from: AztecAddress | NoFrom, additionalScopes: AztecAddress[] = []): AztecAddress[] { - const allScopes = from === NO_FROM ? additionalScopes : [from, ...additionalScopes]; + protected scopesFrom( + from: AztecAddress | NoFrom, + additionalScopes: AztecAddress[], + sendMessagesAs: AztecAddress | undefined, + ): AztecAddress[] { + // The sendMessagesAs account must be in scope so that its tagging secrets can be accessed. + const tagSenderScopes = sendMessagesAs ? [sendMessagesAs] : []; + const baseScopes = from === NO_FROM ? [] : [from]; + const allScopes = [...baseScopes, ...additionalScopes, ...tagSenderScopes]; const scopeSet = new Set(allScopes.map(address => address.toString())); return [...scopeSet].map(AztecAddress.fromStringUnsafe); } @@ -402,7 +409,7 @@ export abstract class BaseWallet implements Wallet { simulatePublic: true, skipTxValidation: opts.skipTxValidation, skipFeeEnforcement: opts.skipFeeEnforcement, - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), overrides: opts.overrides, }); @@ -498,7 +505,7 @@ export abstract class BaseWallet implements Wallet { return this.pxe.profileTx(txRequest, { profileMode: opts.profileMode, skipProofGeneration: opts.skipProofGeneration ?? true, - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); } @@ -515,7 +522,7 @@ export abstract class BaseWallet implements Wallet { }); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); const provenTx = await this.pxe.proveTx(txRequest, { - scopes: this.scopesFrom(opts.from, opts.additionalScopes), + scopes: this.scopesFrom(opts.from, opts.additionalScopes ?? [], opts.sendMessagesAs), senderForTags: this.senderForTagsFrom(opts.from, opts.sendMessagesAs), }); const offchainOutput = extractOffchainOutput( diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.test.ts b/yarn-project/wallets/src/embedded/embedded_wallet.test.ts index e91818763460..cea4feec1f14 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.test.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.test.ts @@ -42,7 +42,7 @@ describe('EmbeddedWallet', () => { }); describe('sendTx', () => { - it('passes sendMessagesAs as senderForTags to PXE simulation', async () => { + it('passes sendMessagesAs as senderForTags and includes it in scopes for PXE simulation', async () => { getPredictedMinFees.mockResolvedValue([new GasFees(2, 2)]); getNodeInfo.mockResolvedValue({ l1ChainId: 1, @@ -69,7 +69,10 @@ describe('EmbeddedWallet', () => { expect(simulateTx).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ senderForTags: sendMessagesAs }), + expect.objectContaining({ + senderForTags: sendMessagesAs, + scopes: expect.arrayContaining([sendMessagesAs]), + }), ); }); diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.ts b/yarn-project/wallets/src/embedded/embedded_wallet.ts index 7616c047c59e..bbb83cef7546 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.ts @@ -330,7 +330,7 @@ export class EmbeddedWallet extends BaseWallet { opts: SimulateViaEntrypointOptions, ): Promise { const { from, feeOptions, additionalScopes, skipTxValidation, skipFeeEnforcement, sendMessagesAs } = opts; - const scopes = this.scopesFrom(from, additionalScopes); + const scopes = this.scopesFrom(from, additionalScopes ?? [], sendMessagesAs); const feeExecutionPayload = await feeOptions.walletFeePaymentMethod?.getExecutionPayload(); const finalExecutionPayload = feeExecutionPayload