diff --git a/docs/netlify.toml b/docs/netlify.toml index b632c6c1b840..b2f94bf382c5 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -759,7 +759,37 @@ # Add new error codes sequentially (1, 2, 3, ...). # ============================================================================= -# Example (uncomment and modify when adding error codes): -# [[redirects]] -# from = "/errors/1" -# to = "/developers/docs/aztec-nr/framework-description/functions/how_to_define_functions" +[[redirects]] + from = "/errors/1" + # A warning that is shown when `aztec compile` is run and the contract crate contains tests + to = "/developers/docs/aztec-nr/testing_contracts#keep-tests-in-the-test-crate" + +[[redirects]] + # Aztec-nr: custom message was received but no handler was configured + from = "/errors/2" + to = "/aztec-nr-api/nightly/noir_aztec/macros/struct.AztecConfig.html" + +[[redirects]] + # Aztec-nr: message received with message type ID in the range reserved for future aztec.nr built-in message types + from = "/errors/3" + to = "/aztec-nr-api/nightly/noir_aztec/messages/msg_type/index.html" + +[[redirects]] + # Aztec-nr: note packed length exceeds MAX_NOTE_PACKED_LEN + from = "/errors/4" + to = "/aztec-nr-api/nightly/noir_aztec/macros/notes/fn.note.html" + +[[redirects]] + # Aztec-nr: event serialized length exceeds MAX_EVENT_SERIALIZED_LEN + from = "/errors/5" + to = "/aztec-nr-api/nightly/noir_aztec/macros/events/fn.event.html" + +[[redirects]] + # Aztec-nr: direct invocation of contract functions is not supported + from = "/errors/6" + to = "/developers/docs/aztec-nr/framework-description/calling_contracts" + +[[redirects]] + # Aztec-nr: user-defined 'offchain_receive' is not allowed + from = "/errors/7" + to = "/aztec-nr-api/nightly/noir_aztec/messages/processing/offchain/fn.receive.html" diff --git a/noir-projects/aztec-nr/aztec/src/macros/aztec.nr b/noir-projects/aztec-nr/aztec/src/macros/aztec.nr index b7b27b9c3d16..9d101b2d7b18 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/aztec.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/aztec.nr @@ -13,11 +13,77 @@ use crate::macros::{ }, }; +use crate::messages::discovery::CustomMessageHandler; + /// Marks a contract as an Aztec contract, generating the interfaces for its functions and notes, as well as injecting /// the `sync_state` utility function. /// -/// Note: This is a module annotation, so the returned quote gets injected inside the module (contract) itself. -pub comptime fn aztec(m: Module) -> Quoted { +/// This type lets users override different parts of the default aztec-nr contract behavior, such +/// as message handling. These are advanced features that require careful understanding of +/// the behavior of these systems. +/// +/// ## Examples +/// +/// ```noir +/// #[aztec(aztec::macros::AztecConfig::new().custom_message_handler(my_handler))] +/// contract MyContract { ... } +/// ``` +pub struct AztecConfig { + custom_message_handler: Option>, +} + +impl AztecConfig { + /// Creates a new `AztecConfig` with default values. + /// + /// Calling `new` is equivalent to invoking the [`aztec`] macro with no parameters. The different methods + /// (e.g. [`AztecConfig::custom_message_handler`]) can then be used to change the default behavior. + pub comptime fn new() -> Self { + Self { custom_message_handler: Option::none() } + } + + /// Sets a handler for custom messages. + /// + /// This enables contracts to process non-standard messages (i.e. any with a message type that is not in + /// [`crate::messages::msg_type`]). + /// + /// `handler` must be a function that conforms to the + /// [`crate::messages::discovery::CustomMessageHandler`] type signature. + pub comptime fn custom_message_handler(_self: Self, handler: CustomMessageHandler<()>) -> Self { + Self { custom_message_handler: Option::some(handler) } + } +} + +/// Enables aztec-nr features on a `contract`. +/// +/// All aztec-nr contracts should have this macro invoked on them, as it is the one that processes all contract +/// functions, notes, storage, generates interfaces for external calls, and creates the message processing +/// boilerplate. +/// +/// ## Examples +/// +/// Most contracts can simply invoke the macro with no parameters, resulting in default aztec-nr behavior: +/// ```noir +/// #[aztec] +/// contract MyContract { ... } +/// ``` +/// +/// Advanced contracts can use [`AztecConfig`] to customize parts of its behavior, such as message +/// processing. +/// ```noir +/// #[aztec(aztec::macros::AztecConfig::new().custom_message_handler(my_handler))] +/// contract MyAdvancedContract { ... } +/// ``` +#[varargs] +pub comptime fn aztec(m: Module, args: [AztecConfig]) -> Quoted { + let num_args = args.len(); + let config = if num_args == 0 { + AztecConfig::new() + } else if num_args == 1 { + args[0] + } else { + panic(f"#[aztec] expects 0 or 1 arguments, got {num_args}") + }; + // Functions that don't have #[external(...)], #[contract_library_method], or #[test] are not allowed in contracts. check_each_fn_macroified(m); @@ -42,12 +108,30 @@ pub comptime fn aztec(m: Module) -> Quoted { } else { quote {} }; + let process_custom_message_option = if config.custom_message_handler.is_some() { + let handler = config.custom_message_handler.unwrap(); + quote { Option::some($handler) } + } else { + quote { Option::>::none() } + }; + + let offchain_inbox_sync_option = quote { + Option::some(aztec::messages::processing::offchain::sync_inbox) + }; + let sync_state_fn_and_abi_export = if !m.functions().any(|f| f.name() == quote { sync_state }) { - generate_sync_state() + generate_sync_state(process_custom_message_option, offchain_inbox_sync_option) } else { quote {} }; + if m.functions().any(|f| f.name() == quote { offchain_receive }) { + panic( + "User-defined 'offchain_receive' is not allowed. The function is auto-injected by the #[aztec] macro. See https://docs.aztec.network/errors/7", + ); + } + let offchain_receive_fn_and_abi_export = generate_offchain_receive(); + let process_message_fn_and_abi_export = if !m.functions().any(|f| f.name() == quote { process_message }) { generate_process_message() } else { @@ -65,6 +149,7 @@ pub comptime fn aztec(m: Module) -> Quoted { $public_dispatch $sync_state_fn_and_abi_export $process_message_fn_and_abi_export + $offchain_receive_fn_and_abi_export } } @@ -263,7 +348,8 @@ comptime fn generate_contract_library_method_compute_note_hash_and_nullifier() - } } -comptime fn generate_sync_state() -> Quoted { +/// Generates the `sync_state` utility function that performs message discovery. +comptime fn generate_sync_state(process_custom_message_option: Quoted, offchain_inbox_sync_option: Quoted) -> Quoted { quote { pub struct sync_state_parameters {} @@ -275,8 +361,12 @@ comptime fn generate_sync_state() -> Quoted { #[aztec::macros::internals_functions_generation::abi_attributes::abi_utility] unconstrained fn sync_state() { let address = aztec::context::UtilityContext::new().this_address(); - - aztec::messages::discovery::do_sync_state(address, _compute_note_hash_and_nullifier); + aztec::messages::discovery::do_sync_state( + address, + _compute_note_hash_and_nullifier, + $process_custom_message_option, + $offchain_inbox_sync_option, + ); } } } @@ -303,6 +393,7 @@ comptime fn generate_process_message() -> Quoted { aztec::messages::discovery::process_message::process_message_ciphertext( address, _compute_note_hash_and_nullifier, + Option::>::none(), message_ciphertext, message_context, ); @@ -314,6 +405,41 @@ comptime fn generate_process_message() -> Quoted { } } +/// Generates an `offchain_receive` utility function that lets callers add messages to the offchain message inbox. +/// +/// For more details, see `aztec::messages::processing::offchain::receive`. +comptime fn generate_offchain_receive() -> Quoted { + quote { + pub struct offchain_receive_parameters { + pub messages: BoundedVec< + aztec::messages::processing::offchain::OffchainMessage, + aztec::messages::processing::offchain::MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL, + >, + } + + #[abi(functions)] + pub struct offchain_receive_abi { + parameters: offchain_receive_parameters, + } + + /// Receives offchain messages into this contract's offchain inbox for subsequent processing. + /// + /// For more details, see `aztec::messages::processing::offchain::receive`. + /// + /// 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, + >, + ) { + let address = aztec::context::UtilityContext::new().this_address(); + aztec::messages::processing::offchain::receive(address, messages); + } + } +} + /// Checks that all functions in the module have a context macro applied. /// /// Non-macroified functions are not allowed in contracts. They must all be one of diff --git a/noir-projects/aztec-nr/aztec/src/messages/discovery/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/discovery/mod.nr index 312327417e48..0b53c6d55f9f 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/discovery/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/discovery/mod.nr @@ -7,11 +7,13 @@ pub mod private_notes; pub mod process_message; use crate::{ + capsules::CapsuleArray, messages::{ discovery::process_message::process_message_ciphertext, logs::note::MAX_NOTE_PACKED_LEN, processing::{ - get_private_logs, pending_tagged_log::PendingTaggedLog, validate_and_store_enqueued_notes_and_events, + get_private_logs, MessageContext, offchain::OffchainInboxSync, OffchainMessageWithContext, + pending_tagged_log::PendingTaggedLog, validate_and_store_enqueued_notes_and_events, }, }, utils::array, @@ -64,6 +66,18 @@ pub type ComputeNoteHashAndNullifier = unconstrained fn[Env](/* packed_note owner */ AztecAddress, /* storage_slot */ Field, /* note_type_id */ Field, /* contract_address */ AztecAddress, /* randomness */ Field, /* note nonce */ Field) -> Option; +/// A handler for custom messages. +/// +/// Contracts that emit custom messages (i.e. any with a message type that is not in [`crate::messages::msg_type`]) +/// need to use [`crate::macros::aztec::AztecConfig::custom_message_handler`] with a function of this type in order to +/// process them. They will otherwise be **silently ignored**. +pub type CustomMessageHandler = unconstrained fn[Env]( +/* contract_address */AztecAddress, +/* msg_type_id */ u64, +/* msg_metadata */ u64, +/* msg_content */ BoundedVec, +/* message_context */ MessageContext); + /// Performs the state synchronization process, in which private logs are downloaded and inspected to find new private /// notes, partial notes and events, etc., and pending partial notes are processed to search for their completion logs. /// This is the mechanism via which a contract updates its knowledge of its private state. @@ -74,9 +88,11 @@ randomness */ Field, /* note nonce */ Field) -> Option; /// /// Receives the address of the contract on which discovery is performed along with its /// `compute_note_hash_and_nullifier` function. -pub unconstrained fn do_sync_state( +pub unconstrained fn do_sync_state( contract_address: AztecAddress, - compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier, + compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier, + process_custom_message: Option>, + offchain_inbox_sync: Option>, ) { debug_log("Performing state synchronization"); @@ -95,12 +111,29 @@ pub unconstrained fn do_sync_state( process_message_ciphertext( contract_address, compute_note_hash_and_nullifier, + process_custom_message, message_ciphertext, pending_tagged_log.context, ); logs.remove(i); }); + if offchain_inbox_sync.is_some() { + let msgs: CapsuleArray = offchain_inbox_sync.unwrap()(contract_address); + msgs.for_each(|i, msg| { + process_message_ciphertext( + contract_address, + compute_note_hash_and_nullifier, + process_custom_message, + msg.message_ciphertext, + msg.message_context, + ); + // The inbox sync returns _a copy_ of messages to process, so we clear them as we do so. This is a + // volatile array with the to-process message, not the actual persistent storage of them. + msgs.remove(i); + }); + } + // Then we process all pending partial notes, regardless of whether they were found in the current or previous // executions. partial_notes::fetch_and_process_partial_note_completion_logs(contract_address, compute_note_hash_and_nullifier); diff --git a/noir-projects/aztec-nr/aztec/src/messages/discovery/process_message.nr b/noir-projects/aztec-nr/aztec/src/messages/discovery/process_message.nr index 965d7224c8a5..f5e48cdb92c5 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/discovery/process_message.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/discovery/process_message.nr @@ -1,11 +1,13 @@ use crate::messages::{ discovery::{ - ComputeNoteHashAndNullifier, partial_notes::process_partial_note_private_msg, + ComputeNoteHashAndNullifier, CustomMessageHandler, partial_notes::process_partial_note_private_msg, private_events::process_private_event_msg, private_notes::process_private_note_msg, }, encoding::{decode_message, MESSAGE_CIPHERTEXT_LEN, MESSAGE_PLAINTEXT_LEN}, encryption::{aes128::AES128, message_encryption::MessageEncryption}, - msg_type::{PARTIAL_NOTE_PRIVATE_MSG_TYPE_ID, PRIVATE_EVENT_MSG_TYPE_ID, PRIVATE_NOTE_MSG_TYPE_ID}, + msg_type::{ + MIN_CUSTOM_MSG_TYPE_ID, PARTIAL_NOTE_PRIVATE_MSG_TYPE_ID, PRIVATE_EVENT_MSG_TYPE_ID, PRIVATE_NOTE_MSG_TYPE_ID, + }, processing::MessageContext, }; @@ -23,9 +25,10 @@ use crate::protocol::{address::AztecAddress, logging::{debug_log, debug_log_form /// /// Events are processed by computing an event commitment from the serialized event data and its randomness field, then /// enqueueing the event data and commitment for validation. -pub unconstrained fn process_message_ciphertext( +pub unconstrained fn process_message_ciphertext( contract_address: AztecAddress, - compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier, + compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier, + process_custom_message: Option>, message_ciphertext: BoundedVec, message_context: MessageContext, ) { @@ -35,6 +38,7 @@ pub unconstrained fn process_message_ciphertext( process_message_plaintext( contract_address, compute_note_hash_and_nullifier, + process_custom_message, message_plaintext_option.unwrap(), message_context, ); @@ -46,9 +50,10 @@ pub unconstrained fn process_message_ciphertext( } } -pub unconstrained fn process_message_plaintext( +pub unconstrained fn process_message_plaintext( contract_address: AztecAddress, - compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier, + compute_note_hash_and_nullifier: ComputeNoteHashAndNullifier, + process_custom_message: Option>, message_plaintext: BoundedVec, message_context: MessageContext, ) { @@ -90,7 +95,23 @@ pub unconstrained fn process_message_plaintext( msg_content, message_context.tx_hash, ); + } else if msg_type_id < MIN_CUSTOM_MSG_TYPE_ID { + debug_log_format( + "Message type ID {0} is in the reserved range but is not recognized, ignoring. See https://docs.aztec.network/errors/3", + [msg_type_id as Field], + ); + } else if process_custom_message.is_some() { + process_custom_message.unwrap()( + contract_address, + msg_type_id, + msg_metadata, + msg_content, + message_context, + ); } else { - debug_log_format("Unknown msg type id {0}", [msg_type_id as Field]); + debug_log_format( + "Received custom message with type id {0} but no handler is configured, ignoring. See https://docs.aztec.network/errors/2", + [msg_type_id as Field], + ); } } diff --git a/noir-projects/aztec-nr/aztec/src/messages/msg_type.nr b/noir-projects/aztec-nr/aztec/src/messages/msg_type.nr index 755fa5f18796..a3228380102e 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/msg_type.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/msg_type.nr @@ -14,3 +14,19 @@ pub global PARTIAL_NOTE_PRIVATE_MSG_TYPE_ID: u64 = 1; /// /// This message contains all information necessary in order to prove existence of the event. pub global PRIVATE_EVENT_MSG_TYPE_ID: u64 = 2; + +pub global MIN_CUSTOM_MSG_TYPE_ID: u64 = 2.pow_32(16) as u64; + +/// Custom message handlers must use message type IDs >= `MIN_CUSTOM_MSG_TYPE_ID`. This function offsets the provided +/// local ID by `MIN_CUSTOM_MSG_TYPE_ID` so that users can define simple sequential IDs starting from 0 without +/// worrying about collisions with the built-in IDs. +/// +/// ## Example +/// +/// ```noir +/// global MY_MSG_TYPE: u64 = custom_msg_type_id(0); +/// global ANOTHER_MSG_TYPE: u64 = custom_msg_type_id(1); +/// ``` +pub fn custom_msg_type_id(local_id: u64) -> u64 { + MIN_CUSTOM_MSG_TYPE_ID + local_id +} diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/message_context.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/message_context.nr index d9367630b5d4..b98b4bdeff20 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/message_context.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/message_context.nr @@ -1,11 +1,11 @@ -use crate::protocol::{address::AztecAddress, constants::MAX_NOTE_HASHES_PER_TX, traits::Deserialize}; +use crate::protocol::{address::AztecAddress, constants::MAX_NOTE_HASHES_PER_TX, traits::{Deserialize, Serialize}}; /// Additional information needed to process a message. /// /// All messages exist in the context of a transaction, and information about that transaction is typically required in /// order to perform validation, store results, etc. For example, messages containing notes require knowledge of note /// hashes and the first nullifier in order to find the note's nonce. -#[derive(Deserialize, Eq)] +#[derive(Serialize, Deserialize, Eq)] pub struct MessageContext { pub tx_hash: Field, pub unique_note_hashes_in_tx: BoundedVec, @@ -13,6 +13,20 @@ pub struct MessageContext { pub recipient: AztecAddress, } +/// Transaction context needed to process a message. +/// +/// Like [`MessageContext`], but `MessageTxContext` does not include the recipient. MessageTxContext's are kind of +/// adhoc: they are just the minimal data structure that the contract needs to get from a PXE oracle to prepare +/// offchain messages to be processed. We reify it with a type just because it crosses Noir<->TS boundaries. +/// The contract knows how to pair the context data with a recipient: then it is able to build a `MessageContext` for +/// subsequent processing. +#[derive(Serialize, Deserialize, Eq)] +pub(crate) struct MessageTxContext { + pub tx_hash: Field, + pub unique_note_hashes_in_tx: BoundedVec, + pub first_nullifier_in_tx: Field, +} + mod test { use crate::messages::processing::MessageContext; use crate::protocol::{address::AztecAddress, traits::{Deserialize, FromField}}; diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr index 1dcf2c9e4257..803cda1e1637 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/mod.nr @@ -1,7 +1,9 @@ pub(crate) mod event_validation_request; +pub mod offchain; mod message_context; pub use message_context::MessageContext; +pub(crate) use message_context::MessageTxContext; pub(crate) mod note_validation_request; pub(crate) mod log_retrieval_request; @@ -13,6 +15,7 @@ use crate::{ event::EventSelector, messages::{ discovery::partial_notes::DeliveredPendingPartialNote, + encoding::MESSAGE_CIPHERTEXT_LEN, logs::{event::MAX_EVENT_SERIALIZED_LEN, note::MAX_NOTE_PACKED_LEN}, processing::{ log_retrieval_request::LogRetrievalRequest, log_retrieval_response::LogRetrievalResponse, @@ -21,7 +24,7 @@ use crate::{ }, oracle, }; -use crate::protocol::{address::AztecAddress, hash::sha256_to_field}; +use crate::protocol::{address::AztecAddress, hash::sha256_to_field, traits::{Deserialize, Serialize}}; use event_validation_request::EventValidationRequest; // Base slot for the pending tagged log array to which the fetch_tagged_logs oracle inserts found private logs. @@ -44,6 +47,13 @@ global LOG_RETRIEVAL_RESPONSES_ARRAY_BASE_SLOT: Field = sha256_to_field( "AZTEC_NR::LOG_RETRIEVAL_RESPONSES_ARRAY_BASE_SLOT".as_bytes(), ); +/// An offchain-delivered message with resolved context, ready for processing during sync. +#[derive(Serialize, Deserialize)] +pub struct OffchainMessageWithContext { + pub message_ciphertext: BoundedVec, + pub message_context: MessageContext, +} + /// Searches for private logs emitted by `contract_address` that might contain messages for one of the local accounts, /// and stores them in a `CapsuleArray` which is then returned. pub(crate) unconstrained fn get_private_logs(contract_address: AztecAddress) -> CapsuleArray { @@ -148,6 +158,23 @@ pub unconstrained fn validate_and_store_enqueued_notes_and_events(contract_addre ); } +/// Resolves message contexts for a list of tx hashes stored in a CapsuleArray. +/// +/// The `message_context_requests_array_base_slot` must point to a CapsuleArray containing tx hashes. +/// PXE will store `Option` values into the responses array at +/// `message_context_responses_array_base_slot`. +pub unconstrained fn resolve_message_contexts( + contract_address: AztecAddress, + message_context_requests_array_base_slot: Field, + message_context_responses_array_base_slot: Field, +) { + oracle::message_processing::resolve_message_contexts( + contract_address, + message_context_requests_array_base_slot, + message_context_responses_array_base_slot, + ); +} + /// Efficiently queries the node for logs that result in the completion of all `DeliveredPendingPartialNote`s stored in /// a `CapsuleArray` by performing all node communication concurrently. Returns a second `CapsuleArray` with Options /// for the responses that correspond to the pending partial notes at the same index. diff --git a/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr new file mode 100644 index 000000000000..7e93d7d896ba --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/messages/processing/offchain.nr @@ -0,0 +1,551 @@ +use crate::{ + capsules::CapsuleArray, + context::UtilityContext, + messages::{ + encoding::MESSAGE_CIPHERTEXT_LEN, + processing::{MessageContext, MessageTxContext, OffchainMessageWithContext, resolve_message_contexts}, + }, + protocol::{ + address::AztecAddress, + constants::MAX_TX_LIFETIME, + hash::sha256_to_field, + traits::{Deserialize, Serialize}, + }, +}; + +/// Base capsule slot for the persistent inbox of [`PendingOffchainMsg`] entries. +/// +/// This is the slot where we accumulate messages received through [`receive`]. +global OFFCHAIN_INBOX_SLOT: Field = sha256_to_field("AZTEC_NR::OFFCHAIN_INBOX_SLOT".as_bytes()); + +/// Capsule array slot used by [`sync_inbox`] to pass tx hash resolution requests to PXE. +global OFFCHAIN_CONTEXT_REQUESTS_SLOT: Field = sha256_to_field("AZTEC_NR::OFFCHAIN_CONTEXT_REQUESTS_SLOT".as_bytes()); + +/// Capsule array slot used by [`sync_inbox`] to read tx context responses from PXE. +global OFFCHAIN_CONTEXT_RESPONSES_SLOT: Field = sha256_to_field("AZTEC_NR::OFFCHAIN_CONTEXT_RESPONSES_SLOT".as_bytes()); + +/// Capsule array slot used by [`sync_inbox`] to collect messages ready for processing. +global OFFCHAIN_READY_MESSAGES_SLOT: Field = sha256_to_field("AZTEC_NR::OFFCHAIN_READY_MESSAGES_SLOT".as_bytes()); + +/// Maximum number of offchain messages accepted by `offchain_receive` in a single call. +pub global MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL: u32 = 16; + +/// Tolerance added to the `MAX_TX_LIFETIME` cap for message expiration. +global TX_EXPIRATION_TOLERANCE: u64 = 7200; // 2 hours + +/// Maximum time-to-live for a tx-bound offchain message: `MAX_TX_LIFETIME + TX_EXPIRATION_CAP_TOLERANCE`. +/// +/// After `received_at + MAX_MSG_TTL`, the message is evicted regardless of the sender-provided expiration. +global MAX_MSG_TTL: u64 = MAX_TX_LIFETIME + TX_EXPIRATION_TOLERANCE; + +/// A function that manages offchain-delivered messages for processing during sync. +/// +/// Offchain messages are messages that are not broadcasted via onchain logs. They are instead delivered to the +/// recipient by calling the `offchain_receive` utility function (injected by the `#[aztec]` macro). Message transport +/// is the app's responsibility. Typical examples of transport methods are: messaging apps, email, QR codes, etc. +/// +/// Once offchain messages are delivered to the recipient's private environment via `offchain_receive`, messages are +/// locally stored in a persistent inbox. +/// +/// This function determines when each message in said inbox is ready for processing, when it can be safely disposed +/// of, etc. +/// +/// The only current implementation of an `OffchainInboxSync` is [`sync_inbox`], which manages an inbox with expiration +/// based eviction and automatic transaction context resolution. +pub(crate) type OffchainInboxSync = unconstrained fn[Env]( +/* contract_address */AztecAddress) -> CapsuleArray; + +/// A message delivered via the `offchain_receive` utility function. +pub struct OffchainMessage { + /// The encrypted message payload. + pub ciphertext: BoundedVec, + /// The intended recipient of the message. + pub recipient: AztecAddress, + /// The hash of the transaction that produced this message. `Option::none` indicates a tx-less message. + pub tx_hash: Option, + /// The timestamp after which this message can be evicted from the inbox. + pub expiration_timestamp: u64, +} + +/// An offchain message awaiting processing (or re-processing) in the inbox. +/// +/// Messages remain in the inbox until they expire, even if they have already been processed. This is necessary to +/// handle reorgs: a processed message may need to be re-processed if the transaction that provided its context is +/// reverted. On each sync, resolved messages are promoted to [`OffchainMessageWithContext`] for processing. +#[derive(Serialize, Deserialize)] +struct PendingOffchainMsg { + /// The encrypted message payload. + ciphertext: BoundedVec, + /// The intended recipient of the message. + recipient: AztecAddress, + /// The hash of the transaction that produced this message. A value of 0 indicates a tx-less message. + tx_hash: Field, + /// The timestamp after which this message can be evicted from the inbox (as provided by the sender). + expiration_timestamp: u64, + /// The timestamp at which this message was received (i.e. added to the inbox via `receive`). + /// + /// Used to cap the effective expiration of messages with an associated transaction: since a tx can only live for + /// `MAX_TX_LIFETIME`, we don't need to keep the message around longer than `received_at + MAX_MSG_TTL`, + /// regardless of the sender-provided `expiration_timestamp`. + received_at: u64, +} + +/// Delivers offchain messages to the given contract's offchain inbox for subsequent processing. +/// +/// Offchain messages are transaction effects that are not broadcasted via onchain logs. Instead, the sender shares the +/// message to the recipient through an external channel (e.g. a URL accessible by the recipient). The recipient then +/// calls this function to hand the messages to the contract so they can be processed through the same mechanisms as +/// onchain messages. +/// +/// Messages are processed when their originating transaction is found onchain (providing the context needed to +/// validate resulting notes and events). +/// +/// Messages are kept in the inbox until their expiration timestamp is reached. For messages with an associated +/// transaction, the effective expiration is capped to `received_at + MAX_MSG_TTL`, since there's no point in +/// keeping a message around longer than its originating transaction could possibly live. +/// +/// Processing order is not guaranteed. +pub unconstrained fn receive( + contract_address: AztecAddress, + messages: BoundedVec, +) { + let inbox: CapsuleArray = CapsuleArray::at(contract_address, OFFCHAIN_INBOX_SLOT); + let now = UtilityContext::new().timestamp(); + let mut i = 0; + let messages_len = messages.len(); + while i < messages_len { + let msg = messages.get(i); + let tx_hash = if msg.tx_hash.is_some() { + msg.tx_hash.unwrap() + } else { + 0 + }; + inbox.push( + PendingOffchainMsg { + ciphertext: msg.ciphertext, + recipient: msg.recipient, + tx_hash, + expiration_timestamp: msg.expiration_timestamp, + received_at: now, + }, + ); + i += 1; + } + + // TODO: Invoke an oracle to invalidate contract sync state cache +} + +/// Returns offchain-delivered messages to process during sync. +/// +/// Messages remain in the inbox and are reprocessed on each sync until their originating transaction is no longer at +/// risk of being dropped by a reorg. +pub unconstrained fn sync_inbox(address: AztecAddress) -> CapsuleArray { + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + let context_resolution_requests: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_CONTEXT_REQUESTS_SLOT); + let resolved_contexts: CapsuleArray> = + CapsuleArray::at(address, OFFCHAIN_CONTEXT_RESPONSES_SLOT); + let ready_to_process: CapsuleArray = + CapsuleArray::at(address, OFFCHAIN_READY_MESSAGES_SLOT); + + // Clear any stale ready messages from a previous run. + ready_to_process.for_each(|i, _| { ready_to_process.remove(i); }); + + // Clear any stale context resolution requests/responses from a previous run. + context_resolution_requests.for_each(|i, _| { context_resolution_requests.remove(i); }); + resolved_contexts.for_each(|i, _| { resolved_contexts.remove(i); }); + + // Build a request list aligned with the inbox indices. + let mut i = 0; + let inbox_len = inbox.len(); + while i < inbox_len { + let msg = inbox.get(i); + context_resolution_requests.push(msg.tx_hash); + i += 1; + } + + // Ask PXE to resolve contexts for all requested tx hashes. + resolve_message_contexts( + address, + OFFCHAIN_CONTEXT_REQUESTS_SLOT, + OFFCHAIN_CONTEXT_RESPONSES_SLOT, + ); + + assert_eq(resolved_contexts.len(), inbox_len); + + let now = UtilityContext::new().timestamp(); + + let mut j = inbox_len; + while j > 0 { + // This loop decides what to do with each message in the offchain message inbox. We need to handle 3 + // different scenarios for each message. + // + // 1. The TX that emitted this message is still not known to PXE: in this case we can't yet process this + // message, as any notes or events discovered will fail to be validated. So we leave the message in the inbox, + // awaiting for future syncs to detect that the TX became available. + // + // 2. The message is not associated to a TX to begin with. The current version of offchain message processing + // does not support this case, but in the future it will. Right now, a message without an associated TX will + // sit in the inbox until it expires. + // + // 3. The TX that emitted this message has been found by PXE. That gives us all the information needed to + // process the message. We add the message to the `ready_to_process` CapsuleArray so that the `sync_state` loop + // processes it. + // + // In all cases, if the message has expired, or the associated TX is older than than `MAX_TX_LIFETIME` + // (24 hours), we remove it from the inbox. + // + // Note: the loop runs backwards because it might call `inbox.remove(j)` to purge expired messages and we also + // need to align it with `resolved_contexts.get(j)`. Going from last to first simplifies the algorithm as + // not yet visited element indexes remain stable. + j -= 1; + let maybe_ctx = resolved_contexts.get(j); + let msg = inbox.get(j); + + // Compute the message's effective expiration timestamp to determine if we can purge it from the inbox. + let effective_expiration = if msg.tx_hash != 0 { + // For messages with an associated transaction, we cap the sender-provided expiration timestamp to + // `received_at + MAX_MSG_TTL`: there's no point in keeping a message once the TX + // can no longer possibly be included in a block. + let tx_lifetime_cap = msg.received_at + MAX_MSG_TTL; + if msg.expiration_timestamp < tx_lifetime_cap { + msg.expiration_timestamp + } else { + tx_lifetime_cap + } + } else { + // Messages with no associated TX expire on their `expiration_timestamp` + msg.expiration_timestamp + }; + + // Message expired. We remove it from the inbox. + if now > effective_expiration { + inbox.remove(j); + } + + // Scenario 1: associated TX not yet available. We keep the message in the inbox, as it might become + // processable as new blocks get mined. + // Scenario 2: no TX associated to message. The message will sit in the inbox until it expires. + if maybe_ctx.is_none() { + continue; + } + + // Scenario 3: Message is ready to process, add to result array. Note we still keep it in the inbox unless we + // consider it has expired: this is because we need to account for reorgs. If reorg occurs after we processed + // a message, the effects of processing the message get rewind. However, the associated TX can be included in + // a subsequent block. Should that happen, the message must be re-processed to ensure consistency. + let ctx = maybe_ctx.unwrap(); + let message_context = MessageContext { + tx_hash: ctx.tx_hash, + unique_note_hashes_in_tx: ctx.unique_note_hashes_in_tx, + first_nullifier_in_tx: ctx.first_nullifier_in_tx, + recipient: msg.recipient, + }; + ready_to_process.push(OffchainMessageWithContext { message_ciphertext: msg.ciphertext, message_context }); + } + + ready_to_process +} + +mod test { + use crate::{ + capsules::CapsuleArray, + oracle::random::random, + protocol::{address::AztecAddress, traits::FromField}, + test::helpers::test_environment::TestEnvironment, + }; + use super::{ + MAX_MSG_TTL, MAX_OFFCHAIN_MESSAGES_PER_RECEIVE_CALL, OFFCHAIN_INBOX_SLOT, OffchainMessage, PendingOffchainMsg, + receive, sync_inbox, + }; + + /// Creates an `OffchainMessage` with dummy ciphertext/recipient. + fn make_msg(tx_hash: Option, expiration_timestamp: u64) -> OffchainMessage { + OffchainMessage { + ciphertext: BoundedVec::new(), + recipient: AztecAddress::from_field(42), + tx_hash, + expiration_timestamp, + } + } + + /// Advances the TXE block timestamp by `offset` seconds and returns the resulting timestamp. + unconstrained fn advance_by(env: TestEnvironment, offset: u64) -> u64 { + env.advance_next_block_timestamp_by(offset); + env.mine_block(); + env.last_block_timestamp() + } + + #[test] + unconstrained fn empty_inbox_returns_empty_result() { + let env = TestEnvironment::new(); + env.utility_context(|context| { + let result = sync_inbox(context.this_address()); + let inbox: CapsuleArray = + CapsuleArray::at(context.this_address(), OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); + assert_eq(inbox.len(), 0); + }); + } + + #[test] + unconstrained fn tx_bound_msg_expires_by_sender_timestamp() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + // Msg expiration attribute is well before the lifetime cap, so it is the effective expiration. + let sender_expiration_offset = 100; + + // Receive message with expiration at receive_time + offset. + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs + .push( + make_msg( + Option::some(random()), + receive_time + sender_expiration_offset, + ), + ); + receive(context.this_address(), msgs); + }); + + // Advance past sender expiration but before the lifetime cap. + let _now = advance_by(env, sender_expiration_offset + 1); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); // context is None, not ready + assert_eq(inbox.len(), 0); // expired, removed + }); + } + + #[test] + unconstrained fn tx_bound_msg_expires_by_lifetime_cap() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + // Sender's expiration is far in the future, so the standard TX lifetime cap kicks in. + msgs.push(make_msg(Option::some(random()), receive_time + 10 * MAX_MSG_TTL)); + receive(context.this_address(), msgs); + }); + + // Advance past the lifetime cap. + let _now = advance_by(env, MAX_MSG_TTL + 1); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); + assert_eq(inbox.len(), 0); // capped expiration exceeded, removed + }); + } + + #[test] + unconstrained fn tx_bound_msg_not_expired_sender_timestamp_binding() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + // Sender expiration is in the future and below the cap, so it is the binding expiration. + let sender_expiration_offset = 100; + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs + .push( + make_msg( + Option::some(random()), + receive_time + sender_expiration_offset, + ), + ); + receive(context.this_address(), msgs); + }); + + // Advance, but not past sender expiration. + let _now = advance_by(env, sender_expiration_offset - 1); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); // context is None, not ready + assert_eq(inbox.len(), 1); // not expired, stays + }); + } + + #[test] + unconstrained fn tx_bound_msg_not_expired_lifetime_cap_binding() { + let env = TestEnvironment::new(); + + // Sender expiration is way beyond the cap, but `now` is still before the cap. + let receive_time = advance_by(env, 10); + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs.push(make_msg(Option::some(random()), receive_time + 10 * MAX_MSG_TTL)); + receive(context.this_address(), msgs); + }); + + // Advance, but not past the lifetime cap. + let _now = advance_by(env, 100); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); // context is None, not ready + assert_eq(inbox.len(), 1); // not expired, stays + }); + } + + #[test] + unconstrained fn tx_less_msg_expires_by_sender_timestamp() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + let sender_expiration_offset = 100; + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs.push(make_msg(Option::none(), receive_time + sender_expiration_offset)); + receive(context.this_address(), msgs); + }); + + let _now = advance_by(env, sender_expiration_offset + 1); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); + assert_eq(inbox.len(), 0); // expired, removed + }); + } + + #[test] + unconstrained fn tx_less_msg_with_large_expiration_is_not_capped() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs.push(make_msg(Option::none(), receive_time + 10 * MAX_MSG_TTL)); + receive(context.this_address(), msgs); + }); + + // Advance past what the lifetime cap would be for a tx-bound message. + let _now = advance_by(env, MAX_MSG_TTL + 1); + + // The message is tx-less, so the lifetime cap does not apply and the sender's large + // expiration is trusted. Even though `now` is past what the cap would be for a tx-bound + // message, the message stays. + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); // context is None, not ready + assert_eq(inbox.len(), 1); // tx-less, not capped, stays + }); + } + + #[test] + unconstrained fn unresolved_tx_stays_in_inbox() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs.push(make_msg(Option::some(random()), receive_time + MAX_MSG_TTL)); + receive(context.this_address(), msgs); + }); + + let _now = advance_by(env, 100); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); // not resolved, not ready + assert_eq(inbox.len(), 1); // not expired, stays + }); + } + + #[test] + unconstrained fn multiple_messages_mixed_expiration() { + let env = TestEnvironment::new(); + let receive_time = advance_by(env, 10); + + let survivor_tx_hash = random(); + + env.utility_context(|context| { + let address = context.this_address(); + let mut msgs: BoundedVec = BoundedVec::new(); + // Message 0: tx-bound, will expire after 50s. + msgs.push(make_msg(Option::some(random()), receive_time + 50)); + // Message 1: tx-bound, will expire after 50_000s (the survivor). + msgs.push(make_msg(Option::some(survivor_tx_hash), receive_time + 50_000)); + // Message 2: tx-less, will expire after 50s. + msgs.push(make_msg(Option::none(), receive_time + 50)); + receive(address, msgs); + }); + + // Advance past 50s but before 50_000s. + let _now = advance_by(env, 51); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + assert_eq(result.len(), 0); // all contexts are None + // Only message 1 should remain: messages 0 and 2 expired. + assert_eq(inbox.len(), 1); + assert_eq(inbox.get(0).tx_hash, survivor_tx_hash); + }); + } + + // -- Resolved context (ready to process) ------------------------------ + + #[test] + unconstrained fn resolved_msg_is_ready_to_process() { + let env = TestEnvironment::new(); + // TestEnvironment::new() deploys protocol contracts, creating blocks with tx effects. + // In TXE, tx hashes equal Fr(blockNumber), so Fr(1) is the tx effect from block 1. + // We use this as a "known resolvable" tx hash. + let known_tx_hash: Field = 1; + let receive_time = advance_by(env, 10); + + env.utility_context(|context| { + let mut msgs: BoundedVec = BoundedVec::new(); + msgs.push(make_msg(Option::some(known_tx_hash), receive_time + MAX_MSG_TTL)); + receive(context.this_address(), msgs); + }); + + let _now = advance_by(env, 100); + + env.utility_context(|context| { + let address = context.this_address(); + let result = sync_inbox(address); + let inbox: CapsuleArray = CapsuleArray::at(address, OFFCHAIN_INBOX_SLOT); + + // The message should be ready to process since its tx context was resolved. + assert_eq(result.len(), 1); + + let ctx = result.get(0).message_context; + assert_eq(ctx.tx_hash, known_tx_hash); + assert_eq(ctx.recipient, AztecAddress::from_field(42)); + assert(ctx.first_nullifier_in_tx != 0, "resolved context must have a first nullifier"); + + // Message stays in inbox (not expired) for potential reorg reprocessing. + assert_eq(inbox.len(), 1); + }); + } +} 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 d0582872b23d..94cd8432d90e 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/message_processing.nr @@ -48,3 +48,22 @@ unconstrained fn bulk_retrieve_logs_oracle( log_retrieval_requests_array_base_slot: Field, log_retrieval_responses_array_base_slot: Field, ) {} + +pub(crate) unconstrained fn resolve_message_contexts( + contract_address: AztecAddress, + message_context_requests_array_base_slot: Field, + message_context_responses_array_base_slot: Field, +) { + resolve_message_contexts_oracle( + contract_address, + message_context_requests_array_base_slot, + message_context_responses_array_base_slot, + ); +} + +#[oracle(utilityResolveMessageContexts)] +unconstrained fn resolve_message_contexts_oracle( + contract_address: AztecAddress, + message_context_requests_array_base_slot: Field, + message_context_responses_array_base_slot: Field, +) {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index b979532dc7dc..aa288ba56c0f 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -4,7 +4,7 @@ /// /// @dev Whenever a contract function or Noir test is run, the `utilityAssertCompatibleOracleVersion` oracle is called /// and if the oracle version is incompatible an error is thrown. -pub global ORACLE_VERSION: Field = 12; +pub global ORACLE_VERSION: Field = 13; /// 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/test_environment.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr index 7d27bfc02aa6..c4c3e072f703 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 @@ -905,6 +905,7 @@ impl TestEnvironment { process_message_plaintext( context.this_address(), compute_note_hash_and_nullifier, + Option::>::none(), message_plaintext, message_context, ); diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index 7b7c76bf8bad..8fb553e44a1e 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -50,6 +50,7 @@ members = [ "contracts/test/no_constructor_contract", "contracts/test/note_getter_contract", "contracts/test/offchain_effect_contract", + "contracts/test/offchain_payment_contract", "contracts/test/only_self_contract", "contracts/test/option_param_contract", "contracts/test/oracle_version_check_contract", diff --git a/noir-projects/noir-contracts/contracts/test/offchain_payment_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/test/offchain_payment_contract/Nargo.toml new file mode 100644 index 000000000000..3cc953ef4a37 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/test/offchain_payment_contract/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "offchain_payment_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../../aztec-nr/aztec" } +balance_set = { path = "../../../../aztec-nr/balance-set" } diff --git a/noir-projects/noir-contracts/contracts/test/offchain_payment_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/offchain_payment_contract/src/main.nr new file mode 100644 index 000000000000..d896eff05ad2 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/test/offchain_payment_contract/src/main.nr @@ -0,0 +1,39 @@ +use aztec::macros::aztec; + +#[aztec] +contract OffchainPayment { + use aztec::{ + macros::{functions::external, storage::storage}, + messages::message_delivery::MessageDelivery, + protocol::address::AztecAddress, + state_vars::Owned, + }; + + use balance_set::BalanceSet; + + #[storage] + struct Storage { + balances: Owned, Context>, + } + + #[external("private")] + fn mint(amount: u128, recipient: AztecAddress) { + // Minted notes are delivered onchain to ensure the recipient can always discover them. + self.storage.balances.at(recipient).add(amount).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + } + + #[external("private")] + fn transfer_offchain(amount: u128, recipient: AztecAddress) { + let sender = self.msg_sender(); + + // Sender change note and recipient note are both delivered offchain. + self.storage.balances.at(sender).sub(amount).deliver(MessageDelivery.OFFCHAIN); + + self.storage.balances.at(recipient).add(amount).deliver(MessageDelivery.OFFCHAIN); + } + + #[external("utility")] + unconstrained fn get_balance(owner: AztecAddress) -> u128 { + self.storage.balances.at(owner).balance_of() + } +} diff --git a/yarn-project/aztec.js/src/contract/batch_call.ts b/yarn-project/aztec.js/src/contract/batch_call.ts index 66c668340687..62a2a27ee59f 100644 --- a/yarn-project/aztec.js/src/contract/batch_call.ts +++ b/yarn-project/aztec.js/src/contract/batch_call.ts @@ -134,7 +134,7 @@ export class BatchCall extends BaseContractInteraction { results[callIndex] = { result: rawReturnValues ? decodeFromAbi(call.returnTypes, rawReturnValues) : [], - ...extractOffchainOutput(simulatedTx.offchainEffects), + ...extractOffchainOutput(simulatedTx.offchainEffects, simulatedTx.publicInputs.expirationTimestamp), }; }); } diff --git a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts index e230aa1cddf8..9b95675d3c6b 100644 --- a/yarn-project/aztec.js/src/contract/contract_function_interaction.ts +++ b/yarn-project/aztec.js/src/contract/contract_function_interaction.ts @@ -167,7 +167,10 @@ export class ContractFunctionInteraction extends BaseContractInteraction { } const returnValue = rawReturnValues ? decodeFromAbi(this.functionDao.returnTypes, rawReturnValues) : []; - const offchainOutput = extractOffchainOutput(simulatedTx.offchainEffects); + const offchainOutput = extractOffchainOutput( + simulatedTx.offchainEffects, + simulatedTx.publicInputs.expirationTimestamp, + ); if (options.includeMetadata || options.fee?.estimateGas) { const { gasLimits, teardownGasLimits } = getGasLimits(simulatedTx, options.fee?.estimatedGasPadding); diff --git a/yarn-project/aztec.js/src/contract/deploy_method.ts b/yarn-project/aztec.js/src/contract/deploy_method.ts index f91881a5245a..d8f6a2b4c881 100644 --- a/yarn-project/aztec.js/src/contract/deploy_method.ts +++ b/yarn-project/aztec.js/src/contract/deploy_method.ts @@ -404,7 +404,7 @@ export class DeployMethod extends ); return { stats: simulatedTx.stats!, - ...extractOffchainOutput(simulatedTx.offchainEffects), + ...extractOffchainOutput(simulatedTx.offchainEffects, simulatedTx.publicInputs.expirationTimestamp), result: undefined, estimatedGas: { gasLimits, teardownGasLimits }, }; diff --git a/yarn-project/aztec.js/src/contract/interaction_options.test.ts b/yarn-project/aztec.js/src/contract/interaction_options.test.ts index 7818299a3854..05b7edfdf9ff 100644 --- a/yarn-project/aztec.js/src/contract/interaction_options.test.ts +++ b/yarn-project/aztec.js/src/contract/interaction_options.test.ts @@ -5,6 +5,8 @@ import { OFFCHAIN_MESSAGE_IDENTIFIER, type OffchainEffect } from '@aztec/stdlib/ import { extractOffchainOutput } from './interaction_options.js'; describe('extractOffchainOutput', () => { + const expirationTimestamp = 1234567890n; + const makeEffect = (data: Fr[], contractAddress?: AztecAddress): OffchainEffect => ({ data, contractAddress: contractAddress ?? AztecAddress.fromField(Fr.random()), @@ -21,14 +23,14 @@ describe('extractOffchainOutput', () => { ); it('returns empty output for empty input', () => { - const result = extractOffchainOutput([]); + const result = extractOffchainOutput([], expirationTimestamp); expect(result.offchainEffects).toEqual([]); expect(result.offchainMessages).toEqual([]); }); it('keeps non-message effects as-is', () => { const effects = [makeEffect([Fr.random(), Fr.random()]), makeEffect([Fr.random()])]; - const result = extractOffchainOutput(effects); + const result = extractOffchainOutput(effects, expirationTimestamp); expect(result.offchainEffects).toEqual(effects); expect(result.offchainMessages).toEqual([]); }); @@ -39,7 +41,7 @@ describe('extractOffchainOutput', () => { const contractAddress = await AztecAddress.random(); const effect = await makeMessageEffect(recipient, payload, contractAddress); - const result = extractOffchainOutput([effect]); + const result = extractOffchainOutput([effect], expirationTimestamp); expect(result.offchainEffects).toEqual([]); expect(result.offchainMessages).toHaveLength(1); @@ -47,6 +49,7 @@ describe('extractOffchainOutput', () => { recipient, payload, contractAddress, + expirationTimestamp, }); }); @@ -55,7 +58,7 @@ describe('extractOffchainOutput', () => { const plainEffect2 = makeEffect([Fr.random(), Fr.random()]); const messageEffect = await makeMessageEffect(); - const result = extractOffchainOutput([plainEffect1, messageEffect, plainEffect2]); + const result = extractOffchainOutput([plainEffect1, messageEffect, plainEffect2], expirationTimestamp); expect(result.offchainEffects).toEqual([plainEffect1, plainEffect2]); expect(result.offchainMessages).toHaveLength(1); @@ -65,7 +68,7 @@ describe('extractOffchainOutput', () => { const msg1 = await makeMessageEffect(); const msg2 = await makeMessageEffect(); - const result = extractOffchainOutput([msg1, msg2]); + const result = extractOffchainOutput([msg1, msg2], expirationTimestamp); expect(result.offchainEffects).toEqual([]); expect(result.offchainMessages).toHaveLength(2); @@ -73,7 +76,7 @@ describe('extractOffchainOutput', () => { it('does not treat an effect as a message if data has only the identifier (no recipient)', () => { const effect = makeEffect([OFFCHAIN_MESSAGE_IDENTIFIER]); - const result = extractOffchainOutput([effect]); + const result = extractOffchainOutput([effect], expirationTimestamp); expect(result.offchainEffects).toEqual([effect]); expect(result.offchainMessages).toEqual([]); diff --git a/yarn-project/aztec.js/src/contract/interaction_options.ts b/yarn-project/aztec.js/src/contract/interaction_options.ts index 005908dd35b8..6dd4725c04c6 100644 --- a/yarn-project/aztec.js/src/contract/interaction_options.ts +++ b/yarn-project/aztec.js/src/contract/interaction_options.ts @@ -147,6 +147,8 @@ export type OffchainMessage = { payload: Fr[]; /** The contract that emitted the message. */ contractAddress: AztecAddress; + /** The timestamp by which this message expires and can be evicted from the inbox. */ + expirationTimestamp: bigint; }; /** Groups all unproven outputs from private execution that are returned to the client. */ @@ -162,7 +164,7 @@ export type OffchainOutput = { * Effects whose data starts with `OFFCHAIN_MESSAGE_IDENTIFIER` are parsed as messages and removed * from the effects array. */ -export function extractOffchainOutput(effects: OffchainEffect[]): OffchainOutput { +export function extractOffchainOutput(effects: OffchainEffect[], txExpirationTimestamp: bigint): OffchainOutput { const offchainEffects: OffchainEffect[] = []; const offchainMessages: OffchainMessage[] = []; @@ -172,6 +174,7 @@ export function extractOffchainOutput(effects: OffchainEffect[]): OffchainOutput recipient: AztecAddress.fromField(effect.data[1]), payload: effect.data.slice(2), contractAddress: effect.contractAddress, + expirationTimestamp: txExpirationTimestamp, }); } else { offchainEffects.push(effect); diff --git a/yarn-project/aztec.js/src/scripts/generate_protocol_contract_types.ts b/yarn-project/aztec.js/src/scripts/generate_protocol_contract_types.ts index 9198556de7cc..6e0f31e72f49 100644 --- a/yarn-project/aztec.js/src/scripts/generate_protocol_contract_types.ts +++ b/yarn-project/aztec.js/src/scripts/generate_protocol_contract_types.ts @@ -99,7 +99,7 @@ import { FunctionType } from '@aztec/stdlib/abi'; import type { ContractArtifact } from '../../api/abi.js'; import { PublicKeys } from '../../api/keys.js'; -import type { AztecAddressLike, EthAddressLike, FieldLike, FunctionSelectorLike, WrappedFieldLike } from '../../utils/abi_types.js'; +import type { AztecAddressLike, EthAddressLike, FieldLike, FunctionSelectorLike, OptionLike, WrappedFieldLike } from '../../utils/abi_types.js'; import { ContractBase, type ContractMethod } from '../contract_base.js'; import { ContractFunctionInteraction } from '../contract_function_interaction.js'; import type { Wallet } from '../../wallet/wallet.js'; diff --git a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts new file mode 100644 index 000000000000..ffe2fd7ce271 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts @@ -0,0 +1,182 @@ +/* eslint-disable camelcase */ +import { AztecAddress } from '@aztec/aztec.js/addresses'; +import { NO_WAIT, extractOffchainOutput } from '@aztec/aztec.js/contracts'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { CheatCodes } from '@aztec/aztec/testing'; +import type { BlockNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; +import { OffchainPaymentContract } from '@aztec/noir-test-contracts.js/OffchainPayment'; +import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; + +import { jest } from '@jest/globals'; + +import { getLogger, setup } from './fixtures/utils.js'; +import type { TestWallet } from './test-wallet/test_wallet.js'; +import { proveInteraction } from './test-wallet/utils.js'; + +const TIMEOUT = 120_000; + +describe('e2e_offchain_payment', () => { + let contract: OffchainPaymentContract; + let aztecNode: AztecNode; + let aztecNodeAdmin: AztecNodeAdmin; + let cheatCodes: CheatCodes; + let wallet: TestWallet; + let accounts: AztecAddress[]; + let teardown: () => Promise; + const logger = getLogger(); + + jest.setTimeout(TIMEOUT); + + beforeAll(async () => { + ({ teardown, wallet, accounts, aztecNode, aztecNodeAdmin, cheatCodes } = await setup(2)); + }); + + afterAll(() => teardown()); + + beforeEach(async () => { + ({ contract } = await OffchainPaymentContract.deploy(wallet).send({ from: accounts[0] })); + }); + + async function forceEmptyBlock() { + const blockBefore = await aztecNode.getBlockNumber(); + logger.info(`Forcing empty block. Current L2 block: ${blockBefore}`); + await aztecNodeAdmin.setConfig({ minTxsPerBlock: 0 }); + await retryUntil( + async () => { + const current = await aztecNode.getBlockNumber(); + logger.info(`Waiting for new L2 block. Current: ${current}`); + return current > blockBefore; + }, + 'new L2 block', + 30, + 1, + ); + await aztecNodeAdmin.setConfig({ minTxsPerBlock: 1 }); + } + + async function forceReorg(block: BlockNumber) { + await retryUntil( + async () => { + const tips = await aztecNode.getL2Tips(); + return tips.checkpointed.block.number >= block; + }, + 'checkpointed block', + 30, + 1, + ); + + await aztecNodeAdmin.pauseSync(); + + await cheatCodes.eth.reorg(1); + await aztecNodeAdmin.rollbackTo(Number(block) - 1); + expect(await aztecNode.getBlockNumber()).toBe(Number(block) - 1); + + await aztecNodeAdmin.resumeSync(); + } + + it('processes an offchain-delivered private payment via QR-style handoff', async () => { + const [alice, bob] = accounts; + + const mintAmount = 100n; + const paymentAmount = 40n; + + // Mint to Alice using onchain delivery so she can spend the note. + await contract.methods.mint(mintAmount, alice).send({ from: alice }); + + // Alice sends the private transfer which emits offchain messages. + const { receipt, offchainMessages } = await contract.methods + .transfer_offchain(paymentAmount, bob) + .send({ from: alice }); + expect(offchainMessages.length).toBeGreaterThan(0); + + const messageForBob = offchainMessages.find(msg => msg.recipient.equals(bob)); + expect(messageForBob).toBeTruthy(); + + await contract.methods + .offchain_receive([ + { + ciphertext: messageForBob!.payload, + recipient: bob, + tx_hash: receipt.txHash.hash, + expiration_timestamp: messageForBob!.expirationTimestamp, + }, + ]) + .simulate({ from: bob }); + + // Force an empty block so the PXE re-syncs and discovers the offchain-delivered notes. + await forceEmptyBlock(); + + // TODO(F-413): we need to implement scopes on capsules so we can check Alice's balance too here. This is not + // possible right now because the offchain inbox is shared for all accounts using this contract in the same PXE, + // which is bad. + const { result: bobBalance } = await contract.methods.get_balance(bob).simulate({ from: bob }); + expect(bobBalance).toBe(paymentAmount); + }); + + it('reprocesses an offchain-delivered payment after an L1 reorg', async () => { + const [alice, bob] = accounts; + const mintAmount = 100n; + const paymentAmount = 40n; + + await contract.methods.mint(mintAmount, alice).send({ from: alice }); + + const provenTx = await proveInteraction(wallet, contract.methods.transfer_offchain(paymentAmount, bob), { + from: alice, + }); + + const receipt = await provenTx.send(); + expect(receipt.blockNumber).toBeDefined(); + + const txBlockNumber = receipt.blockNumber!; + const txHash = provenTx.getTxHash(); + + const { offchainMessages } = extractOffchainOutput(provenTx.offchainEffects, provenTx.data.expirationTimestamp); + const messageForBob = offchainMessages.find(msg => msg.recipient.equals(bob)); + expect(messageForBob).toBeTruthy(); + + // Deliver the offchain message for eventual processing + await contract.methods + .offchain_receive([ + { + ciphertext: messageForBob!.payload, + recipient: bob, + tx_hash: txHash.hash, + expiration_timestamp: messageForBob!.expirationTimestamp, + }, + ]) + .simulate({ from: bob }); + + // TODO: revisit this. The call to offchain_receive is a utility and as such it causes the contract to sync, which, + // in combination with our caching policies, means subsequent utility calls won't trigger a re-sync. + // Given we're hooking the offchain sync process to the general sync process, this means we won't process any new + // offchain messages until at least one block passes. + // A potential escape hatch for this is to remove the check that forbids external invocation of `sync_state`. + // That would let users trigger syncs manually to circumvent caching issues like this. + await forceEmptyBlock(); + + // Check that Bob got the payment before a re-org + const { result: bobBalance } = await contract.methods.get_balance(bob).simulate({ from: bob }); + expect(bobBalance).toBe(paymentAmount); + + await forceReorg(txBlockNumber); + + // Verify that the payment TX is no longer present after the reorg + const txEffectAfterRollback = await aztecNode.getTxEffect(txHash); + expect(txEffectAfterRollback).toBeFalsy(); + + // Verify that Bob's balance has rolled back to 0 (pre-payment value) after the reorg + const { result: bobAfterRollback } = await contract.methods.get_balance(bob).simulate({ from: bob }); + expect(bobAfterRollback).toBe(0n); + + // Resend the tx after the reorg and force block production so the sequencer picks it up. + await provenTx.send({ wait: NO_WAIT }); + await forceEmptyBlock(); + + // Check that the message was reprocessed and Bob has his payment again. + // Notice what we want to test here is that the offchain effects don't need to be re-enqueued + // for the system to re-process it. + const { result: bobBalanceAfterResentTx } = await contract.methods.get_balance(bob).simulate({ from: bob }); + expect(bobBalanceAfterResentTx).toBe(paymentAmount); + }); +}); diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index e4e783f26b02..2e4d43343f57 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -89,6 +89,7 @@ import { import type { AccessScopes } from '../access_scopes.js'; import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; +import type { MessageContextService } from '../messages/message_context_service.js'; import type { AddressStore } from '../storage/address_store/address_store.js'; import type { CapsuleStore } from '../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; @@ -138,6 +139,7 @@ export type ContractFunctionSimulatorArgs = { privateEventStore: PrivateEventStore; simulator: CircuitSimulator; contractSyncService: ContractSyncService; + messageContextService: MessageContextService; }; /** @@ -157,6 +159,7 @@ export class ContractFunctionSimulator { private readonly privateEventStore: PrivateEventStore; private readonly simulator: CircuitSimulator; private readonly contractSyncService: ContractSyncService; + private readonly messageContextService: MessageContextService; constructor(args: ContractFunctionSimulatorArgs) { this.contractStore = args.contractStore; @@ -171,6 +174,7 @@ export class ContractFunctionSimulator { this.privateEventStore = args.privateEventStore; this.simulator = args.simulator; this.contractSyncService = args.contractSyncService; + this.messageContextService = args.messageContextService; this.log = createLogger('simulator'); } @@ -241,6 +245,7 @@ export class ContractFunctionSimulator { senderAddressBookStore: this.senderAddressBookStore, capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, + messageContextService: this.messageContextService, contractSyncService: this.contractSyncService, jobId, totalPublicCalldataCount: 0, @@ -335,6 +340,7 @@ export class ContractFunctionSimulator { senderAddressBookStore: this.senderAddressBookStore, capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, + messageContextService: this.messageContextService, jobId, scopes, }); diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 7ac8010eec78..4a7c7b2b357d 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -4,6 +4,7 @@ export { HashedValuesCache } from './hashed_values_cache.js'; export { pickNotes } from './pick_notes.js'; export type { NoteData, IMiscOracle, IUtilityExecutionOracle, IPrivateExecutionOracle } from './oracle/interfaces.js'; export { MessageLoadOracleInputs } from './oracle/message_load_oracle_inputs.js'; +export { MessageContextService } from '../messages/message_context_service.js'; export { UtilityExecutionOracle } from './oracle/utility_execution_oracle.js'; export { PrivateExecutionOracle } from './oracle/private_execution_oracle.js'; export { Oracle } from './oracle/oracle.js'; diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/message_tx_context.test.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/message_tx_context.test.ts new file mode 100644 index 000000000000..72e8f5c01c9a --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/message_tx_context.test.ts @@ -0,0 +1,172 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { TxHash } from '@aztec/stdlib/tx'; + +import { MessageTxContext } from './message_tx_context.js'; + +describe('MessageTxContext', () => { + it('serialization of some matches snapshot', () => { + const txHash = new TxHash(new Fr(123)); + const uniqueNoteHashes = [new Fr(4n), new Fr(5n)]; + const firstNullifier = new Fr(6n); + const ctx = new MessageTxContext(txHash, uniqueNoteHashes, firstNullifier); + const serialized = MessageTxContext.toSerializedOption(ctx); + expect(serialized.map(f => f.toString())).toMatchInlineSnapshot( + ` + [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "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", + ] + `, + ); + }); + it('serialization of none matches snapshot', () => { + const serialized = MessageTxContext.toSerializedOption(null); + expect(serialized.map(f => f.toString())).toMatchInlineSnapshot( + ` + [ + "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", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ] + `, + ); + }); + it('serialization length of empty matches', () => { + const txHash = new TxHash(new Fr(123)); + const uniqueNoteHashes = [new Fr(4n), new Fr(5n)]; + const firstNullifier = new Fr(6n); + const ctx = new MessageTxContext(txHash, uniqueNoteHashes, firstNullifier); + expect(ctx.toFields().length).toEqual(MessageTxContext.toEmptyFields().length); + }); +}); diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/message_tx_context.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/message_tx_context.ts new file mode 100644 index 000000000000..a379e0219f76 --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/message_tx_context.ts @@ -0,0 +1,55 @@ +import { MAX_NOTE_HASHES_PER_TX } from '@aztec/constants'; +import { range } from '@aztec/foundation/array'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { TxHash } from '@aztec/stdlib/tx'; + +/** + * Intermediate struct used to return resolved message contexts from PXE. The + * `utilityResolveMessageContexts` oracle stores values of this type in a CapsuleArray. + */ +export class MessageTxContext { + constructor( + public txHash: TxHash, + public uniqueNoteHashesInTx: Fr[], + public firstNullifierInTx: Fr, + ) {} + + toFields(): Fr[] { + return [ + this.txHash.hash, + ...serializeBoundedVec(this.uniqueNoteHashesInTx, MAX_NOTE_HASHES_PER_TX), + this.firstNullifierInTx, + ]; + } + + static toEmptyFields(): Fr[] { + const serializationLen = + 1 /* txHash */ + MAX_NOTE_HASHES_PER_TX + 1 /* uniqueNoteHashesInTx BVec */ + 1; /* firstNullifierInTx */ + return range(serializationLen).map(_ => Fr.zero()); + } + + static toSerializedOption(response: MessageTxContext | null): Fr[] { + if (response) { + return [new Fr(1), ...response.toFields()]; + } else { + return [new Fr(0), ...MessageTxContext.toEmptyFields()]; + } + } +} + +/** + * Helper function to serialize a bounded vector according to Noir's BoundedVec format + * @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[] { + if (values.length > maxLength) { + throw new Error(`Attempted to serialize ${values} values into a BoundedVec with max length ${maxLength}`); + } + + const lengthDiff = maxLength - values.length; + const zeroPaddingArray = Array(lengthDiff).fill(Fr.ZERO); + const storage = values.concat(zeroPaddingArray); + return [...storage, new Fr(values.length)]; +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts index 9e9a14805f57..5dc6d3b18368 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/interfaces.ts @@ -130,6 +130,11 @@ export interface IUtilityExecutionOracle { logRetrievalRequestsArrayBaseSlot: Fr, logRetrievalResponsesArrayBaseSlot: Fr, ): Promise; + utilityResolveMessageContexts( + contractAddress: AztecAddress, + messageContextRequestsArrayBaseSlot: Fr, + messageContextResponsesArrayBaseSlot: Fr, + ): Promise; utilityStoreCapsule(contractAddress: AztecAddress, key: Fr, capsule: Fr[]): Promise; utilityLoadCapsule(contractAddress: AztecAddress, key: Fr): Promise; utilityDeleteCapsule(contractAddress: AztecAddress, key: Fr): Promise; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts index ebfc888b4eab..ca545910b5b5 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle.ts @@ -535,6 +535,19 @@ export class Oracle { return []; } + async utilityResolveMessageContexts( + [contractAddress]: ACVMField[], + [messageContextRequestsArrayBaseSlot]: ACVMField[], + [messageContextResponsesArrayBaseSlot]: ACVMField[], + ): Promise { + await this.handlerAsUtility().utilityResolveMessageContexts( + AztecAddress.fromString(contractAddress), + Fr.fromString(messageContextRequestsArrayBaseSlot), + Fr.fromString(messageContextResponsesArrayBaseSlot), + ); + return []; + } + async utilityStoreCapsule( [contractAddress]: ACVMField[], [slot]: ACVMField[], diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts index 9a6ce8b9bc36..8878ad6b4ca0 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts @@ -13,6 +13,7 @@ import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +import type { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../../storage/contract_store/contract_store.js'; @@ -38,6 +39,7 @@ describe('Oracle Version Check test suite', () => { let capsuleStore: ReturnType>; let privateEventStore: ReturnType>; let contractSyncService: ReturnType>; + let messageContextService: ReturnType>; let acirSimulator: ContractFunctionSimulator; let contractAddress: AztecAddress; let anchorBlockHeader: BlockHeader; @@ -57,6 +59,7 @@ describe('Oracle Version Check test suite', () => { capsuleStore = mock(); privateEventStore = mock(); contractSyncService = mock(); + messageContextService = mock(); utilityAssertCompatibleOracleVersionSpy = jest.spyOn( UtilityExecutionOracle.prototype, 'utilityAssertCompatibleOracleVersion', @@ -103,6 +106,7 @@ describe('Oracle Version Check test suite', () => { privateEventStore, simulator, contractSyncService, + messageContextService, }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts index 02e5f378c460..edf5e87ea101 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts @@ -67,6 +67,7 @@ import { toFunctionSelector } from 'viem'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { syncState } from '../../contract_sync/helpers.js'; +import type { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../../storage/contract_store/contract_store.js'; @@ -124,6 +125,7 @@ describe('Private Execution test suite', () => { let capsuleStore: MockProxy; let privateEventStore: MockProxy; let contractSyncService: MockProxy; + let messageContextService: MockProxy; let acirSimulator: ContractFunctionSimulator; let anchorBlockHeader = BlockHeader.empty(); let logger: Logger; @@ -324,6 +326,8 @@ describe('Private Execution test suite', () => { privateEventStore = mock(); senderAddressBookStore = mock(); contractSyncService = mock(); + messageContextService = mock(); + messageContextService.resolveMessageContexts.mockResolvedValue([]); // Configure mock to actually perform sync_state calls (needed for nested call tests) contractSyncService.ensureContractSynced.mockImplementation( async (contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, jobId) => { @@ -491,6 +495,7 @@ describe('Private Execution test suite', () => { privateEventStore, simulator, contractSyncService, + messageContextService, }); }); 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 a1b2ada7881e..7e88d162ce7e 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 @@ -578,6 +578,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP senderAddressBookStore: this.senderAddressBookStore, capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, + messageContextService: this.messageContextService, contractSyncService: this.contractSyncService, jobId: this.jobId, totalPublicCalldataCount: this.totalPublicCalldataCount, 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 fd6cb7ef4a2a..fb182639c8c2 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 @@ -18,6 +18,7 @@ import { mock } from 'jest-mock-extended'; import type { _MockProxy } from 'jest-mock-extended/lib/Mock.js'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +import { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../../storage/contract_store/contract_store.js'; @@ -27,6 +28,7 @@ import type { RecipientTaggingStore } from '../../storage/tagging_store/recipien import type { SenderAddressBookStore } from '../../storage/tagging_store/sender_address_book_store.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { ContractFunctionSimulator } from '../contract_function_simulator.js'; +import { MessageTxContext } from '../noir-structs/message_tx_context.js'; import { UtilityExecutionOracle } from './utility_execution_oracle.js'; describe('Utility Execution test suite', () => { @@ -43,6 +45,7 @@ describe('Utility Execution test suite', () => { let capsuleStore: ReturnType>; let privateEventStore: ReturnType>; let contractSyncService: ReturnType>; + let messageContextService: MessageContextService; let acirSimulator: ContractFunctionSimulator; let owner: AztecAddress; let ownerCompleteAddress: CompleteAddress; @@ -65,6 +68,7 @@ describe('Utility Execution test suite', () => { capsuleStore = mock(); privateEventStore = mock(); contractSyncService = mock(); + messageContextService = new MessageContextService(aztecNode); const capsuleArrays = new Map(); anchorBlockHeader = BlockHeader.random(); senderTaggingStore.getLastFinalizedIndex.mockResolvedValue(undefined); @@ -103,6 +107,7 @@ describe('Utility Execution test suite', () => { privateEventStore, simulator, contractSyncService, + messageContextService, }); const ownerPartialAddress = Fr.random(); @@ -219,6 +224,7 @@ describe('Utility Execution test suite', () => { senderAddressBookStore, capsuleStore, privateEventStore, + messageContextService, jobId: 'test-job-id', scopes: 'ALL_SCOPES', }); @@ -231,5 +237,104 @@ describe('Utility Execution test suite', () => { ); }); }); + + describe('utilityResolveMessageContexts', () => { + const requestSlot = Fr.random(); + const responseSlot = Fr.random(); + + it('throws when contractAddress does not match', async () => { + const wrongAddress = await AztecAddress.random(); + await expect( + utilityExecutionOracle.utilityResolveMessageContexts(wrongAddress, requestSlot, responseSlot), + ).rejects.toThrow(`Got a message context request from ${wrongAddress}, expected ${contractAddress}`); + }); + + it('sets null in response capsule for zero tx hashes', async () => { + capsuleStore.readCapsuleArray.mockResolvedValueOnce([[Fr.ZERO]]); + + await utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot); + + const response = capsuleStore.setCapsuleArray.mock.calls.find( + call => call[0].equals(contractAddress) && call[1].equals(responseSlot), + ); + expect(response).toBeDefined(); + const responseFields = response![2][0]; + expect(responseFields).toEqual(MessageTxContext.toSerializedOption(null)); + expect(aztecNode.getTxEffect).not.toHaveBeenCalled(); + }); + + it('resolves a valid tx hash into a MessageTxContext', async () => { + const txHash = TxHash.random(); + const noteHash = Fr.random(); + const firstNullifier = Fr.random(); + + capsuleStore.readCapsuleArray.mockResolvedValueOnce([[txHash.hash]]); + aztecNode.getTxEffect.mockResolvedValueOnce({ + l2BlockNumber: BlockNumber(syncedBlockNumber - 1), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash, noteHashes: [noteHash], nullifiers: [firstNullifier] }, + } as any); + + await utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot); + + const response = capsuleStore.setCapsuleArray.mock.calls.find( + call => call[0].equals(contractAddress) && call[1].equals(responseSlot), + ); + expect(response).toBeDefined(); + const responseFields = response![2][0]; + const expected = MessageTxContext.toSerializedOption(new MessageTxContext(txHash, [noteHash], firstNullifier)); + expect(responseFields).toEqual(expected); + }); + + it('sets null in response capsule for tx effects beyond anchor block', async () => { + const txHash = TxHash.random(); + + capsuleStore.readCapsuleArray.mockResolvedValueOnce([[txHash.hash]]); + aztecNode.getTxEffect.mockResolvedValueOnce({ + l2BlockNumber: BlockNumber(syncedBlockNumber + 1), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash, noteHashes: [], nullifiers: [Fr.random()] }, + } as any); + + await utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot); + + const response = capsuleStore.setCapsuleArray.mock.calls.find( + call => call[0].equals(contractAddress) && call[1].equals(responseSlot), + ); + expect(response).toBeDefined(); + const responseFields = response![2][0]; + expect(responseFields).toEqual(MessageTxContext.toSerializedOption(null)); + }); + + it('throws on empty capsule entry', async () => { + capsuleStore.readCapsuleArray.mockResolvedValueOnce([[]]); + await expect( + utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot), + ).rejects.toThrow('Malformed message context request at index 0: expected 1 field (tx hash), got 0'); + }); + + it('throws on capsule entry with extra fields', async () => { + capsuleStore.readCapsuleArray.mockResolvedValueOnce([[Fr.random(), Fr.random()]]); + await expect( + utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot), + ).rejects.toThrow('Malformed message context request at index 0: expected 1 field (tx hash), got 2'); + }); + + it('clears the request capsule after processing', async () => { + capsuleStore.readCapsuleArray.mockResolvedValueOnce([]); + await utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot); + expect(capsuleStore.setCapsuleArray).toHaveBeenCalledWith(contractAddress, requestSlot, [], 'test-job-id'); + }); + + it('clears the request capsule even on error', async () => { + capsuleStore.readCapsuleArray.mockResolvedValueOnce([[]]); + await expect( + utilityExecutionOracle.utilityResolveMessageContexts(contractAddress, requestSlot, responseSlot), + ).rejects.toThrow(); + expect(capsuleStore.setCapsuleArray).toHaveBeenCalledWith(contractAddress, requestSlot, [], 'test-job-id'); + }); + }); }); }); 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 9e0d38befcba..3cf5295716bd 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 @@ -24,6 +24,7 @@ import type { AccessScopes } from '../../access_scopes.js'; import { createContractLogger, logContractMessage } from '../../contract_logging.js'; import { EventService } from '../../events/event_service.js'; import { LogService } from '../../logs/log_service.js'; +import { MessageContextService } from '../../messages/message_context_service.js'; import { NoteService } from '../../notes/note_service.js'; import { ORACLE_VERSION } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; @@ -36,6 +37,7 @@ import type { SenderAddressBookStore } from '../../storage/tagging_store/sender_ import { EventValidationRequest } from '../noir-structs/event_validation_request.js'; import { LogRetrievalRequest } from '../noir-structs/log_retrieval_request.js'; import { LogRetrievalResponse } from '../noir-structs/log_retrieval_response.js'; +import { MessageTxContext } from '../noir-structs/message_tx_context.js'; import { NoteValidationRequest } from '../noir-structs/note_validation_request.js'; import { UtilityContext } from '../noir-structs/utility_context.js'; import { pickNotes } from '../pick_notes.js'; @@ -58,6 +60,7 @@ export type UtilityExecutionOracleArgs = { senderAddressBookStore: SenderAddressBookStore; capsuleStore: CapsuleStore; privateEventStore: PrivateEventStore; + messageContextService: MessageContextService; jobId: string; log?: ReturnType; scopes: AccessScopes; @@ -85,6 +88,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra protected readonly senderAddressBookStore: SenderAddressBookStore; protected readonly capsuleStore: CapsuleStore; protected readonly privateEventStore: PrivateEventStore; + protected readonly messageContextService: MessageContextService; protected readonly jobId: string; protected log: ReturnType; protected readonly scopes: AccessScopes; @@ -103,6 +107,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.senderAddressBookStore = args.senderAddressBookStore; this.capsuleStore = args.capsuleStore; this.privateEventStore = args.privateEventStore; + this.messageContextService = args.messageContextService; this.jobId = args.jobId; this.log = args.log ?? createLogger('simulator:client_view_context'); this.scopes = args.scopes; @@ -552,6 +557,47 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ); } + public async utilityResolveMessageContexts( + contractAddress: AztecAddress, + messageContextRequestsArrayBaseSlot: Fr, + messageContextResponsesArrayBaseSlot: Fr, + ) { + try { + if (!this.contractAddress.equals(contractAddress)) { + throw new Error(`Got a message context request from ${contractAddress}, expected ${this.contractAddress}`); + } + const requestCapsules = await this.capsuleStore.readCapsuleArray( + contractAddress, + messageContextRequestsArrayBaseSlot, + this.jobId, + ); + + const txHashes = requestCapsules.map((fields, i) => { + if (fields.length !== 1) { + throw new Error( + `Malformed message context request at index ${i}: expected 1 field (tx hash), got ${fields.length}`, + ); + } + return fields[0]; + }); + + const maybeMessageContexts = await this.messageContextService.resolveMessageContexts( + txHashes, + this.anchorBlockHeader.getBlockNumber(), + ); + + // Leave response in response capsule array. + await this.capsuleStore.setCapsuleArray( + contractAddress, + messageContextResponsesArrayBaseSlot, + maybeMessageContexts.map(MessageTxContext.toSerializedOption), + this.jobId, + ); + } finally { + await this.capsuleStore.setCapsuleArray(contractAddress, messageContextRequestsArrayBaseSlot, [], this.jobId); + } + } + public utilityStoreCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[]): Promise { if (!contractAddress.equals(this.contractAddress)) { // TODO(#10727): instead of this check that this.contractAddress is allowed to access the external DB diff --git a/yarn-project/pxe/src/messages/message_context_service.test.ts b/yarn-project/pxe/src/messages/message_context_service.test.ts new file mode 100644 index 000000000000..6449f1e1b6cf --- /dev/null +++ b/yarn-project/pxe/src/messages/message_context_service.test.ts @@ -0,0 +1,126 @@ +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import { BlockHash } from '@aztec/stdlib/block'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { TxHash } from '@aztec/stdlib/tx'; + +import { mock } from 'jest-mock-extended'; + +import { MessageTxContext } from '../contract_function_simulator/noir-structs/message_tx_context.js'; +import { MessageContextService } from './message_context_service.js'; + +describe('MessageContextService', () => { + let aztecNode: ReturnType>; + let service: MessageContextService; + const anchorBlockNumber = 100; + + beforeEach(() => { + aztecNode = mock(); + service = new MessageContextService(aztecNode); + }); + + it('returns null for zero tx hash', async () => { + const results = await service.resolveMessageContexts([Fr.ZERO], anchorBlockNumber); + + expect(results).toEqual([null]); + expect(aztecNode.getTxEffect).not.toHaveBeenCalled(); + }); + + it('returns null when tx effect is not found', async () => { + const txHash = TxHash.random(); + aztecNode.getTxEffect.mockResolvedValueOnce(undefined); + + const results = await service.resolveMessageContexts([txHash.hash], anchorBlockNumber); + + expect(results).toEqual([null]); + }); + + it('returns null when tx effect is beyond anchor block', async () => { + const txHash = TxHash.random(); + aztecNode.getTxEffect.mockResolvedValueOnce({ + l2BlockNumber: BlockNumber(anchorBlockNumber + 1), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash, noteHashes: [Fr.random()], nullifiers: [Fr.random()] }, + } as any); + + const results = await service.resolveMessageContexts([txHash.hash], anchorBlockNumber); + + expect(results).toEqual([null]); + }); + + it('throws when tx effect has no nullifiers', async () => { + const txHash = TxHash.random(); + aztecNode.getTxEffect.mockResolvedValueOnce({ + l2BlockNumber: BlockNumber(anchorBlockNumber - 1), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash, noteHashes: [Fr.random()], nullifiers: [] }, + } as any); + + await expect(service.resolveMessageContexts([txHash.hash], anchorBlockNumber)).rejects.toThrow( + `Tx effect for ${txHash} has no nullifiers`, + ); + }); + + it('resolves a valid tx hash into a MessageTxContext', async () => { + const txHash = TxHash.random(); + const noteHashes = [Fr.random(), Fr.random()]; + const firstNullifier = Fr.random(); + + aztecNode.getTxEffect.mockResolvedValueOnce({ + l2BlockNumber: BlockNumber(anchorBlockNumber - 1), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash, noteHashes, nullifiers: [firstNullifier, Fr.random()] }, + } as any); + + const results = await service.resolveMessageContexts([txHash.hash], anchorBlockNumber); + + expect(results).toEqual([new MessageTxContext(txHash, noteHashes, firstNullifier)]); + }); + + it('resolves tx hashes in different situations', async () => { + const validTxHash = TxHash.random(); + const validNoteHashes = [Fr.random()]; + const validNullifier = Fr.random(); + + const notFoundTxHash = TxHash.random(); + const futureTxHash = TxHash.random(); + + aztecNode.getTxEffect.mockImplementation((hash: TxHash) => { + if (hash.equals(validTxHash)) { + return { + l2BlockNumber: BlockNumber(anchorBlockNumber), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash: validTxHash, noteHashes: validNoteHashes, nullifiers: [validNullifier] }, + } as any; + } + if (hash.equals(futureTxHash)) { + return { + l2BlockNumber: BlockNumber(anchorBlockNumber + 5), + l2BlockHash: BlockHash.random(), + txIndexInBlock: 0, + data: { txHash: futureTxHash, noteHashes: [], nullifiers: [Fr.random()] }, + } as any; + } + return undefined; // notFoundTxHash + }); + + const results = await service.resolveMessageContexts( + [ + Fr.ZERO, // zero → null + validTxHash.hash, // valid → MessageTxContext + notFoundTxHash.hash, // not found → null + futureTxHash.hash, // beyond anchor → null + ], + anchorBlockNumber, + ); + + expect(results).toEqual([null, new MessageTxContext(validTxHash, validNoteHashes, validNullifier), null, null]); + + // Zero hash should not trigger getTxEffect + expect(aztecNode.getTxEffect).toHaveBeenCalledTimes(3); + }); +}); diff --git a/yarn-project/pxe/src/messages/message_context_service.ts b/yarn-project/pxe/src/messages/message_context_service.ts new file mode 100644 index 000000000000..ae39812a7c3b --- /dev/null +++ b/yarn-project/pxe/src/messages/message_context_service.ts @@ -0,0 +1,45 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { TxHash } from '@aztec/stdlib/tx'; + +import { MessageTxContext } from '../contract_function_simulator/noir-structs/message_tx_context.js'; + +/** Resolves transaction hashes into the context needed to process messages. */ +export class MessageContextService { + constructor(private readonly aztecNode: AztecNode) {} + + /** + * Resolves a list of tx hashes into their message contexts. + * + * For each tx hash, looks up the corresponding tx effect and extracts the note hashes and first nullifier needed to + * process messages that originated from that transaction. Returns `null` for tx hashes that are zero, not yet + * available, or in blocks beyond the anchor block. + */ + resolveMessageContexts(txHashes: Fr[], anchorBlockNumber: number): Promise<(MessageTxContext | null)[]> { + // TODO: optimize, we might be hitting the node to get the same txHash repeatedly + return Promise.all( + txHashes.map(async txHashField => { + // A zero tx hash indicates a tx-less offchain message (e.g. one not tied to any onchain transaction). + // These messages don't have a transaction context to resolve, so we return null. + if (txHashField.isZero()) { + return null; + } + + const txHash = TxHash.fromField(txHashField); + const txEffect = await this.aztecNode.getTxEffect(txHash); + if (!txEffect || txEffect.l2BlockNumber > anchorBlockNumber) { + return null; + } + + // Every tx has at least one nullifier (the first nullifier derived from the tx hash). Hitting this condition + // would mean a buggy node, but since we need to access data.nullifiers[0], the defensive check does no harm. + const data = txEffect.data; + if (data.nullifiers.length === 0) { + throw new Error(`Tx effect for ${txHash} has no nullifiers`); + } + + return new MessageTxContext(data.txHash, data.noteHashes, data.nullifiers[0]); + }), + ); + } +} diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 5f6dd5abc335..2230ee6a0f96 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -4,9 +4,10 @@ /// /// @dev Whenever a contract function or Noir test is run, the `utilityAssertCompatibleOracleVersion` oracle is called /// and if the oracle version is incompatible an error is thrown. -export const ORACLE_VERSION = 12; +export const ORACLE_VERSION = 13; /// This hash is computed as by hashing the Oracle interface and it is used to detect when the Oracle interface changes, /// which in turn implies that you need to update the ORACLE_VERSION constant in this file and in /// `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. + export const ORACLE_INTERFACE_HASH = '666a8a7fc697f72b29dbf0ae7464db269cf5afa019acac8861f814543147dbb4'; diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 9b7e5cc3ed98..69cdde197114 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -68,6 +68,7 @@ import { PXEDebugUtils } from './debug/pxe_debug_utils.js'; import { enrichPublicSimulationError, enrichSimulationError } from './error_enriching.js'; import { PrivateEventFilterValidator } from './events/private_event_filter_validator.js'; import { JobCoordinator } from './job_coordinator/job_coordinator.js'; +import { MessageContextService } from './messages/message_context_service.js'; import { PrivateKernelExecutionProver, type PrivateKernelExecutionProverConfig, @@ -157,6 +158,7 @@ export class PXE { private addressStore: AddressStore, private privateEventStore: PrivateEventStore, private contractSyncService: ContractSyncService, + private messageContextService: MessageContextService, private simulator: CircuitSimulator, private proverEnabled: boolean, private proofCreator: PrivateKernelProver, @@ -212,6 +214,8 @@ export class PXE { noteStore, createLogger('pxe:contract_sync', bindings), ); + const messageContextService = new MessageContextService(node); + const synchronizer = new BlockSynchronizer( node, store, @@ -252,6 +256,7 @@ export class PXE { addressStore, privateEventStore, contractSyncService, + messageContextService, simulator, proverEnabled, proofCreator, @@ -293,6 +298,7 @@ export class PXE { privateEventStore: this.privateEventStore, simulator: this.simulator, contractSyncService: this.contractSyncService, + messageContextService: this.messageContextService, }); } 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 45be8bbcf95c..32de1d689550 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 @@ -387,6 +387,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl // contract would perform, including setting senderForTags. senderForTags: from, simulator, + messageContextService: this.stateMachine.messageContextService, }); // Note: This is a slight modification of simulator.run without any of the checks. Maybe we should modify simulator.run with a boolean value to skip checks. @@ -743,6 +744,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl senderAddressBookStore: this.senderAddressBookStore, capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, + messageContextService: this.stateMachine.messageContextService, jobId, scopes, }); diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 55deeb05b91e..3bbbf12dd169 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -750,6 +750,24 @@ export class RPCTranslator { return toForeignCallResult([]); } + async utilityResolveMessageContexts( + foreignContractAddress: ForeignCallSingle, + foreignMessageContextRequestsArrayBaseSlot: ForeignCallSingle, + foreignMessageContextResponsesArrayBaseSlot: ForeignCallSingle, + ) { + const contractAddress = AztecAddress.fromField(fromSingle(foreignContractAddress)); + const messageContextRequestsArrayBaseSlot = fromSingle(foreignMessageContextRequestsArrayBaseSlot); + const messageContextResponsesArrayBaseSlot = fromSingle(foreignMessageContextResponsesArrayBaseSlot); + + await this.handlerAsUtility().utilityResolveMessageContexts( + contractAddress, + messageContextRequestsArrayBaseSlot, + messageContextResponsesArrayBaseSlot, + ); + + return toForeignCallResult([]); + } + async utilityStoreCapsule( foreignContractAddress: ForeignCallSingle, foreignSlot: ForeignCallSingle, diff --git a/yarn-project/txe/src/state_machine/index.ts b/yarn-project/txe/src/state_machine/index.ts index cf7884c24ff4..5976e9f346a6 100644 --- a/yarn-project/txe/src/state_machine/index.ts +++ b/yarn-project/txe/src/state_machine/index.ts @@ -4,6 +4,7 @@ import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; import { type AnchorBlockStore, type ContractStore, ContractSyncService, type NoteStore } from '@aztec/pxe/server'; +import { MessageContextService } from '@aztec/pxe/simulator'; import { L2Block } from '@aztec/stdlib/block'; import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; @@ -26,6 +27,7 @@ export class TXEStateMachine { public archiver: TXEArchiver, public anchorBlockStore: AnchorBlockStore, public contractSyncService: ContractSyncService, + public messageContextService: MessageContextService, ) {} public static async create( @@ -68,7 +70,9 @@ export class TXEStateMachine { createLogger('txe:contract_sync'), ); - return new this(node, synchronizer, archiver, anchorBlockStore, contractSyncService); + const messageContextService = new MessageContextService(node); + + return new this(node, synchronizer, archiver, anchorBlockStore, contractSyncService, messageContextService); } public async handleL2Block(block: L2Block) { diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 5c7b87ea4feb..1dc14f7d5f21 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -369,6 +369,7 @@ export class TXESession implements TXESessionStateHandler { contractSyncService: this.stateMachine.contractSyncService, jobId: this.currentJobId, scopes: 'ALL_SCOPES', + messageContextService: this.stateMachine.messageContextService, }); // We store the note and tagging index caches fed into the PrivateExecutionOracle (along with some other auxiliary @@ -437,6 +438,7 @@ export class TXESession implements TXESessionStateHandler { senderAddressBookStore: this.senderAddressBookStore, capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, + messageContextService: this.stateMachine.messageContextService, jobId: this.currentJobId, scopes: 'ALL_SCOPES', }); @@ -528,6 +530,7 @@ export class TXESession implements TXESessionStateHandler { senderAddressBookStore: this.senderAddressBookStore, capsuleStore: this.capsuleStore, privateEventStore: this.privateEventStore, + messageContextService: this.stateMachine.messageContextService, jobId: this.currentJobId, scopes, }); 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 a769d5907f2c..55298d2449bd 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -394,7 +394,10 @@ export abstract class BaseWallet implements Wallet { const feeOptions = await this.completeFeeOptions(opts.from, executionPayload.feePayer, opts.fee?.gasSettings); const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(executionPayload, opts.from, feeOptions); const provenTx = await this.pxe.proveTx(txRequest, this.scopesFrom(opts.from, opts.additionalScopes)); - const offchainOutput = extractOffchainOutput(provenTx.getOffchainEffects()); + const offchainOutput = extractOffchainOutput( + provenTx.getOffchainEffects(), + provenTx.publicInputs.expirationTimestamp, + ); const tx = await provenTx.toTx(); const txHash = tx.getTxHash(); if (await this.aztecNode.getTxEffect(txHash)) {