diff --git a/aztec-up/bin/0.0.1/aztec-install b/aztec-up/bin/0.0.1/aztec-install index 522dfebe979c..6d0321d49b71 100755 --- a/aztec-up/bin/0.0.1/aztec-install +++ b/aztec-up/bin/0.0.1/aztec-install @@ -183,10 +183,13 @@ function install_aztec_up { # Updates appropriate shell script to ensure the paths are in PATH. function update_path_env_var { - # We need both: - # - $AZTEC_HOME/current/bin and $AZTEC_HOME/current/node_modules/.bin for version-specific tools + # We need: + # - $AZTEC_HOME/current/bin for version-specific tools (nargo, aztec, bb, ...) # - $AZTEC_HOME/bin for shared tools like aztec-up - local path_line="export PATH=\"\$HOME/.aztec/current/bin:\$HOME/.aztec/current/node_modules/.bin:\$HOME/.aztec/bin:\$PATH\"" + # - $AZTEC_HOME/current/node_modules/.bin is intentionally NOT added: the per-version installer + # symlinks the @aztec-owned bins into current/bin, so adding node_modules/.bin + # would only expose transitive npm deps (jest, tsc, ...) and shadow user tools. + local path_line="export PATH=\"\$HOME/.aztec/current/bin:\$HOME/.aztec/bin:\$PATH\"" # Determine the user's shell. local shell_profile="" diff --git a/aztec-up/bin/0.0.1/aztec-up b/aztec-up/bin/0.0.1/aztec-up index 8be3fcb0ea59..3cf7420ba85e 100755 --- a/aztec-up/bin/0.0.1/aztec-up +++ b/aztec-up/bin/0.0.1/aztec-up @@ -549,8 +549,10 @@ function cmd_env { local version_dir="$AZTEC_HOME/versions/$resolved_version" - # Output PATH export with relevant bin directories - echo "export PATH=\"$version_dir/bin:$version_dir/node_modules/.bin:\$PATH\"" + # Output PATH export with relevant bin directories. + # node_modules/.bin is intentionally excluded -- the per-version installer + # symlinks @aztec-owned bins into bin/, keeping transitive npm deps off PATH. + echo "export PATH=\"$version_dir/bin:\$PATH\"" } # Main entry point diff --git a/aztec-up/bin/0.0.1/install b/aztec-up/bin/0.0.1/install index 75c24f27456d..a665f9428b25 100755 --- a/aztec-up/bin/0.0.1/install +++ b/aztec-up/bin/0.0.1/install @@ -197,6 +197,29 @@ function install_aztec_packages { npm install @aztec/aztec@$VERSION @aztec/cli-wallet@$VERSION @aztec/bb.js@$VERSION --prefix "$version_path" } +function symlink_aztec_bins { + set -euo pipefail + # Populate version_bin_path with symlinks to @aztec-owned bins only. Adding + # node_modules/.bin wholesale to PATH would shadow user-installed tools + # (jest, tsc, semver, ...) with Aztec's transitive dependencies. + local npm_bin_dir="$version_path/node_modules/.bin" + [ -d "$npm_bin_dir" ] || return 0 + + local bin_link bin_name target + for bin_link in "$npm_bin_dir"/*; do + [ -L "$bin_link" ] || continue + target=$(readlink "$bin_link") + # npm writes relative symlinks like ../@aztec/aztec/... for scoped packages. + [[ "$target" == ../@aztec/* ]] || continue + bin_name=$(basename "$bin_link") + if [ -e "$version_bin_path/$bin_name" ] && [ ! -L "$version_bin_path/$bin_name" ]; then + echo_yellow "refusing to overwrite non-symlink $bin_name; @aztec package bin collides with a hand-installed toolchain binary" >&2 + exit 1 + fi + ln -sfn "../node_modules/.bin/$bin_name" "$version_bin_path/$bin_name" + done +} + function main { # Create version directory mkdir -p "$version_bin_path" @@ -228,6 +251,11 @@ function main { echo -n "Installing aztec packages... " dump_fail retry install_aztec_packages echo_green "done." + + # Expose only @aztec-owned bins on PATH (drops transitive npm deps). + echo -n "Making aztec commands available... " + dump_fail retry symlink_aztec_bins + echo_green "done." } main "$@" diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index e531a2764e93..91c04d5da4f1 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,24 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [CLI] `aztec-up` no longer exposes transitive npm bins on PATH + +The `aztec-up` installer used to add `$HOME/.aztec/current/node_modules/.bin` to your shell `PATH`, which put ~40 transitive npm bins (`jest`, `tsc`, `tsserver`, `semver`, `uuid`, `json5`, ...) onto your interactive shell and silently shadowed your own installed versions of those tools. Only the seven `@aztec/*`-owned bins (`aztec`, `aztec-wallet`, `bb`, `bb-cli`, `blob-client`, `noir-codegen`, `txe`) are now exposed. + +If you had an Aztec version installed before this release, your shell profile (`~/.bashrc` or `~/.zshrc`) still contains the old `PATH` line. Re-run the installer once (replacing `[VERSION]` with whichever toolchain version you're on, e.g. `4.2.0`) to replace it: + +```bash +VERSION=[VERSION] bash -i <(curl -sL https://install.aztec.network) +``` + +Open a fresh shell and confirm the leak is gone: + +```bash +echo $PATH +``` + +`$HOME/.aztec/current/node_modules/.bin` should no longer appear in the output. You'll also see your own `jest`, `tsc`, etc. again instead of the ones bundled with the Aztec toolchain. + ### [Aztec.nr] `emit_private_log_unsafe` / `emit_raw_note_log_unsafe` are deprecated `emit_private_log_unsafe` and `emit_raw_note_log_unsafe` are deprecated and will be removed in a future release. Migrate to the new `emit_private_log_vec_unsafe` / `emit_raw_note_log_vec_unsafe` functions, which take a `BoundedVec` instead of the `(log: [Field; PRIVATE_LOG_CIPHERTEXT_LEN], length: u32)` pair. diff --git a/noir-projects/aztec-nr/aztec/src/macros/aztec.nr b/noir-projects/aztec-nr/aztec/src/macros/aztec.nr index 932aeb3c93ea..1c5282b43844 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/aztec.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/aztec.nr @@ -9,6 +9,10 @@ use crate::{ dispatch::generate_public_dispatch, emit_public_init_nullifier::generate_emit_public_init_nullifier, internals_functions_generation::{create_fn_abi_exports, process_functions}, + offchain_receive::{ + OFFCHAIN_RECEIVE_FN_NAME, OFFCHAIN_RECEIVE_PARAM_NAME, offchain_receive_param_type, + OFFCHAIN_RECEIVE_RETURN_TYPE, + }, storage::STORAGE_LAYOUT_NAME, utils::{is_fn_contract_library_method, is_fn_external, is_fn_internal, is_fn_test, module_has_storage}, }, @@ -249,17 +253,18 @@ comptime fn generate_sync_state(process_custom_message_option: Quoted, offchain_ /// /// For more details, see `aztec::messages::processing::offchain::receive`. comptime fn generate_offchain_receive() -> Quoted { + let param_type = offchain_receive_param_type(quote { aztec }); + let parameters_struct_name = f"{OFFCHAIN_RECEIVE_FN_NAME}_parameters".quoted_contents(); + let abi_struct_name = f"{OFFCHAIN_RECEIVE_FN_NAME}_abi".quoted_contents(); + quote { - pub struct offchain_receive_parameters { - pub messages: BoundedVec< - aztec::messages::processing::offchain::OffchainMessage, - aztec::messages::processing::offchain::MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL, - >, + pub struct $parameters_struct_name { + pub $OFFCHAIN_RECEIVE_PARAM_NAME: $param_type, } #[abi(functions)] - pub struct offchain_receive_abi { - parameters: offchain_receive_parameters, + pub struct $abi_struct_name { + parameters: $parameters_struct_name, } /// Receives offchain messages into this contract's offchain inbox for subsequent processing. @@ -270,14 +275,9 @@ comptime fn generate_offchain_receive() -> Quoted { /// /// This function is automatically injected by the `#[aztec]` macro. #[aztec::macros::internals_functions_generation::abi_attributes::abi_utility] - unconstrained fn offchain_receive( - messages: BoundedVec< - aztec::messages::processing::offchain::OffchainMessage, - aztec::messages::processing::offchain::MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL, - >, - ) { + unconstrained fn $OFFCHAIN_RECEIVE_FN_NAME($OFFCHAIN_RECEIVE_PARAM_NAME: $param_type) -> $OFFCHAIN_RECEIVE_RETURN_TYPE { let address = aztec::context::UtilityContext::new().this_address(); - aztec::messages::processing::offchain::receive(address, messages); + aztec::messages::processing::offchain::receive(address, $OFFCHAIN_RECEIVE_PARAM_NAME); } } } diff --git a/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions.nr b/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions.nr index 921ef6a23401..6cf2c4e2a5db 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions.nr @@ -3,8 +3,13 @@ use crate::macros::{ create_private_self_call_stub, create_private_static_stub, create_private_stub, create_public_self_call_static_stub, create_public_self_call_stub, create_public_self_enqueue_static_stub, create_public_self_enqueue_stub, create_public_static_stub, create_public_stub, create_utility_stub, + create_utility_stub_from_parts, }, internals_functions_generation::external_functions_registry, + offchain_receive::{ + OFFCHAIN_RECEIVE_FN_NAME, OFFCHAIN_RECEIVE_PARAM_NAME, offchain_receive_param_type, + OFFCHAIN_RECEIVE_RETURN_TYPE, + }, utils::is_fn_view, }; @@ -37,7 +42,12 @@ pub(crate) comptime fn generate_external_function_calls(m: Module) -> Quoted { }) .join(quote {}); - let utility_contract_methods = utility_functions.map(|function| create_utility_stub(function)).join(quote {}); + // `offchain_receive` is injected into every contract by `#[aztec]` as quoted code rather than via the + // `#[external("utility")]` attribute that populates `UTILITY_REGISTRY`, so it doesn't appear in + // `utility_functions`. We append its stub so it shows up in the interface like any other utility. + let utility_stubs = + utility_functions.map(|function| create_utility_stub(function)).push_back(create_offchain_receive_stub()); + let utility_contract_methods = utility_stubs.join(quote {}); quote { $private_contract_methods @@ -46,6 +56,16 @@ pub(crate) comptime fn generate_external_function_calls(m: Module) -> Quoted { } } +/// Builds the interface stub for the macro-injected `offchain_receive` utility. Pulls the signature +/// from `crate::macros::offchain_receive` so it stays in lockstep with `generate_offchain_receive`. +comptime fn create_offchain_receive_stub() -> Quoted { + let param_type = offchain_receive_param_type(quote { crate }).as_type(); + let fn_parameters = @[(OFFCHAIN_RECEIVE_PARAM_NAME, param_type)]; + let fn_return_type = OFFCHAIN_RECEIVE_RETURN_TYPE.as_type(); + + create_utility_stub_from_parts(OFFCHAIN_RECEIVE_FN_NAME, fn_parameters, fn_return_type) +} + /// Generates helper structs for convenient self-invocation of contract functions: /// /// - `CallSelf`: Call your own private or public functions, e.g.: `self.call_self.some_private_function(args)` diff --git a/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions_stubs.nr b/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions_stubs.nr index 513ea3593ba8..d3d43ce6b38b 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions_stubs.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/calls_generation/external_functions_stubs.nr @@ -28,8 +28,17 @@ comptime fn create_stub_base(f: FunctionDefinition) -> (Quoted, Quoted, Quoted, // Unfortunately, the usage of macros makes it a bit of a black box. To actually view the target function, you // could instead command+click on `MyImportedContract`, or you can just manually search it. If you want to view the // noir code that gets generated by this macro, you can use `nargo expand` on your contract. - let fn_name = f.name(); - let fn_parameters = f.parameters(); + create_stub_base_from_parts(f.name(), f.parameters()) +} + +/// Variant of `create_stub_base` that takes raw `(name, parameters)` instead of a `FunctionDefinition`. +/// +/// This lets macro-injected functions (e.g. `offchain_receive`) reuse the stub-generation machinery without needing a +/// `FunctionDefinition`. +pub(crate) comptime fn create_stub_base_from_parts( + fn_name: Quoted, + fn_parameters: [(Quoted, Type)], +) -> (Quoted, Quoted, Quoted, Quoted, u32, Quoted, u32, Field) { let fn_parameters_list = fn_parameters.map(|(name, typ): (Quoted, Type)| quote { $name: $typ }).join(quote {,}); let (serialized_args_array_construction, serialized_args_array_len_quote, serialized_args_array_name) = @@ -38,7 +47,7 @@ comptime fn create_stub_base(f: FunctionDefinition) -> (Quoted, Quoted, Quoted, let fn_name_str = f"\"{fn_name}\"".quoted_contents(); let fn_name_len: u32 = unquote!(quote { $fn_name_str.as_bytes().len()}); - let fn_selector: Field = compute_fn_selector(f.name(), f.parameters()); + let fn_selector: Field = compute_fn_selector(fn_name, fn_parameters); ( fn_name, fn_parameters_list, serialized_args_array_construction, serialized_args_array_name, @@ -123,9 +132,17 @@ pub(crate) comptime fn create_public_static_stub(f: FunctionDefinition) -> Quote } pub(crate) comptime fn create_utility_stub(f: FunctionDefinition) -> Quoted { + create_utility_stub_from_parts(f.name(), f.parameters(), f.return_type()) +} + +/// Variant of `create_utility_stub` that takes raw `(name, parameters, return_type)` instead of `FunctionDefinition`. +pub(crate) comptime fn create_utility_stub_from_parts( + fn_name: Quoted, + fn_parameters: [(Quoted, Type)], + fn_return_type: Type, +) -> Quoted { let (fn_name, fn_parameters_list, serialized_args_array_construction, serialized_args_array_name, serialized_args_array_len, fn_name_str, fn_name_len, fn_selector) = - create_stub_base(f); - let fn_return_type = f.return_type(); + create_stub_base_from_parts(fn_name, fn_parameters); quote { pub fn $fn_name(self, $fn_parameters_list) -> aztec::context::calls::UtilityCall<$fn_name_len, $serialized_args_array_len, $fn_return_type> { diff --git a/noir-projects/aztec-nr/aztec/src/macros/mod.nr b/noir-projects/aztec-nr/aztec/src/macros/mod.nr index 18c2e184da1a..ab2b33a4bbf1 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/mod.nr @@ -6,6 +6,7 @@ pub use aztec::{aztec, AztecConfig}; pub mod dispatch; pub(crate) mod calls_generation; pub(crate) mod emit_public_init_nullifier; +pub(crate) mod offchain_receive; pub mod internals_functions_generation; pub mod functions; pub mod utils; diff --git a/noir-projects/aztec-nr/aztec/src/macros/offchain_receive.nr b/noir-projects/aztec-nr/aztec/src/macros/offchain_receive.nr new file mode 100644 index 000000000000..a64fe078ec4e --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/macros/offchain_receive.nr @@ -0,0 +1,29 @@ +//! Shared signature for the macro-injected `offchain_receive` utility. +//! +//! `offchain_receive` lives in two places: it is injected into every contract by the `#[aztec]` +//! macro (see `generate_offchain_receive` in `macros/aztec.nr`), and its interface stub is +//! generated so other contracts can call it (see `create_offchain_receive_stub` in +//! `macros/calls_generation/external_functions.nr`). Both sites must agree on the function's +//! name, parameter name, parameter type, and return type, or the selectors they compute would +//! diverge and runtime calls would fail silently. +//! +//! To keep them in lockstep, both sites pull the signature components from here. + +/// Name of the injected utility function. +pub(crate) comptime global OFFCHAIN_RECEIVE_FN_NAME: Quoted = quote { offchain_receive }; + +/// Name of the single parameter. +pub(crate) comptime global OFFCHAIN_RECEIVE_PARAM_NAME: Quoted = quote { messages }; + +/// Return type of the function. +pub(crate) comptime global OFFCHAIN_RECEIVE_RETURN_TYPE: Quoted = quote { () }; + +/// The parameter type `BoundedVec`. +pub(crate) comptime fn offchain_receive_param_type(prefix: Quoted) -> Quoted { + quote { + BoundedVec< + $prefix::messages::processing::offchain::OffchainMessage, + $prefix::messages::processing::offchain::MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL, + > + } +} diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr index 6fa4078de2ec..066586b5955c 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr @@ -52,6 +52,7 @@ pub(crate) type OffchainInboxSync = unconstrained fn[Env]( /* contract_address */AztecAddress, /* scope */ AztecAddress) -> EphemeralArray; /// A message delivered via the `offchain_receive` utility function. +#[derive(Serialize, Deserialize)] pub struct OffchainMessage { /// The encrypted message payload. pub ciphertext: BoundedVec, 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 b2dcd79f3ea1..3d45d9e4ae71 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 @@ -1,7 +1,7 @@ use crate::protocol::{ abis::function_selector::FunctionSelector, address::AztecAddress, - traits::{Deserialize, Packable, Serialize}, + traits::{Deserialize, FromField, Packable, Serialize}, }; use crate::{ @@ -15,9 +15,10 @@ use crate::{ discovery::{ ComputeNoteHash, ComputeNoteNullifier, CustomMessageHandler, process_message::process_message_plaintext, }, - encoding::MESSAGE_PLAINTEXT_LEN, + encoding::{MESSAGE_CIPHERTEXT_LEN, MESSAGE_PLAINTEXT_LEN}, logs::{event::encode_private_event_message, note::encode_private_note_message}, - processing::{MessageContext, validate_and_store_enqueued_notes_and_events}, + offchain_messages::OFFCHAIN_MESSAGE_IDENTIFIER, + processing::{MessageContext, offchain::OffchainMessage, validate_and_store_enqueued_notes_and_events}, }, note::{note_interface::{NoteHash, NoteType}, NoteMessage}, oracle::version::assert_compatible_oracle_version, @@ -608,6 +609,63 @@ impl TestEnvironment { T::deserialize(serialized_return_values) } + /// Returns offchain messages emitted by the last top-level call into TXE. + /// + /// The returned `BoundedVec` can be passed directly to a contract's `offchain_receive` + /// utility function to ingest the messages into the recipient's private state. + /// + /// Each returned message is populated with: + /// - `ciphertext`, `recipient`: decoded from the raw emitted payload. + /// - `tx_hash`: `Option::some(hash)` for tx-producing entry points (`call_private`, + /// `call_public`); `Option::none()` for tx-less entry points (`execute_utility`, + /// `private_context`, `utility_context`). + /// - `anchor_block_timestamp`: the timestamp of the block the top-level call was + /// anchored against, matching the value the production flow would produce. + /// + /// Returns up to `MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY` messages. The caller is responsible for + /// splitting the result into batches that fit `MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL` before + /// forwarding each batch to `offchain_receive`. + /// + /// # Example + /// + /// ```noir + /// env.call_private(sender, Token::at(token).transfer_in_private_with_offchain_delivery(...)); + /// let messages = env.offchain_messages(); + /// let mut batch: BoundedVec<_, MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL> = BoundedVec::new(); + /// for i in 0..messages.len() { batch.push(messages.get(i)); } + /// env.execute_utility(Token::at(token).offchain_receive(batch)); + /// ``` + pub unconstrained fn offchain_messages( + _self: Self, + ) -> BoundedVec { + let raw_effects = txe_oracles::get_last_call_offchain_effects(); + let txe_oracles::TXECallContext { tx_hash, anchor_block_timestamp } = txe_oracles::get_last_call_context(); + + let mut messages: BoundedVec = + BoundedVec::new(); + + for i in 0..raw_effects.len() { + let payload = raw_effects.get(i); + + // The raw payload layout produced by `deliver_offchain_message` is: + // [OFFCHAIN_MESSAGE_IDENTIFIER, recipient, ...ciphertext] + // A call may also emit offchain effects that are *not* `OffchainMessage`s (e.g. authwit requests + // via `auth.nr::emit_offchain_effect`). We only concern ourselves with offchain messages here. + if payload.get(0) == OFFCHAIN_MESSAGE_IDENTIFIER { + let recipient = AztecAddress::from_field(payload.get(1)); + + let mut ciphertext: BoundedVec = BoundedVec::new(); + for j in 0..MESSAGE_CIPHERTEXT_LEN { + ciphertext.push(payload.get(2 + j)); + } + + messages.push(OffchainMessage { ciphertext, recipient, tx_hash, anchor_block_timestamp }); + } + } + + messages + } + /// Performs a public contract function call, including the processing of any nested public calls. Returns the /// result of the called function. /// 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 2b36c86ebae3..b92a8dbadfb7 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 @@ -1,4 +1,7 @@ -use crate::{context::inputs::PrivateContextInputs, event::EventSelector, test::helpers::utils::TestAccount}; +use crate::{ + context::inputs::PrivateContextInputs, event::EventSelector, messages::encoding::MESSAGE_CIPHERTEXT_LEN, + test::helpers::utils::TestAccount, +}; use crate::protocol::{ abis::function_selector::FunctionSelector, @@ -10,6 +13,13 @@ use crate::protocol::{ traits::{Deserialize, ToField}, }; +/// Upper bound on the number of raw offchain effects the TXE test environment will surface +/// to Noir in a single `get_last_call_offchain_effects` query. +pub(crate) global MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY: u32 = 64; + +/// Maximum number of fields in a single raw offchain effect payload. +pub(crate) global MAX_OFFCHAIN_EFFECT_LEN: u32 = 2 + MESSAGE_CIPHERTEXT_LEN; + global MAX_PRIVATE_EVENTS_PER_TXE_QUERY: u32 = 5; global MAX_EVENT_SERIALIZATION_LENGTH: u32 = 10; @@ -77,6 +87,38 @@ pub unconstrained fn get_last_block_timestamp() -> u64 {} #[oracle(aztec_txe_getLastTxEffects)] pub unconstrained fn get_last_tx_effects() -> (Field, BoundedVec, BoundedVec) {} +/// Returns the raw offchain effect payloads emitted by the last top-level call into TXE. +/// Each effect is a variable-length field array (e.g. offchain messages have a different size +/// than authwit authorization requests). +pub unconstrained fn get_last_call_offchain_effects() -> BoundedVec, MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY> { + let (raw_storage, effect_lengths, count) = get_last_call_offchain_effects_oracle(); + + let mut effects = BoundedVec::new(); + for i in 0..count { + effects.push(BoundedVec::from_parts(raw_storage[i], effect_lengths[i])); + } + effects +} + +#[oracle(aztec_txe_getLastCallOffchainEffects)] +unconstrained fn get_last_call_offchain_effects_oracle() -> ([[Field; MAX_OFFCHAIN_EFFECT_LEN]; MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY], [u32; MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY], u32) {} + +/// The context of the last top-level call into TXE, as captured by the call executor. Refreshed on +/// every top-level call. +pub struct TXECallContext { + /// Tx hash of the top-level call, or `None` if the call was tx-less (`execute_utility`, context + /// setters, etc.). + pub tx_hash: Option, + /// Anchor block timestamp captured at the *start* of the call. Note: this is not the timestamp + /// of the block where a transaction produced by this call is included, which does not exist at + /// the moment TXE runs the call. + pub anchor_block_timestamp: u64, +} + +/// Returns the context of the last top-level call into TXE. See [`TXECallContext`]. +#[oracle(aztec_txe_getLastCallContext)] +pub unconstrained fn get_last_call_context() -> TXECallContext {} + #[oracle(aztec_txe_getDefaultAddress)] pub unconstrained fn get_default_address() -> AztecAddress {} diff --git a/noir-projects/noir-contracts/contracts/app/token_contract/src/main.nr b/noir-projects/noir-contracts/contracts/app/token_contract/src/main.nr index 4c17ecf9a6f7..f68bc1fc0e3a 100644 --- a/noir-projects/noir-contracts/contracts/app/token_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/app/token_contract/src/main.nr @@ -308,6 +308,29 @@ pub contract Token { } // docs:end:transfer_in_private + // docs:start:transfer_in_private_with_offchain_delivery + /// Mirrors `transfer_in_private` but delivers the resulting notes via offchain messages. + /// + /// Offchain messages are returned to the caller as encrypted payloads. The sender is responsible for getting the + /// recipient's note to them (typically by encoding it into a link, QR code, or direct message). The recipient + /// ingests it by calling `offchain_receive` on their environment. + /// + /// A `Transfer` event is also emitted to be delivered offchain. + #[authorize_once("from", "authwit_nonce")] + #[external("private")] + fn transfer_in_private_with_offchain_delivery( + from: AztecAddress, + to: AztecAddress, + amount: u128, + authwit_nonce: Field, + ) { + self.storage.balances.at(from).sub(amount).deliver(MessageDelivery.OFFCHAIN); + self.storage.balances.at(to).add(amount).deliver(MessageDelivery.OFFCHAIN); + + self.emit(Transfer { from, to, amount }).deliver_to(to, MessageDelivery.OFFCHAIN); + } + // docs:end:transfer_in_private_with_offchain_delivery + #[authorize_once("from", "authwit_nonce")] #[external("private")] fn burn_private(from: AztecAddress, amount: u128, authwit_nonce: Field) { diff --git a/noir-projects/noir-contracts/contracts/app/token_contract/src/test.nr b/noir-projects/noir-contracts/contracts/app/token_contract/src/test.nr index 6efa4a085443..6e183e563ac9 100644 --- a/noir-projects/noir-contracts/contracts/app/token_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/app/token_contract/src/test.nr @@ -5,6 +5,7 @@ pub mod mint_to_public; pub mod reading_constants; pub mod transfer; pub mod transfer_in_private; +pub mod transfer_in_private_with_offchain_delivery; pub mod transfer_in_public; pub mod transfer_to_private; pub mod transfer_to_public_and_prepare_private_balance_increase; diff --git a/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr b/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr new file mode 100644 index 000000000000..ca28a5de4178 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/token_contract/src/test/transfer_in_private_with_offchain_delivery.nr @@ -0,0 +1,102 @@ +use crate::test::utils; +use crate::Token; +use aztec::test::helpers::authwit::add_private_authwit_from_call; +use generic_proxy_contract::GenericProxy; + +#[test] +unconstrained fn transfer_in_private_with_offchain_delivery_updates_both_balances() { + let (env, token_contract_address, owner, recipient, mint_amount) = + utils::setup_and_mint_to_private(/* with_account_contracts */ false); + + let transfer_amount = 1000 as u128; + let transfer_call = Token::at(token_contract_address) + .transfer_in_private_with_offchain_delivery(owner, recipient, transfer_amount, 0); + + // Transfer tokens. The offchain-delivered notes (change note for the sender, balance note for the recipient, + // Transfer event) end up in TXE's offchain effect buffer. + env.call_private(owner, transfer_call); + + // `offchain_messages()` returns up to `MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY` (64), but `offchain_receive` takes at + // most `MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL` (16). This test only produces a handful of messages, so a single + // batch suffices. + let messages = env.offchain_messages(); + let mut batch + : BoundedVec<_, aztec::messages::processing::offchain::MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL> = + BoundedVec::new(); + for i in 0..messages.len() { + batch.push(messages.get(i)); + } + env.execute_utility(Token::at(token_contract_address).offchain_receive(batch)); + + utils::check_private_balance( + env, + token_contract_address, + owner, + mint_amount - transfer_amount, + ); + utils::check_private_balance(env, token_contract_address, recipient, transfer_amount); +} + +#[test(should_fail_with = "Assertion failed: Invalid authwit nonce. When 'from' and 'msg_sender' are the same, 'authwit_nonce' must be zero")] +unconstrained fn transfer_in_private_with_offchain_delivery_failure_on_behalf_of_self_non_zero_nonce() { + let (env, token_contract_address, owner, recipient, _) = + utils::setup_and_mint_to_private(/* with_account_contracts */ false); + let transfer_amount = 1000 as u128; + let transfer_call = Token::at(token_contract_address) + .transfer_in_private_with_offchain_delivery(owner, recipient, transfer_amount, 1); + add_private_authwit_from_call(env, owner, recipient, transfer_call); + env.call_private(owner, transfer_call); +} + +#[test(should_fail_with = "Balance too low")] +unconstrained fn transfer_in_private_with_offchain_delivery_failure_on_behalf_of_more_than_balance() { + let (env, token_contract_address, owner, recipient, mint_amount, proxy) = + utils::setup_and_mint_to_private_with_proxy(); + let transfer_amount = mint_amount + (1 as u128); + let transfer_call = Token::at(token_contract_address) + .transfer_in_private_with_offchain_delivery(owner, recipient, transfer_amount, 1); + add_private_authwit_from_call(env, owner, proxy, transfer_call); + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); +} + +#[test(should_fail_with = "Unknown auth witness for message hash")] +unconstrained fn transfer_in_private_with_offchain_delivery_failure_on_behalf_of_other_without_approval() { + let (env, token_contract_address, owner, recipient, _, proxy) = + utils::setup_and_mint_to_private_with_proxy(); + let transfer_amount = 1000 as u128; + let transfer_call = Token::at(token_contract_address) + .transfer_in_private_with_offchain_delivery(owner, recipient, transfer_amount, 1); + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); +} + +#[test(should_fail_with = "Unknown auth witness for message hash")] +unconstrained fn transfer_in_private_with_offchain_delivery_failure_on_behalf_of_other_wrong_caller() { + let (env, token_contract_address, owner, recipient, _, proxy) = + utils::setup_and_mint_to_private_with_proxy(); + let transfer_amount = 1000 as u128; + let transfer_call = Token::at(token_contract_address) + .transfer_in_private_with_offchain_delivery(owner, recipient, transfer_amount, 1); + add_private_authwit_from_call(env, owner, owner, transfer_call); + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); +} diff --git a/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/main.nr index 13d0bd3d9412..f244eba56c97 100644 --- a/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/main.nr @@ -1,11 +1,16 @@ +mod test; + use aztec::macros::aztec; /// This contract is used to test that emitting offchain effects works correctly. #[aztec] contract OffchainEffect { use aztec::{ - macros::{events::event, functions::external, storage::storage}, - messages::message_delivery::MessageDelivery, + macros::{events::event, functions::{external, view}, storage::storage}, + messages::{ + encoding::MESSAGE_CIPHERTEXT_LEN, message_delivery::MessageDelivery, + offchain_messages::deliver_offchain_message, + }, note::note_viewer_options::NoteViewerOptions, oracle::offchain_effect::emit_offchain_effect, protocol::{address::AztecAddress, traits::Serialize}, @@ -63,6 +68,27 @@ contract OffchainEffect { ); } + /// Emits a single offchain message in a regular (non-static) private call. The matching + /// view function below lets tests contrast the two call shapes. + #[external("private")] + fn call_deliver_offchain_message( + ciphertext: [Field; MESSAGE_CIPHERTEXT_LEN], + recipient: AztecAddress, + ) { + deliver_offchain_message(ciphertext, recipient); + } + + /// Static variant of `call_deliver_offchain_message`. Used to verify that TXE correctly captures and exposes the call context + /// of offchain effects emitted during a static call. + #[external("private")] + #[view] + fn view_deliver_offchain_message( + ciphertext: [Field; MESSAGE_CIPHERTEXT_LEN], + recipient: AztecAddress, + ) { + deliver_offchain_message(ciphertext, recipient); + } + #[external("utility")] unconstrained fn emitting_offchain_effect_from_utility_reverts() { emit_offchain_effect([0; 5]); diff --git a/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/test.nr new file mode 100644 index 000000000000..4ed4b4d4ac53 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/test/offchain_effect_contract/src/test.nr @@ -0,0 +1,76 @@ +use crate::OffchainEffect; + +use aztec::{ + messages::{encoding::MESSAGE_CIPHERTEXT_LEN, offchain_messages::deliver_offchain_message}, + protocol::address::AztecAddress, + test::helpers::test_environment::{PrivateContextOptions, TestEnvironment}, +}; + +unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress) { + let mut env = TestEnvironment::new(); + let caller = env.create_light_account(); + let contract_address = env.deploy("OffchainEffect").without_initializer(); + (env, contract_address, caller) +} + +unconstrained fn dummy_ciphertext() -> [Field; MESSAGE_CIPHERTEXT_LEN] { + let mut c = [0; MESSAGE_CIPHERTEXT_LEN]; + c[0] = 42; + c +} + +/// This test might feel a bit ad-hoc. It really aims at verifying that TXE correctly simulates the call context of +/// offchain effects when they are emitted by static calls. +#[test] +unconstrained fn static_call_offchain_message_has_no_tx_hash() { + let (env, contract_address, caller) = setup(); + let ciphertext = dummy_ciphertext(); + + // Baseline: a non-static call that mines a block. This ensures TXE world state captures a real tx hash. + env.call_private( + caller, + OffchainEffect::at(contract_address).call_deliver_offchain_message(ciphertext, caller), + ); + let baseline = env.offchain_messages(); + assert(baseline.len() == 1); + assert(baseline.get(0).tx_hash.is_some()); + + env.view_private( + caller, + OffchainEffect::at(contract_address).view_deliver_offchain_message(ciphertext, caller), + ); + let view_messages = env.offchain_messages(); + assert(view_messages.len() == 1); + + // This is the key assertion of this test: we want to make sure that TXE properly resets old state when a static + // view is run after a regular private call. Note `tx_hash` was *some value* before the static call, and now is + // none. + assert(view_messages.get(0).tx_hash.is_none()); +} + +/// TXE regression test: when a private context is entered with a user-supplied `anchor_block_number`, +/// offchain messages emitted during that context must carry the timestamp of the *anchor* block, +/// not the latest block. +#[test] +unconstrained fn private_context_offchain_message_uses_anchor_block_timestamp() { + let (env, _, caller) = setup(); + + env.mine_block(); + let anchor_block_number = env.last_block_number(); + let anchor_timestamp = env.last_block_timestamp(); + + env.advance_next_block_timestamp_by(1000); + env.mine_block(); + let latest_timestamp = env.last_block_timestamp(); + assert(latest_timestamp != anchor_timestamp); + + env.private_context_opts( + PrivateContextOptions::new().at_anchor_block_number(anchor_block_number), + |_context| deliver_offchain_message(dummy_ciphertext(), caller), + ); + + let messages = env.offchain_messages(); + assert(messages.len() == 1); + assert(messages.get(0).anchor_block_timestamp == anchor_timestamp); + assert(messages.get(0).anchor_block_timestamp != latest_timestamp); +} diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index c70fc0dbc4dd..d77d3e25cc40 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -68,7 +68,7 @@ export interface ITxeExecutionOracle { argsHash: Fr, isStaticCall: boolean, jobId: string, - ): Promise; + ): Promise<{ returnValues: Fr[]; offchainEffects: Fr[][] }>; executeUtilityFunction( targetContractAddress: AztecAddress, functionSelector: FunctionSelector, 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 102489d353de..92193552a97c 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 @@ -15,7 +15,6 @@ import { CapsuleService, CapsuleStore, type ContractStore, - type ContractSyncService, NoteStore, ORACLE_VERSION_MAJOR, PrivateEventStore, @@ -116,7 +115,6 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private version: Fr, private chainId: Fr, private authwits: Map, - private readonly contractSyncService: ContractSyncService, ) { this.logger = createLogger('txe:top_level_context'); this.logger.debug('Entering Top Level Context'); @@ -514,11 +512,17 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } } + // Walk the nested private-call tree and collect every offchain effect the transaction emitted. + // PXE stores these on each `PrivateCallExecutionResult` and they never reach TXE via the + // `aztec_utl_emitOffchainEffect` foreign-call path (that path only fires at the top-level), so + // we pull them out here and the RPC wrapper will hand them to `TXESession` for buffering. + const offchainEffects = collectNested([executionResult], r => r.offchainEffects.map(e => e.data)); + if (isStaticCall) { await checkpoint!.revert(); await forkedWorldTrees.close(); - return executionResult.returnValues ?? []; + return { returnValues: executionResult.returnValues ?? [], offchainEffects }; } const txEffect = TxEffect.empty(); @@ -540,7 +544,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl await forkedWorldTrees.close(); - return executionResult.returnValues ?? []; + return { returnValues: executionResult.returnValues ?? [], offchainEffects }; } async publicCallNewFlow( @@ -764,7 +768,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl capsuleService: new CapsuleService(this.capsuleStore, scopes), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, - contractSyncService: this.contractSyncService, + contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.node, jobId, scopes, diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index dd535d4b8bf9..a40fadc3cbe9 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -1,6 +1,6 @@ import type { ContractInstanceWithAddress } from '@aztec/aztec.js/contracts'; import { Fr, Point } from '@aztec/aztec.js/fields'; -import { MAX_NOTE_HASHES_PER_TX, MAX_NULLIFIERS_PER_TX } from '@aztec/constants'; +import { MAX_NOTE_HASHES_PER_TX, MAX_NULLIFIERS_PER_TX, PRIVATE_LOG_CIPHERTEXT_LEN } from '@aztec/constants'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { type IMiscOracle, @@ -298,6 +298,46 @@ export class RPCTranslator { ]); } + // eslint-disable-next-line camelcase + aztec_txe_getLastCallOffchainEffects() { + // This oracle returns all offchain effect payloads (messages, authwit requests, etc.) emitted by the last top-level call, + // MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY is arbitrarily set at 64 because we need a bound. Nothing inherent about it. + const MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY = 64; + // Must match MAX_OFFCHAIN_EFFECT_LEN in txe_oracles.nr. + const MAX_OFFCHAIN_EFFECT_LEN = 2 + PRIVATE_LOG_CIPHERTEXT_LEN; + + const { effects } = this.stateHandler.getLastCallOffchainEffects(); + + if (effects.length > MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY) { + throw new Error(`${effects.length} offchain effects exceed max ${MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY}`); + } + if (effects.some(e => e.length > MAX_OFFCHAIN_EFFECT_LEN)) { + throw new Error(`Some offchain effect has length larger than max ${MAX_OFFCHAIN_EFFECT_LEN}`); + } + + const rawArrayStorage = effects + .map(e => e.concat(Array(MAX_OFFCHAIN_EFFECT_LEN - e.length).fill(new Fr(0)))) + .concat( + Array(MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY - effects.length).fill(Array(MAX_OFFCHAIN_EFFECT_LEN).fill(new Fr(0))), + ) + .flat(); + + const effectLengths = effects + .map(e => new Fr(e.length)) + .concat(Array(MAX_OFFCHAIN_EFFECTS_PER_TXE_QUERY - effects.length).fill(new Fr(0))); + + const count = new Fr(effects.length); + + return toForeignCallResult([toArray(rawArrayStorage), toArray(effectLengths), toSingle(count)]); + } + + // eslint-disable-next-line camelcase + aztec_txe_getLastCallContext() { + const { txHash, anchorBlockTimestamp } = this.stateHandler.getLastCallContext(); + const isSome = txHash.isZero() ? 0 : 1; + return toForeignCallResult([toSingle(isSome), toSingle(txHash), toSingle(new Fr(anchorBlockTimestamp))]); + } + // eslint-disable-next-line camelcase async aztec_txe_getPrivateEvents( foreignSelector: ForeignCallSingle, @@ -1071,8 +1111,13 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - aztec_utl_emitOffchainEffect(_foreignData: ForeignCallArray) { - throw new Error('Offchain effects are not yet supported in the TestEnvironment'); + aztec_utl_emitOffchainEffect(foreignData: ForeignCallArray) { + // Record the raw payload against the currently-executing top-level call. The Noir side + // (via `env.offchain_messages()`) is responsible for decoding the protocol-reserved prefix + // (`OFFCHAIN_MESSAGE_IDENTIFIER`, recipient) and turning each payload into an `OffchainMessage` struct suitable + // for `offchain_receive`. + this.stateHandler.recordOffchainEffect(fromArray(foreignData)); + return Promise.resolve(toForeignCallResult([])); } // AVM opcodes @@ -1281,18 +1326,38 @@ export class RPCTranslator { const argsHash = fromSingle(foreignArgsHash); const isStaticCall = fromSingle(foreignIsStaticCall).toBool(); - const returnValues = await this.handlerAsTxe().privateCallNewFlow( - from, - targetContractAddress, - functionSelector, - args, - argsHash, - isStaticCall, - this.stateHandler.getCurrentJob(), - ); + const returnValues = await this.stateHandler.withTopLevelCallTracking(async () => { + const { returnValues, offchainEffects } = await this.handlerAsTxe().privateCallNewFlow( + from, + targetContractAddress, + functionSelector, + args, + argsHash, + isStaticCall, + this.stateHandler.getCurrentJob(), + ); + + // Private execution collects offchain effects inside PXE's PrivateExecutionOracle rather than + // round-tripping them through `aztec_utl_emitOffchainEffect`, so the session buffer is empty + // at this point. Drain the effects from the execution tree into the session buffer so the + // next `env.offchain_messages()` call in the test sees them. + for (const data of offchainEffects) { + this.stateHandler.recordOffchainEffect(data); + } + + // TODO(F-335): Avoid doing the following call here. + await this.stateHandler.cycleJob(); + + if (isStaticCall) { + // Static calls revert their checkpoint and mine no block, so there is no tx hash to tag + // offchain effects with. Querying `getLastTxEffects()` here would return an unrelated + // predecessor tx. + return { result: returnValues }; + } + const { txHash } = await this.handlerAsTxe().getLastTxEffects(); + return { result: returnValues, txHash: txHash.hash }; + }); - // TODO(F-335): Avoid doing the following call here. - await this.stateHandler.cycleJob(); return toForeignCallResult([toArray(returnValues)]); } @@ -1306,15 +1371,20 @@ export class RPCTranslator { const functionSelector = FunctionSelector.fromField(fromSingle(foreignFunctionSelector)); const args = fromArray(foreignArgs); - const returnValues = await this.handlerAsTxe().executeUtilityFunction( - targetContractAddress, - functionSelector, - args, - this.stateHandler.getCurrentJob(), - ); + const returnValues = await this.stateHandler.withTopLevelCallTracking(async () => { + const returnValues = await this.handlerAsTxe().executeUtilityFunction( + targetContractAddress, + functionSelector, + args, + this.stateHandler.getCurrentJob(), + ); + + // TODO(F-335): Avoid doing the following call here. + await this.stateHandler.cycleJob(); + + return { result: returnValues }; + }); - // TODO(F-335): Avoid doing the following call here. - await this.stateHandler.cycleJob(); return toForeignCallResult([toArray(returnValues)]); } @@ -1330,10 +1400,20 @@ export class RPCTranslator { const calldata = fromArray(foreignCalldata); const isStaticCall = fromSingle(foreignIsStaticCall).toBool(); - const returnValues = await this.handlerAsTxe().publicCallNewFlow(from, address, calldata, isStaticCall); + const returnValues = await this.stateHandler.withTopLevelCallTracking(async () => { + const returnValues = await this.handlerAsTxe().publicCallNewFlow(from, address, calldata, isStaticCall); + + // TODO(F-335): Avoid doing the following call here. + await this.stateHandler.cycleJob(); + + if (isStaticCall) { + // See equivalent branch in `aztec_txe_privateCallNewFlow`. + return { result: returnValues }; + } + const { txHash } = await this.handlerAsTxe().getLastTxEffects(); + return { result: returnValues, txHash: txHash.hash }; + }); - // TODO(F-335): Avoid doing the following call here. - await this.stateHandler.cycleJob(); return toForeignCallResult([toArray(returnValues)]); } diff --git a/yarn-project/txe/src/txe_session.test.ts b/yarn-project/txe/src/txe_session.test.ts index b6506eb20cd3..6422a2cd8355 100644 --- a/yarn-project/txe/src/txe_session.test.ts +++ b/yarn-project/txe/src/txe_session.test.ts @@ -26,7 +26,6 @@ describe('TXESession.processFunction', () => { new Fr(1), // chainId new Fr(1), // version 0n, // nextBlockTimestamp - {} as any, // contractSyncService ); }); diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 6357b772887e..da020dbb5b30 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -9,7 +9,6 @@ import { CapsuleService, CapsuleStore, ContractStore, - ContractSyncService, JobCoordinator, NoteService, NoteStore, @@ -43,7 +42,7 @@ import { GasSettings } from '@aztec/stdlib/gas'; import { computeProtocolNullifier } from '@aztec/stdlib/hash'; import { PrivateContextInputs } from '@aztec/stdlib/kernel'; import { makeGlobalVariables } from '@aztec/stdlib/testing'; -import { CallContext, GlobalVariables, TxContext } from '@aztec/stdlib/tx'; +import { CallContext, GlobalVariables, OFFCHAIN_MESSAGE_IDENTIFIER, TxContext } from '@aztec/stdlib/tx'; import { z } from 'zod'; @@ -118,6 +117,65 @@ export interface TXESessionStateHandler { // TODO(F-335): Exposing the job info is abstraction breakage - drop the following 2 functions. cycleJob(): Promise; getCurrentJob(): string; + + /** + * Runs an executor-style top-level call (private/public call, utility execution) with last-call tracking. + */ + withTopLevelCallTracking(work: () => Promise<{ result: T; txHash?: Fr }>): Promise; + + /** + * Captures a raw offchain effect payload for consumption from test environment. Called by the `emit_offchain_effect` + * oracle handler whenever a contract function emits an offchain message, at any call depth. + */ + recordOffchainEffect(data: Fr[]): void; + + /** + * Returns the raw offchain effect payloads emitted by the last top-level call. Each payload follows the protocol + * convention documented on `OFFCHAIN_MESSAGE_IDENTIFIER`, i.e. `[identifier, recipient, ...ciphertext]`. Decoding into + * `OffchainMessage` structs happens on the Noir side of the test helper. Marks the buffer as queried so the + * unqueried-messages warning doesn't fire on the next reset. + */ + getLastCallOffchainEffects(): { effects: Fr[][] }; + + /** + * Returns the context of the last top-level call: its tx hash (`Fr.ZERO` if the call was tx-less) and the anchor + * block timestamp captured at the start of the call. Does *not* mark the buffer as queried — context reads are + * metadata, not effect consumption. + */ + getLastCallContext(): { txHash: Fr; anchorBlockTimestamp: bigint }; +} + +/** + * Session state tracking the most recently completed top-level call: the offchain effect buffer it produced, and the + * call's context (tx hash + anchor block timestamp). The context is refreshed on every top-level call, independently + * of whether the call produced offchain effects. + */ +interface LastCallState { + /** + * Raw offchain effect payloads emitted by the currently-executing (or most recently completed) top-level call. Wiped + * at the start of every top-level entry point, appended to on every `emit_offchain_effect` oracle invocation. + */ + offchainEffects: Fr[][]; + /** + * Tracks whether the test has queried `effects` since the last reset. If a new top-level call clobbers the buffer + * without it being queried first, any accumulated messages are lost and we emit a warning so tests don't silently + * drop delivery. + */ + queried: boolean; + /** + * Tx hash of the most recently completed top-level call, or `Fr.ZERO` if the call was tx-less (context setters, + * utility execution). Populated by call executor handlers after execution completes. + */ + txHash: Fr; + /** + * Anchor block timestamp of the most recently completed top-level call, captured from the anchor block header that + * was active when the call started. Populated by call executor handlers after execution completes. + */ + anchorBlockTimestamp: bigint; +} + +function emptyLastCallState(): LastCallState { + return { offchainEffects: [], queried: false, txHash: Fr.ZERO, anchorBlockTimestamp: 0n }; } /** @@ -127,6 +185,7 @@ export interface TXESessionStateHandler { export class TXESession implements TXESessionStateHandler { private state: SessionState = { name: 'TOP_LEVEL' }; private authwits: Map = new Map(); + private lastCallInfo: LastCallState = emptyLastCallState(); constructor( private logger: Logger, @@ -151,7 +210,6 @@ export class TXESession implements TXESessionStateHandler { private chainId: Fr, private version: Fr, private nextBlockTimestamp: bigint, - private contractSyncService: ContractSyncService, ) {} static async init(contractStore: ContractStore) { @@ -188,7 +246,6 @@ export class TXESession implements TXESessionStateHandler { const initialJobId = jobCoordinator.beginJob(); const logger = createLogger('txe:session'); - const contractSyncService = new ContractSyncService(stateMachine.node, contractStore, noteStore, logger); const topLevelOracleHandler = new TXEOracleTopLevelContext( stateMachine, @@ -206,7 +263,6 @@ export class TXESession implements TXESessionStateHandler { version, chainId, new Map(), - contractSyncService, ); await topLevelOracleHandler.advanceBlocksBy(1); @@ -229,7 +285,6 @@ export class TXESession implements TXESessionStateHandler { version, chainId, nextBlockTimestamp, - contractSyncService, ); } @@ -275,6 +330,50 @@ export class TXESession implements TXESessionStateHandler { return this.currentJobId; } + private resetLastCall(): void { + const notQueriedMessageCount = this.lastCallInfo.queried + ? 0 + : this.lastCallInfo.offchainEffects.filter(payload => payload[0]?.equals(OFFCHAIN_MESSAGE_IDENTIFIER)).length; + if (notQueriedMessageCount > 0) { + this.logger.warn( + `Dropping ${notQueriedMessageCount} unqueried offchain message(s) from the previous top-level call. ` + + `To deliver them, call \`env.offchain_messages()\` and forward the result to the recipient contract's ` + + `\`offchain_receive\` utility before issuing another top-level call. To intentionally discard, assign ` + + `to \`let _ = env.offchain_messages()\` to silence this warning.`, + ); + } + this.lastCallInfo = emptyLastCallState(); + } + + recordOffchainEffect(data: Fr[]): void { + this.lastCallInfo.offchainEffects.push(data); + } + + private setLastCallContext(txHash: Fr, anchorBlockTimestamp: bigint): void { + this.lastCallInfo.txHash = txHash; + this.lastCallInfo.anchorBlockTimestamp = anchorBlockTimestamp; + } + + async withTopLevelCallTracking(work: () => Promise<{ result: T; txHash?: Fr }>): Promise { + this.resetLastCall(); + // Capture the anchor *before* `work` runs: private/public executor calls mine a new block as a + // side effect, and that block's timestamp should not be attributed to this call's anchor. + const anchorBlockTimestamp = (await this.stateMachine.node.getBlockHeader('latest'))!.globalVariables.timestamp; + const { result, txHash } = await work(); + this.setLastCallContext(txHash ?? Fr.ZERO, anchorBlockTimestamp); + return result; + } + + getLastCallOffchainEffects(): { effects: Fr[][] } { + this.lastCallInfo.queried = true; + return { effects: this.lastCallInfo.offchainEffects }; + } + + getLastCallContext(): { txHash: Fr; anchorBlockTimestamp: bigint } { + const { txHash, anchorBlockTimestamp } = this.lastCallInfo; + return { txHash, anchorBlockTimestamp }; + } + async enterTopLevelState() { switch (this.state.name) { case 'PRIVATE': { @@ -316,7 +415,6 @@ export class TXESession implements TXESessionStateHandler { this.version, this.chainId, this.authwits, - this.contractSyncService, ); this.state = { name: 'TOP_LEVEL' }; @@ -328,6 +426,7 @@ export class TXESession implements TXESessionStateHandler { anchorBlockNumber?: BlockNumber, ): Promise { this.exitTopLevelState(); + this.resetLastCall(); // Private execution has two associated block numbers: the anchor block (i.e. the historical block that is used to // build the proof), and the *next* block, i.e. the one we'll create once the execution ends, and which will contain @@ -389,17 +488,22 @@ export class TXESession implements TXESessionStateHandler { this.state = { name: 'PRIVATE', nextBlockGlobalVariables, noteCache, taggingIndexCache }; this.logger.debug(`Entered state ${this.state.name}`); + // Record the *resolved* anchor's timestamp — if the caller pinned the anchor to a past block + // via `anchorBlockNumber`, "latest" would be the wrong anchor for offchain-message semantics. + this.setLastCallContext(Fr.ZERO, anchorBlock!.globalVariables.timestamp); + return (this.oracleHandler as PrivateExecutionOracle).getPrivateContextInputs(); } async enterPublicState(contractAddress?: AztecAddress) { this.exitTopLevelState(); + this.resetLastCall(); // The PublicContext will create a block with a single transaction in it, containing the effects of what was done in // the test. The block therefore gets the *next* block number and timestamp. - const latestBlockNumber = (await this.stateMachine.node.getBlockHeader('latest'))!.globalVariables.blockNumber; + const latestHeader = (await this.stateMachine.node.getBlockHeader('latest'))!; const globalVariables = makeGlobalVariables(undefined, { - blockNumber: BlockNumber(latestBlockNumber + 1), + blockNumber: BlockNumber(latestHeader.globalVariables.blockNumber + 1), timestamp: this.nextBlockTimestamp, version: this.version, chainId: this.chainId, @@ -414,10 +518,14 @@ export class TXESession implements TXESessionStateHandler { this.state = { name: 'PUBLIC' }; this.logger.debug(`Entered state ${this.state.name}`); + + // Public state is anchored at the latest block. + this.setLastCallContext(Fr.ZERO, latestHeader.globalVariables.timestamp); } async enterUtilityState(contractAddress: AztecAddress = DEFAULT_ADDRESS) { this.exitTopLevelState(); + this.resetLastCall(); const anchorBlockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); @@ -448,7 +556,7 @@ export class TXESession implements TXESessionStateHandler { capsuleService: new CapsuleService(this.capsuleStore, await this.keyStore.getAccounts()), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, - contractSyncService: this.contractSyncService, + contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.node, jobId: this.currentJobId, scopes: await this.keyStore.getAccounts(), @@ -456,6 +564,9 @@ export class TXESession implements TXESessionStateHandler { this.state = { name: 'UTILITY' }; this.logger.debug(`Entered state ${this.state.name}`); + + // Utility state anchors at whatever the anchor block store is pointing to (tracked as latest). + this.setLastCallContext(Fr.ZERO, anchorBlockHeader.globalVariables.timestamp); } private exitTopLevelState() { @@ -542,7 +653,7 @@ export class TXESession implements TXESessionStateHandler { capsuleService: new CapsuleService(this.capsuleStore, scopes), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, - contractSyncService: this.contractSyncService, + contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.node, jobId: this.currentJobId, scopes,