From df57245b6e3164f26b2dcef88e55327d63c2f545 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Thu, 11 Jun 2026 19:12:46 -0300 Subject: [PATCH 01/14] feat(pxe): add get_delivery_privacy_preference oracle --- .../docs/aztec-nr/debugging.md | 48 +-------- .../framework-description/note_delivery.md | 30 ++++++ .../pxe/execution_hooks.md | 86 ++++++++++++++++ .../aztec/src/messages/delivery/mod.nr | 2 + .../messages/delivery/privacy_preference.nr | 98 +++++++++++++++++++ .../src/oracle/delivery_privacy_preference.nr | 55 +++++++++++ .../aztec-nr/aztec/src/oracle/mod.nr | 1 + .../aztec-nr/aztec/src/oracle/version.nr | 2 +- .../src/test/helpers/test_environment.nr | 8 ++ .../aztec/src/test/helpers/txe_oracles.nr | 9 +- yarn-project/pxe/src/config/index.ts | 1 + .../src/contract_function_simulator/index.ts | 1 + .../oracle/oracle_registry.ts | 11 +++ .../oracle/oracle_type_mappings.ts | 12 +++ .../oracle/private_execution_oracle.test.ts | 42 ++++++++ .../oracle/private_execution_oracle.ts | 26 +++++ .../oracle/utility_execution_oracle.ts | 4 +- yarn-project/pxe/src/hooks/execution_hooks.ts | 29 ++++-- .../hooks/get_delivery_privacy_preference.ts | 65 ++++++++++++ yarn-project/pxe/src/hooks/index.ts | 9 +- yarn-project/pxe/src/oracle_version.ts | 4 +- yarn-project/txe/src/oracle/interfaces.ts | 2 + .../txe/src/oracle/txe_oracle_registry.ts | 5 + .../oracle/txe_oracle_top_level_context.ts | 11 ++- .../txe/src/oracle/txe_oracle_version.ts | 4 +- yarn-project/txe/src/rpc_translator.ts | 19 ++++ yarn-project/txe/src/txe_session.ts | 16 ++- .../wallets/src/embedded/embedded_wallet.ts | 20 +++- .../src/embedded/entrypoints/browser.ts | 8 +- .../wallets/src/embedded/entrypoints/node.ts | 8 +- 30 files changed, 565 insertions(+), 71 deletions(-) create mode 100644 docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md create mode 100644 noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr create mode 100644 noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr create mode 100644 yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts diff --git a/docs/docs-developers/docs/aztec-nr/debugging.md b/docs/docs-developers/docs/aztec-nr/debugging.md index 780d24b3343e..8843d304498b 100644 --- a/docs/docs-developers/docs/aztec-nr/debugging.md +++ b/docs/docs-developers/docs/aztec-nr/debugging.md @@ -44,57 +44,13 @@ a malicious or compromised contract could leak private information to an untrust contract utility calls by default and requires explicit authorization via an execution hook. Calls to standard contracts (such as the HandshakeRegistry, which is queried during every contract's sync) are always automatically authorized. -When a contract executes a utility function that calls into a different contract, PXE asks an **execution hook** whether the call should be allowed. If no hook is configured, or the hook denies the request, you will see: +When a contract executes a utility function that calls into a different contract, PXE asks an [execution hook](../foundational-topics/pxe/execution_hooks.md) whether the call should be allowed. If no hook is configured, or the hook denies the request, you will see: ``` Cross-contract utility call denied: . attempted to call : (). ``` -##### In production - -Pass an `authorizeUtilityCall` hook when creating your PXE: - -```typescript -import { PXE } from "@aztec/pxe/server"; - -const pxe = await PXE.create({ - // ...other options - hooks: { - authorizeUtilityCall: async (request) => { - // Inspect request.caller, request.target, request.functionSelector, etc. - return { authorized: true }; - }, - }, -}); -``` - -The hook receives a `UtilityCallAuthorizationRequest` with the caller and target addresses, their contract class IDs, function selector, function name, arguments, and caller context (`'private'`, `'private view'`, or `'utility'`). Return `{ authorized: true }` to allow or `{ authorized: false, reason: '...' }` to deny with a message. - -##### In Noir tests - -When testing cross-contract utility calls in the Noir test environment (TXE), use `with_authorized_utility_call_targets` on your call options: - -```rust -// For private calls: -env.call_private_opts( - account, - CallPrivateOptions::new().with_authorized_utility_call_targets([target_address]), - MyContract::at(caller).some_private_fn(), -); - -// For private view calls: -env.view_private_opts( - account, - ViewPrivateOptions::new().with_authorized_utility_call_targets([target_address]), - MyContract::at(caller).some_view_fn(), -); - -// For utility calls: -env.execute_utility_opts( - ExecuteUtilityOptions::new().with_authorized_utility_call_targets([target_address]), - MyContract::at(caller).some_utility_fn(), -); -``` +See [execution hooks](../foundational-topics/pxe/execution_hooks.md#authorizeutilitycall) for how to authorize calls, both in production and in Noir tests. ### Circuit Errors diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index 2dc47e05f919..c23229fe80f4 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -161,6 +161,36 @@ Ask yourself: **"Is the sender incentivized to deliver this note correctly?"** - **Yes, but they cannot or prefer not to contact them offchain or you don't want to implement offchain delivery** Use `ONCHAIN_UNCONSTRAINED` - **No, the sender might not deliver correctly** Use `ONCHAIN_CONSTRAINED` +## Delivery privacy preference + +Onchain delivery tags every message so the recipient can find it efficiently (see [note discovery](#note-discovery-and-the-sender) below). Computing a tag requires a secret shared between sender and recipient. That secret can be derived in several ways, and the choice involves a privacy trade-off. Each party involved in message delivery owns a different part of the decision: + +- **Contracts** choose a delivery mode, and can optionally pin a tag-secret derivation via the `MessageDelivery` builders. By default they pin nothing and delegate the decision to the wallet. This is the recommended setting unless the contract requires a specific mechanism to work. +- **Wallets** answer that delegation with the **delivery privacy preference**: a wallet-level setting with two values, **max privacy** and **best effort**. It decides how much privacy the user is willing to trade so that delivery works with less sender-recipient coordination. + +The preference is consulted whenever a message needs a tagging secret and the contract has not pinned a derivation. + +### Max privacy vs best effort + +- **Max privacy** (the PXE default): nothing that could link sender and recipient is ever published, and delivery relies on sender-recipient coordination. Unconstrained delivery uses a secret derived from the sender and recipient addresses, which leaves no onchain trace, but the recipient only finds the message if they registered the sender in their PXE. Constrained delivery requires an interactive handshake with the recipient, and fails when none exists, because there is no privacy-preserving way to establish a secret on the fly. +- **Best effort**: tags are derived from a non-interactive handshake, reusing an existing one or establishing it onchain as part of the send. The recipient discovers the message without knowing the sender in advance or coordinating with them in any other way, at the cost of publishing a handshake that reveals information about the recipient. + +| | Max privacy | Best effort | +|---|---|---| +| Onchain footprint when establishing a secret | None | A handshake revealing information about the recipient | +| Unconstrained delivery to an unknown recipient | Found only if the recipient registered the sender | Found without sender-recipient coordination | +| Constrained delivery | Requires an interactive handshake signed by the recipient | Works without recipient involvement | + +### Configuring the preference + +Wallets configure the preference through the `getDeliveryPrivacyPreference` [execution hook](../../foundational-topics/pxe/execution_hooks.md) when creating their PXE. The hook receives the message context (executing contract, sender, recipient and delivery mode), so a wallet can answer per message instead of with a fixed value. + +The defaults differ by environment: + +- **PXE**: max privacy. A bare PXE makes the conservative choice and never leaks without opt-in. +- **Embedded wallet** (`@aztec/wallets/embedded`): best effort. It targets development scenarios where delivery working out of the box matters more than handshake privacy. Override it by passing your own `hooks.getDeliveryPrivacyPreference` in its `pxe` options. +- **TXE tests**: max privacy, matching the bare PXE. Tests opt into best effort via `env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort())`. + ## Note Discovery and the Sender When a note is delivered, recipients need to discover it among all the encrypted logs on the network. Aztec.nr uses a **tagging system** that requires computing a shared secret between the sender and recipient. diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md new file mode 100644 index 000000000000..02bd59f70064 --- /dev/null +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -0,0 +1,86 @@ +--- +title: Execution hooks +sidebar_position: 3 +tags: [pxe, wallets] +description: How wallets use PXE execution hooks to apply custom policies during client-side simulation. +--- + +Execution hooks are callbacks that the PXE invokes during client-side simulation when an operation needs a decision from the wallet. They let the wallet apply its own policies before execution proceeds, such as prompting the user, consulting a dynamic allowlist, or inspecting call arguments. Every hook is optional, and when a hook is absent the PXE applies a safe default. + +## Configuring hooks + +Pass a `hooks` object when creating the PXE: + +```typescript +import { createPXE } from "@aztec/pxe/server"; +import { DeliveryPrivacyPreference } from "@aztec/pxe/config"; + +const pxe = await createPXE(node, config, { + hooks: { + // Allow calls to a known helper contract, deny everything else. + authorizeUtilityCall: async (request) => { + return request.target.equals(trustedHelper) + ? { authorized: true } + : { authorized: false, reason: "Unknown target" }; + }, + // Accept the privacy leak of on-the-fly handshakes so messages reach recipients that haven't registered the sender. + getDeliveryPrivacyPreference: async () => DeliveryPrivacyPreference.BEST_EFFORT, + }, +}); +``` + +## `authorizeUtilityCall` + +Called whenever a utility function makes a cross-contract call. A call made by a malicious contract could leak private information, so the hook lets the wallet decide, per call, whether to allow it. A static allowlist would not work here because neither the app nor the wallet can predict ahead of time which contracts will be invoked during execution: permission must be asked after execution has begun. Calls to standard contracts (such as the HandshakeRegistry, which is queried during every contract's sync) bypass this hook and are always authorized. + +Unlike [authentication witnesses (authwits)](../../aztec-js/how_to_use_authwit.md), the hook is invoked live, while execution is underway. Authwits can be recorded during simulation and signed once at the end, but the PXE cannot predict what a utility call would return, so it must ask before continuing. Most of the time the wallet can answer on its own, for example against a list of audited or previously trusted contracts, without involving the user. + +### In production + +Pass an `authorizeUtilityCall` hook when [creating the PXE](#configuring-hooks). It receives a `UtilityCallAuthorizationRequest` with the caller and target addresses, their contract class IDs, the function selector, the function name, the arguments, and the caller context (`'private'`, `'private view'`, or `'utility'`). Return `{ authorized: true }` to allow the call, or `{ authorized: false, reason: '...' }` to deny it with a message. + +When the hook is absent, cross-contract utility calls are denied. See [Cross-contract utility call denied](../../aztec-nr/debugging.md#cross-contract-utility-call-denied) for the resulting error. + +### In Noir tests + +The hook only exists on a real PXE. When testing cross-contract utility calls in the Noir test environment (TXE), use `with_authorized_utility_call_targets` on your call options: + +```rust +// For private calls: +env.call_private_opts( + account, + CallPrivateOptions::new().with_authorized_utility_call_targets([target_address]), + MyContract::at(caller).some_private_fn(), +); + +// For private view calls: +env.view_private_opts( + account, + ViewPrivateOptions::new().with_authorized_utility_call_targets([target_address]), + MyContract::at(caller).some_view_fn(), +); + +// For utility calls: +env.execute_utility_opts( + ExecuteUtilityOptions::new().with_authorized_utility_call_targets([target_address]), + MyContract::at(caller).some_utility_fn(), +); +``` + +## `getDeliveryPrivacyPreference` + +Called when message delivery needs a tagging secret and the executing contract has not pinned a tag-secret derivation. The hook lets the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination; see [Delivery privacy preference](../../aztec-nr/framework-description/note_delivery.md#delivery-privacy-preference) for the trade-offs and the defaults in each environment. + +### In production + +Pass a `getDeliveryPrivacyPreference` hook when [creating the PXE](#configuring-hooks). It receives a `DeliveryPrivacyPreferenceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. + +When the hook is absent, the PXE assumes `DeliveryPrivacyPreference.MAX_PRIVACY`, so privacy is never weakened without the wallet opting in. + +### In Noir tests + +The hook only exists on a real PXE. In the Noir test environment (TXE), which defaults to max privacy like a bare PXE, set the preference on the test environment; it affects message delivery in subsequent private executions: + +```rust +env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()); +``` diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr index 57b47837846f..9952c98b0b7d 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr @@ -1,5 +1,6 @@ mod builder; mod mode; +mod privacy_preference; mod tag_secret_derivation; pub mod handshake; @@ -21,6 +22,7 @@ pub use builder::{ MessageDelivery, MessageDeliveryBuilder, OffchainDelivery, OnchainConstrainedDelivery, OnchainUnconstrainedDelivery, }; pub use mode::OnchainDeliveryMode; +pub use privacy_preference::DeliveryPrivacyPreference; /// Performs private delivery of a message to `recipient` according to `delivery_mode`. /// diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr new file mode 100644 index 000000000000..10372c5bf3d0 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr @@ -0,0 +1,98 @@ +use crate::protocol::{traits::Deserialize, utils::reader::Reader}; + +/// A wallet-owned policy that selects the tag-secret derivation used by message delivery when the contract does not +/// pin one. +/// +/// Max privacy never leaks information and instead relies on sender-recipient coordination for delivery. Best effort +/// accepts a privacy leak so that delivery requires no sender-recipient coordination at all. +/// +/// ## Who configures what +/// +/// Each party involved in message delivery owns a different knob: +/// +/// - **Contracts** choose a delivery mode and can optionally pin a tag-secret derivation via the `via_*` methods on +/// the [`MessageDelivery`](crate::messages::delivery::MessageDelivery) builders (see +/// [`OnchainUnconstrainedDelivery`](crate::messages::delivery::OnchainUnconstrainedDelivery) and +/// [`OnchainConstrainedDelivery`](crate::messages::delivery::OnchainConstrainedDelivery)). When they pin nothing, +/// the decision is delegated to the wallet, which is the recommended default. +/// - **Wallets** answer that delegation with this preference, reported through the +/// [`get_delivery_privacy_preference`](crate::oracle::delivery_privacy_preference::get_delivery_privacy_preference) +/// oracle. +/// +/// ## Privacy +/// +/// The two variants trade off as follows: +/// +/// - [`max_privacy`](Self::max_privacy): nothing that could link sender and recipient is published, and delivery +/// relies on sender-recipient coordination. Unconstrained delivery uses the address-pair (ECDH) secret, which is +/// derived locally and leaves no on-chain trace, but the recipient only finds the message if they registered the +/// sender. Constrained delivery requires an interactive handshake with the recipient, and fails when none exists. +/// - [`best_effort`](Self::best_effort): tags are derived from a non-interactive handshake, reusing an existing one +/// or establishing it on the fly, letting the recipient discover the message without any prior sender-recipient +/// coordination. Establishing it publishes an on-chain handshake that reveals information about the recipient. +pub struct DeliveryPrivacyPreference { + inner: u8, +} + +impl DeliveryPrivacyPreference { + /// Never leak information to obtain a tagging secret. + /// + /// Delivery relies on sender-recipient coordination instead: unconstrained messages are only found by recipients + /// who registered the sender, and constrained delivery requires an interactive handshake. + pub fn max_privacy() -> Self { + Self { inner: 1 } + } + + /// Accept a privacy leak to deliver without sender-recipient coordination. + /// + /// Tags are derived from a non-interactive handshake, reused when one exists and otherwise established on the + /// fly. The handshake is published on-chain and reveals information about the recipient. In exchange, the + /// recipient discovers the message without coordinating with the sender in advance. + pub fn best_effort() -> Self { + Self { inner: 2 } + } + + /// Validates a raw discriminant, as deserialization must always reject unknown values. + fn from_u8(inner: u8) -> Self { + let preference = Self { inner }; + assert( + (preference == Self::max_privacy()) | (preference == Self::best_effort()), + "unrecognized delivery privacy preference", + ); + preference + } +} + +impl Deserialize for DeliveryPrivacyPreference { + let N: u32 = ::N; + + fn deserialize(fields: [Field; Self::N]) -> Self { + Self::from_u8(::deserialize(fields)) + } + + fn stream_deserialize(reader: &mut Reader) -> Self { + Self::from_u8(reader.read() as u8) + } +} + +impl Eq for DeliveryPrivacyPreference { + fn eq(self, other: Self) -> bool { + self.inner == other.inner + } +} + +mod test { + use crate::protocol::traits::Deserialize; + use super::DeliveryPrivacyPreference; + + #[test] + fn deserialize_roundtrips_valid_preferences() { + assert(DeliveryPrivacyPreference::deserialize([1]) == DeliveryPrivacyPreference::max_privacy()); + assert(DeliveryPrivacyPreference::deserialize([2]) == DeliveryPrivacyPreference::best_effort()); + } + + #[test(should_fail_with = "unrecognized delivery privacy preference")] + fn deserializing_invalid_preference_fails() { + let _ = DeliveryPrivacyPreference::deserialize([99]); + } +} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr new file mode 100644 index 000000000000..b484f03723a4 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr @@ -0,0 +1,55 @@ +use crate::messages::delivery::{DeliveryPrivacyPreference, OnchainDeliveryMode}; +use crate::protocol::address::AztecAddress; + +/// Returns the wallet's [`DeliveryPrivacyPreference`] for a message sent from `sender` to `recipient` with the given +/// delivery `mode`. +pub(crate) unconstrained fn get_delivery_privacy_preference( + sender: AztecAddress, + recipient: AztecAddress, + mode: OnchainDeliveryMode, +) -> DeliveryPrivacyPreference { + get_delivery_privacy_preference_oracle(sender, recipient, mode) +} + +#[oracle(aztec_prv_getDeliveryPrivacyPreference)] +unconstrained fn get_delivery_privacy_preference_oracle( + _sender: AztecAddress, + _recipient: AztecAddress, + _mode: OnchainDeliveryMode, +) -> DeliveryPrivacyPreference {} + +mod test { + use crate::messages::delivery::{DeliveryPrivacyPreference, OnchainDeliveryMode}; + use crate::protocol::{address::AztecAddress, traits::FromField}; + use crate::test::helpers::test_environment::TestEnvironment; + use super::get_delivery_privacy_preference; + + #[test] + unconstrained fn defaults_to_max_privacy() { + let env = TestEnvironment::new(); + + env.private_context(|_| { + let preference = get_delivery_privacy_preference( + AztecAddress::from_field(1), + AztecAddress::from_field(2), + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(preference, DeliveryPrivacyPreference::max_privacy()); + }); + } + + #[test] + unconstrained fn returns_the_preference_set_in_the_test_environment() { + let env = TestEnvironment::new(); + env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()); + + env.private_context(|_| { + let preference = get_delivery_privacy_preference( + AztecAddress::from_field(1), + AztecAddress::from_field(2), + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(preference, DeliveryPrivacyPreference::best_effort()); + }); + } +} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 6d677346a72f..75773b8fdd1c 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -10,6 +10,7 @@ pub mod capsules; pub(crate) mod ephemeral_oracles; pub(crate) mod transient_oracles; pub mod contract_sync; +pub mod delivery_privacy_preference; pub mod public_call; pub mod tx_phase; pub mod execution; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/version.nr b/noir-projects/aztec-nr/aztec/src/oracle/version.nr index 5fbc2b55a97b..6ac7c52b7c61 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/version.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/version.nr @@ -11,7 +11,7 @@ /// immediately if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency /// without actually using any of the new oracles then there is no reason to throw. pub global ORACLE_VERSION_MAJOR: Field = 29; -pub global ORACLE_VERSION_MINOR: Field = 1; +pub global ORACLE_VERSION_MINOR: Field = 2; /// 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 ea0c0b07a46d..09ba33acbd57 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr @@ -13,6 +13,7 @@ use crate::{ event::{event_interface::EventInterface, EventMessage}, hash::hash_args, messages::{ + delivery::DeliveryPrivacyPreference, discovery::{ ComputeNoteHash, ComputeNoteNullifier, CustomMessageHandler, process_message::process_message_plaintext, }, @@ -810,6 +811,13 @@ impl TestEnvironment { txe_oracles::advance_timestamp_by(duration); } + /// Sets the [`DeliveryPrivacyPreference`](crate::messages::delivery::DeliveryPrivacyPreference) that the wallet + /// reports through the oracle in [`delivery_privacy_preference`](crate::oracle::delivery_privacy_preference), + /// affecting message delivery in subsequent private executions. + pub unconstrained fn set_delivery_privacy_preference(_self: Self, preference: DeliveryPrivacyPreference) { + txe_oracles::set_delivery_privacy_preference(preference); + } + /// Creates a new account that can be used as the `from` parameter in contract calls, e.g. in `private_call` or /// `public_call`, or be made the owner or recipient of notes in `private_context`. /// diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr index 7c783f8b37a0..c610c4ba2b73 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr @@ -1,5 +1,7 @@ use crate::{ - context::inputs::PrivateContextInputs, event::EventSelector, messages::encoding::MESSAGE_CIPHERTEXT_LEN, + context::inputs::PrivateContextInputs, + event::EventSelector, + messages::{delivery::DeliveryPrivacyPreference, encoding::MESSAGE_CIPHERTEXT_LEN}, test::helpers::utils::TestAccount, }; @@ -20,7 +22,7 @@ use crate::protocol::{ /// /// The TypeScript counterparts are in `yarn-project/txe/src/txe_oracle_version.ts`. pub global TXE_ORACLE_VERSION_MAJOR: Field = 1; -pub global TXE_ORACLE_VERSION_MINOR: Field = 0; +pub global TXE_ORACLE_VERSION_MINOR: Field = 3; /// Asserts that the TXE oracle interface version is compatible. pub unconstrained fn assert_compatible_txe_oracle_version() { @@ -253,6 +255,9 @@ pub unconstrained fn add_account(secret: Field) -> TestAccount {} #[oracle(aztec_txe_addAuthWitness)] pub unconstrained fn add_authwit(address: AztecAddress, message_hash: Field) {} +#[oracle(aztec_txe_setDeliveryPrivacyPreference)] +pub unconstrained fn set_delivery_privacy_preference(preference: DeliveryPrivacyPreference) {} + #[oracle(aztec_txe_privateCallNewFlow)] unconstrained fn private_call_new_flow_oracle( _from: Option, diff --git a/yarn-project/pxe/src/config/index.ts b/yarn-project/pxe/src/config/index.ts index a6978e0e9180..9f81c6c098e7 100644 --- a/yarn-project/pxe/src/config/index.ts +++ b/yarn-project/pxe/src/config/index.ts @@ -10,6 +10,7 @@ import { type ChainConfig, chainConfigMappings } from '@aztec/stdlib/config'; import { type DataStoreConfig, dataConfigMappings } from '@aztec/stdlib/kv-store'; export { getPackageInfo } from './package_info.js'; +export * from '../hooks/index.js'; /** * Configuration settings for the prover factory diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 0e4bea0a9899..3db30bf7b675 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -17,6 +17,7 @@ export { BUFFER, BYTE, DELIVERY_MODE, + DELIVERY_PRIVACY_PREFERENCE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, FIELD, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index fb9beaa9850f..53254377be41 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -18,6 +18,7 @@ import { CONTRACT_CLASS_LOG_INPUT, CONTRACT_INSTANCE, DELIVERY_MODE, + DELIVERY_PRIVACY_PREFERENCE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, FIELD, @@ -60,6 +61,7 @@ export { BUFFER, BYTE, DELIVERY_MODE, + DELIVERY_PRIVACY_PREFERENCE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, FIELD, @@ -502,6 +504,15 @@ export const ORACLE_REGISTRY = { }), aztec_prv_getSenderForTags: makeEntry({ returnType: OPTION(AZTEC_ADDRESS) }), + + aztec_prv_getDeliveryPrivacyPreference: makeEntry({ + params: [ + { name: 'sender', type: AZTEC_ADDRESS }, + { name: 'recipient', type: AZTEC_ADDRESS }, + { name: 'deliveryMode', type: DELIVERY_MODE }, + ], + returnType: DELIVERY_PRIVACY_PREFERENCE, + }), } satisfies Record; // ─── Registry Infrastructure ───────────────────────────────────────────────── diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index 9704cdd94497..a0824b9ffcaf 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -37,6 +37,10 @@ import { import { NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; import { BlockHeader, TxEffect, TxHash } from '@aztec/stdlib/tx'; +import { + type DeliveryPrivacyPreference, + deliveryPrivacyPreferenceFromNumber, +} from '../../hooks/get_delivery_privacy_preference.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import { EphemeralArray } from '../noir-structs/ephemeral_array.js'; import { EventValidationRequest } from '../noir-structs/event_validation_request.js'; @@ -151,6 +155,14 @@ export const DELIVERY_MODE: TypeMapping = { }, }; +export const DELIVERY_PRIVACY_PREFERENCE: TypeMapping = { + serialization: { fn: preference => [new Fr(preference)] }, + deserialization: { + fn: readers => deliveryPrivacyPreferenceFromNumber(BYTE.deserialization!.fn(readers)), + slots: BYTE.deserialization!.slots, + }, +}; + export const BIGINT: TypeMapping = { serialization: { fn: v => [new Fr(v)] }, deserialization: { fn: ([reader]) => reader.readField().toBigInt(), slots: 1 }, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index 4825288921f4..fe9c6a3ec271 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -7,11 +7,16 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; import { GasFees, GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { type BlockHeader, CallContext, type Capsule, TxContext } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; +import { + DeliveryPrivacyPreference, + type DeliveryPrivacyPreferenceRequest, +} from '../../hooks/get_delivery_privacy_preference.js'; import type { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; @@ -65,6 +70,43 @@ describe('PrivateExecutionOracle', () => { }); }); + describe('deliveryPrivacyPreference', () => { + let sender: AztecAddress; + let recipient: AztecAddress; + + beforeAll(async () => { + sender = await AztecAddress.random(); + recipient = await AztecAddress.random(); + }); + + it('defaults to max privacy when no hooks are configured', async () => { + const oracle = makeOracle(); + + await expect( + oracle.getDeliveryPrivacyPreference(sender, recipient, AppTaggingSecretKind.CONSTRAINED), + ).resolves.toEqual(DeliveryPrivacyPreference.MAX_PRIVACY); + }); + + it('returns the preference reported by the wallet hook', async () => { + const requests: DeliveryPrivacyPreferenceRequest[] = []; + const oracle = makeOracle({ + hooks: { + getDeliveryPrivacyPreference: request => { + requests.push(request); + return Promise.resolve(DeliveryPrivacyPreference.BEST_EFFORT); + }, + }, + }); + + await expect( + oracle.getDeliveryPrivacyPreference(sender, recipient, AppTaggingSecretKind.UNCONSTRAINED), + ).resolves.toEqual(DeliveryPrivacyPreference.BEST_EFFORT); + expect(requests).toEqual([ + { contractAddress, sender, recipient, deliveryMode: AppTaggingSecretKind.UNCONSTRAINED }, + ]); + }); + }); + const makeOracle = (overrides: Partial = {}): PrivateExecutionOracle => { return new PrivateExecutionOracle({ argsHash: Fr.ZERO, 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 a304231ae711..aa8516c9b7f9 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 @@ -28,6 +28,7 @@ import { type TxContext, } from '@aztec/stdlib/tx'; +import { DeliveryPrivacyPreference } from '../../hooks/get_delivery_privacy_preference.js'; import { NoteService } from '../../notes/note_service.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { syncSenderTaggingIndexes } from '../../tagging/index.js'; @@ -186,6 +187,31 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP ); } + /** + * Returns the wallet's delivery privacy preference, which message delivery consults when it must establish a new + * tagging secret with a recipient and the executing contract has not pinned a delivery mechanism (see + * {@link DeliveryPrivacyPreference} for the trade-offs involved). + * + * The value is sourced from the wallet's `getDeliveryPrivacyPreference` execution hook, which receives the executing + * contract plus the message's sender, recipient and delivery mode so it can answer per message instead of with a + * fixed policy. When no hook is configured this defaults to max privacy, so that privacy is never weakened without + * the wallet opting in. + */ + public getDeliveryPrivacyPreference( + sender: AztecAddress, + recipient: AztecAddress, + deliveryMode: AppTaggingSecretKind, + ): Promise { + return ( + this.hooks?.getDeliveryPrivacyPreference?.({ + contractAddress: this.contractAddress, + sender, + recipient, + deliveryMode, + }) ?? Promise.resolve(DeliveryPrivacyPreference.MAX_PRIVACY) + ); + } + /** * Returns the sender-side app tagging secret for a `(sender, recipient)` pair. * 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 b959ff8f0723..f0a5e85efad5 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 @@ -867,9 +867,9 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra callerContext: this.callerContext, }; - const response = this.hooks + const response = this.hooks?.authorizeUtilityCall ? await this.hooks.authorizeUtilityCall(request) - : { authorized: false, reason: 'No execution hooks configured' }; + : { authorized: false, reason: 'No authorizeUtilityCall hook configured' }; if (!response.authorized) { const reason = response.reason ? `: ${response.reason}` : ''; diff --git a/yarn-project/pxe/src/hooks/execution_hooks.ts b/yarn-project/pxe/src/hooks/execution_hooks.ts index 420efe8fbd4f..7dec5b2a82cf 100644 --- a/yarn-project/pxe/src/hooks/execution_hooks.ts +++ b/yarn-project/pxe/src/hooks/execution_hooks.ts @@ -1,16 +1,21 @@ import type { AuthorizeUtilityCall } from './authorize_utility_call.js'; +import type { GetDeliveryPrivacyPreference } from './get_delivery_privacy_preference.js'; /** - * Hooks that PXE invokes during client-side simulation to gate operations that the protocol + * Hooks that PXE invokes during client-side simulation to gate or steer operations that the protocol * does not restrict on its own. They give the wallet a chance to apply custom policies (e.g. * prompting the user, consulting a dynamic allowlist, or inspecting call arguments) before the - * execution proceeds. + * execution proceeds. Every hook is optional, and when a hook is absent PXE applies a safe default. * * For example, {@link authorizeUtilityCall} is called whenever a utility function makes a cross-contract call. A call * made by a malicious contract could leak private information, so the hook lets the wallet decide, per-call, whether * to allow it. A static allowlist would not work here because neither the app nor the wallet can predict ahead of * time which contracts will be invoked during execution. Calls to standard contracts (such as the HandshakeRegistry) - * bypass this hook and are always authorized. + * bypass this hook and are always authorized. When the hook is absent, cross-contract utility calls are denied. + * + * Similarly, {@link getDeliveryPrivacyPreference} is called when message delivery needs a tagging secret and the + * contract has not pinned a tag-secret derivation, letting the wallet choose between maximum privacy and delivery + * that requires no sender-recipient coordination. When the hook is absent, PXE assumes maximum privacy. * * Note: hooks are unrelated to authentication witnesses (authwits). Authwits are an on-chain * mechanism where a contract verifies that a caller was authorized by a specific account; hooks @@ -27,22 +32,30 @@ import type { AuthorizeUtilityCall } from './authorize_utility_call.js'; * ? { authorized: true } * : { authorized: false, reason: 'Unknown target' }; * }, + * // Accept the privacy leak of on-the-fly handshakes so messages reach recipients that haven't registered + * // the sender. + * getDeliveryPrivacyPreference: async () => DeliveryPrivacyPreference.BEST_EFFORT, * }, * }); * ``` */ export interface ExecutionHooks { - /** Called when a contract attempts a cross-contract utility call. */ - authorizeUtilityCall: AuthorizeUtilityCall; + /** Called when a contract attempts a cross-contract utility call. Calls are denied when absent. */ + authorizeUtilityCall?: AuthorizeUtilityCall; + /** + * Called when message delivery needs a tagging secret and the contract has not pinned a tag-secret derivation. + * Maximum privacy is assumed when absent. + */ + getDeliveryPrivacyPreference?: GetDeliveryPrivacyPreference; } /** * Builds an {@link ExecutionHooks} from individually-constructed hook callbacks. Returns `undefined` * when every field is absent, so callers can unconditionally pass the result as `hooks`. */ -export function composeHooks(partial: Partial): ExecutionHooks | undefined { - if (Object.values(partial).every(v => v === undefined)) { +export function composeHooks(hooks: ExecutionHooks): ExecutionHooks | undefined { + if (Object.values(hooks).every(v => v === undefined)) { return undefined; } - return partial as ExecutionHooks; + return hooks; } diff --git a/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts b/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts new file mode 100644 index 000000000000..66b12a0de1ed --- /dev/null +++ b/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts @@ -0,0 +1,65 @@ +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +/** + * A wallet-owned policy that selects the tag-secret derivation used by message delivery when the executing contract + * does not pin one. Max privacy never leaks information and instead relies on sender-recipient coordination for + * delivery. Best effort accepts a privacy leak so that delivery requires no sender-recipient coordination at all. + * + * The send flow consults this value whenever a message needs a tagging secret. + */ +export enum DeliveryPrivacyPreference { + /** + * Never publish anything that could link sender and recipient in order to obtain a tagging secret; delivery relies + * on sender-recipient coordination instead. Unconstrained delivery uses the locally-derived address-pair (ECDH) + * secret, but the recipient only finds the message if they registered the sender; constrained delivery requires an + * interactive handshake with the recipient, and fails when none exists. + */ + MAX_PRIVACY = 1, + /** + * Derive tags from a non-interactive handshake, reusing an existing one or establishing it on the fly, letting the + * recipient discover the message without any prior sender-recipient coordination. Establishing it publishes an + * on-chain handshake that reveals information about the recipient. + */ + BEST_EFFORT = 2, +} + +/** Checks whether `value` is a known {@link DeliveryPrivacyPreference} discriminant. */ +function isDeliveryPrivacyPreference(value: number): value is DeliveryPrivacyPreference { + return value in DeliveryPrivacyPreference; +} + +/** Validates that `value` is a known {@link DeliveryPrivacyPreference} discriminant and narrows it to the enum. */ +export function deliveryPrivacyPreferenceFromNumber(value: number): DeliveryPrivacyPreference { + if (!isDeliveryPrivacyPreference(value)) { + throw new Error(`Unrecognized delivery privacy preference: ${value}`); + } + return value; +} + +/** Information about the message delivery requesting the preference. */ +export type DeliveryPrivacyPreferenceRequest = { + /** The contract whose execution is sending the message. */ + contractAddress: AztecAddress; + /** The sender of the message, i.e. the local side of the tagging secret that would be established. */ + sender: AztecAddress; + /** The recipient of the message, i.e. the party an on-chain handshake would reveal information about. */ + recipient: AztecAddress; + /** Whether the message is delivered with constrained or unconstrained tagging. */ + deliveryMode: AppTaggingSecretKind; +}; + +/** + * Hook called when message delivery needs a tagging secret and the executing contract has not pinned a tag-secret + * derivation, letting the wallet choose between maximum privacy and delivery that requires no sender-recipient + * coordination (see {@link DeliveryPrivacyPreference} for the trade-offs involved). + * + * The request identifies the message (executing contract, sender, recipient and delivery mode), so wallets can apply + * per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. + * + * When the hook is not configured, PXE defaults to {@link DeliveryPrivacyPreference.MAX_PRIVACY} so that privacy is + * never weakened without the wallet opting in. + */ +export type GetDeliveryPrivacyPreference = ( + request: DeliveryPrivacyPreferenceRequest, +) => Promise; diff --git a/yarn-project/pxe/src/hooks/index.ts b/yarn-project/pxe/src/hooks/index.ts index b9ceb80c404e..351677e80089 100644 --- a/yarn-project/pxe/src/hooks/index.ts +++ b/yarn-project/pxe/src/hooks/index.ts @@ -3,5 +3,10 @@ export type { UtilityCallAuthorizationRequest, UtilityCallAuthorizationResponse, } from './authorize_utility_call.js'; -export type { ExecutionHooks } from './execution_hooks.js'; -export { composeHooks } from './execution_hooks.js'; +export { type ExecutionHooks, composeHooks } from './execution_hooks.js'; +export { + DeliveryPrivacyPreference, + type DeliveryPrivacyPreferenceRequest, + type GetDeliveryPrivacyPreference, + deliveryPrivacyPreferenceFromNumber, +} from './get_delivery_privacy_preference.js'; diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 3143e93dfca5..9f6fc74d226f 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -11,7 +11,7 @@ /// if AZTEC_NR_MINOR > PXE_MINOR because if a contract is updated to use a newer Aztec.nr dependency without actually /// using any of the new oracles then there is no reason to throw. export const ORACLE_VERSION_MAJOR = 29; -export const ORACLE_VERSION_MINOR = 1; +export const ORACLE_VERSION_MINOR = 2; /// This hash is computed from the `ORACLE_REGISTRY` declaration (each oracle's name, ordered parameter names and /// types, and return type) and is used to detect when the oracle interface changes. When it does, you need to either: @@ -19,4 +19,4 @@ export const ORACLE_VERSION_MINOR = 1; /// - increment only `ORACLE_VERSION_MINOR` if the change is additive (a new oracle was added). /// /// These constants must be kept in sync between this file and `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = 'f5cd3321b32371186f30dfd11b246946fb425cadffb8e6564b897d5184e43fe9'; +export const ORACLE_INTERFACE_HASH = 'bd605eeb3034e2bdb88255effc2d4237e3bf7ecdf3678e9edf021a736f4f127f'; diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index ec0f316391b0..d78f32d7c745 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -2,6 +2,7 @@ import { CompleteAddress } from '@aztec/aztec.js/addresses'; import { TxHash } from '@aztec/aztec.js/tx'; import { BlockNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { DeliveryPrivacyPreference } from '@aztec/pxe/server'; import type { EventSelector, FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { GasSettings } from '@aztec/stdlib/gas'; @@ -69,6 +70,7 @@ export interface ITxeExecutionOracle { createAccount(secret: Fr): Promise; addAccount(secret: Fr): Promise; addAuthWitness(address: AztecAddress, messageHash: Fr): Promise; + setDeliveryPrivacyPreference(preference: DeliveryPrivacyPreference): void; getLastBlockTimestamp(): Promise; getLastTxEffects(): Promise<{ txHash: TxHash; diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index ad57134bb89f..ed09d85dad1b 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -14,6 +14,7 @@ import { BIGINT, BLOCK_NUMBER, BOOL, + DELIVERY_PRIVACY_PREFERENCE, FIELD, FUNCTION_SELECTOR, type InputSlot, @@ -219,6 +220,10 @@ export const TXE_ORACLE_REGISTRY = { ], }), + aztec_txe_setDeliveryPrivacyPreference: makeEntry({ + params: [{ name: 'preference', type: DELIVERY_PRIVACY_PREFERENCE }], + }), + aztec_txe_getLastBlockTimestamp: makeEntry({ returnType: BIGINT, }), 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 0ffebc718581..38b340ec1bb5 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 @@ -14,6 +14,7 @@ import { CapsuleService, CapsuleStore, type ContractStore, + type DeliveryPrivacyPreference, type ExecutionHooks, NoteStore, ORACLE_VERSION_MAJOR, @@ -113,6 +114,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private version: Fr, private chainId: Fr, private authwits: Map, + private deliveryPrivacyPreference: DeliveryPrivacyPreference, private readonly artifactResolver: TXEArtifactResolver, private readonly rootPath: string, private readonly packageName: string, @@ -352,6 +354,10 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl this.authwits.set(authWitness.requestHash.toString(), authWitness); } + setDeliveryPrivacyPreference(preference: DeliveryPrivacyPreference): void { + this.deliveryPrivacyPreference = preference; + } + async mineBlock(options: { nullifiers?: Fr[] } = {}) { const blockNumber = await this.getNextBlockNumber(); @@ -474,6 +480,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl isStaticCall ? 'private view' : 'private', authorizedUtilityCallTargets, ), + getDeliveryPrivacyPreference: () => Promise.resolve(this.deliveryPrivacyPreference), }), transientArrayService, }); @@ -878,9 +885,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } } - close(): [bigint, Map] { + close(): [bigint, Map, DeliveryPrivacyPreference] { this.logger.debug('Exiting Top Level Context'); - return [this.nextBlockTimestamp, this.authwits]; + return [this.nextBlockTimestamp, this.authwits, this.deliveryPrivacyPreference]; } private async getLastBlockNumber(): Promise { diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index d8f8eddab895..0df77c2b7a9c 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -6,7 +6,7 @@ * The Noir counterparts are in `noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr`. */ export const TXE_ORACLE_VERSION_MAJOR = 1; -export const TXE_ORACLE_VERSION_MINOR = 2; +export const TXE_ORACLE_VERSION_MINOR = 3; /** * This hash is computed from the TXE oracle interfaces (IAvmExecutionOracle and ITxeExecutionOracle) and is used to @@ -14,4 +14,4 @@ export const TXE_ORACLE_VERSION_MINOR = 2; * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '9e5f6ad5fd170d1de5ddd417f19cce47b17382567e08360dc6a783154828e218'; +export const TXE_ORACLE_INTERFACE_HASH = '10681b22123e70ebc0239a4ee1a1606dad624a2e9cc7f06e981c4d9f58b8e06a'; diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index d783117e06bf..35a36f9e6dd5 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -210,6 +210,15 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_txe_setDeliveryPrivacyPreference(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_txe_setDeliveryPrivacyPreference', + inputs, + handler: ([preference]) => this.handlerAsTxe().setDeliveryPrivacyPreference(preference), + }); + } + // PXE oracles // eslint-disable-next-line camelcase @@ -1073,6 +1082,16 @@ export class RPCTranslator { }); } + // eslint-disable-next-line camelcase + aztec_prv_getDeliveryPrivacyPreference(...inputs: ForeignCallArgs) { + return callTxeHandler({ + oracle: 'aztec_prv_getDeliveryPrivacyPreference', + inputs, + handler: ([sender, recipient, deliveryMode]) => + this.handlerAsPrivate().getDeliveryPrivacyPreference(sender, recipient, deliveryMode), + }); + } + // eslint-disable-next-line camelcase aztec_prv_getAppTaggingSecret(...inputs: ForeignCallArgs) { return callTxeHandler({ diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 00eff064e091..eb1e03dbae49 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -10,6 +10,7 @@ import { CapsuleService, CapsuleStore, ContractStore, + DeliveryPrivacyPreference, JobCoordinator, NoteService, NoteStore, @@ -17,6 +18,7 @@ import { RecipientTaggingStore, SenderAddressBookStore, SenderTaggingStore, + composeHooks, } from '@aztec/pxe/server'; import { ExecutionNoteCache, @@ -229,6 +231,7 @@ function emptyLastCallState(): LastCallState { export class TXESession implements TXESessionStateHandler { private state: SessionState = { name: 'TOP_LEVEL' }; private authwits: Map = new Map(); + private deliveryPrivacyPreference: DeliveryPrivacyPreference = DeliveryPrivacyPreference.MAX_PRIVACY; private lastCallInfo: LastCallState = emptyLastCallState(); private txeOracleVersion: { major: number; minor: number } | undefined; @@ -344,6 +347,7 @@ export class TXESession implements TXESessionStateHandler { version, chainId, new Map(), + DeliveryPrivacyPreference.MAX_PRIVACY, artifactResolver, rootPath, packageName, @@ -657,6 +661,7 @@ export class TXESession implements TXESessionStateHandler { this.version, this.chainId, this.authwits, + this.deliveryPrivacyPreference, this.artifactResolver, this.rootPath, this.packageName, @@ -728,6 +733,9 @@ export class TXESession implements TXESessionStateHandler { scopes: await this.keyStore.getAccounts(), messageContextService: this.stateMachine.messageContextService, simulator: new WASMSimulator(), + hooks: composeHooks({ + getDeliveryPrivacyPreference: () => Promise.resolve(this.deliveryPrivacyPreference), + }), transientArrayService, }); @@ -841,7 +849,13 @@ export class TXESession implements TXESessionStateHandler { // level context is re-created. This is because authwits create a temporary utility context that'd otherwise reset // the authwits if not persisted, so we'd not be able to pass more than one per execution. // Ideally authwits would be passed alongside a contract call instead of pre-seeded. - [this.nextBlockTimestamp, this.authwits] = (this.oracleHandler as TXEOracleTopLevelContext).close(); + // + // The oracle handler is discarded on every state transition, so `close` hands back the session-scoped values that + // top-level cheatcodes may have mutated (next block timestamp, authwits, delivery privacy preference) for the + // session to seed into the contexts it creates later. + [this.nextBlockTimestamp, this.authwits, this.deliveryPrivacyPreference] = ( + this.oracleHandler as TXEOracleTopLevelContext + ).close(); } private async exitPrivateState() { diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.ts b/yarn-project/wallets/src/embedded/embedded_wallet.ts index 56e3d2e3acb0..bf5ea2eb0f5a 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.ts @@ -26,6 +26,7 @@ import { Fq, Fr } from '@aztec/foundation/curves/bn254'; import type { Logger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import type { PXEConfig, PXECreationOptions } from '@aztec/pxe/client/lazy'; +import { DeliveryPrivacyPreference, type ExecutionHooks } from '@aztec/pxe/config'; import type { PXE } from '@aztec/pxe/server'; import type { ContractArtifact, EventMetadataDefinition, FunctionCall } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -49,7 +50,13 @@ import { BaseWallet, type SimulateViaEntrypointOptions } from '@aztec/wallet-sdk import type { AccountContractsProvider } from './account-contract-providers/types.js'; import { type AccountType, WalletDB } from './wallet_db.js'; -/** Options for the PXE instance created by the EmbeddedWallet. */ +/** + * Options for the PXE instance created by the EmbeddedWallet. + * + * Unless overridden via `hooks.getDeliveryPrivacyPreference`, the embedded wallet reports a best-effort delivery + * privacy preference (a bare PXE defaults to max privacy), trading the privacy cost of on-chain handshakes for + * message delivery that works without sender and recipient having to coordinate. + */ export type EmbeddedWalletPXEOptions = Partial & PXECreationOptions; /** Splits a unified EmbeddedWalletPXEOptions into PXEConfig overrides and PXECreationOptions. */ @@ -68,6 +75,17 @@ export function splitPxeOptions(pxe?: EmbeddedWalletPXEOptions): { }; } +/** + * Applies the embedded wallet's default execution hooks on top of user-provided ones, defaulting the delivery + * privacy preference to best effort (see {@link EmbeddedWalletPXEOptions} for why). + */ +export function applyEmbeddedWalletHookDefaults(hooks: ExecutionHooks | undefined): ExecutionHooks { + return { + getDeliveryPrivacyPreference: () => Promise.resolve(DeliveryPrivacyPreference.BEST_EFFORT), + ...hooks, + }; +} + /** Options for the EmbeddedWallet's own DB (accounts, senders — distinct from PXE state). */ export type EmbeddedWalletDBOptions = { /** Override the wallet DB backend. If omitted, an IndexedDB (browser) / LMDB (node) store is created. */ diff --git a/yarn-project/wallets/src/embedded/entrypoints/browser.ts b/yarn-project/wallets/src/embedded/entrypoints/browser.ts index aeca41194a67..268a649ba210 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/browser.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/browser.ts @@ -9,7 +9,12 @@ import { getStandardMultiCallEntrypoint } from '@aztec/standard-contracts/multi- import { LazyAccountContractsProvider } from '../account-contract-providers/lazy.js'; import type { AccountContractsProvider } from '../account-contract-providers/types.js'; -import { EmbeddedWallet, type EmbeddedWalletOptions, splitPxeOptions } from '../embedded_wallet.js'; +import { + EmbeddedWallet, + type EmbeddedWalletOptions, + applyEmbeddedWalletHookDefaults, + splitPxeOptions, +} from '../embedded_wallet.js'; import { WalletDB } from '../wallet_db.js'; export class BrowserEmbeddedWallet extends EmbeddedWallet { @@ -54,6 +59,7 @@ export class BrowserEmbeddedWallet extends EmbeddedWallet { await getStandardHandshakeRegistry(), ], }, + hooks: applyEmbeddedWalletHookDefaults(mergedCreationOverrides.hooks), loggers: { store: rootLogger.createChild('pxe:data'), pxe: rootLogger.createChild('pxe:service'), diff --git a/yarn-project/wallets/src/embedded/entrypoints/node.ts b/yarn-project/wallets/src/embedded/entrypoints/node.ts index 38eed84e259a..22a32ad85ca8 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/node.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/node.ts @@ -10,7 +10,12 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { BundleAccountContractsProvider } from '../account-contract-providers/bundle.js'; import type { AccountContractsProvider } from '../account-contract-providers/types.js'; -import { EmbeddedWallet, type EmbeddedWalletOptions, splitPxeOptions } from '../embedded_wallet.js'; +import { + EmbeddedWallet, + type EmbeddedWalletOptions, + applyEmbeddedWalletHookDefaults, + splitPxeOptions, +} from '../embedded_wallet.js'; import { WalletDB } from '../wallet_db.js'; export class NodeEmbeddedWallet extends EmbeddedWallet { @@ -55,6 +60,7 @@ export class NodeEmbeddedWallet extends EmbeddedWallet { await getStandardHandshakeRegistry(), ], }, + hooks: applyEmbeddedWalletHookDefaults(mergedCreationOverrides.hooks), loggers: { store: rootLogger.createChild('pxe:data'), pxe: rootLogger.createChild('pxe:service'), From 26917d52d8f4e429d1efe409b286028e430d0b8a Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Thu, 11 Jun 2026 19:35:20 -0300 Subject: [PATCH 02/14] fix(pxe): remove duplicate hooks re-export from server entrypoint --- yarn-project/pxe/src/entrypoints/server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn-project/pxe/src/entrypoints/server/index.ts b/yarn-project/pxe/src/entrypoints/server/index.ts index 5597cdf7efd2..7fac75907e37 100644 --- a/yarn-project/pxe/src/entrypoints/server/index.ts +++ b/yarn-project/pxe/src/entrypoints/server/index.ts @@ -1,6 +1,5 @@ export * from '../../notes_filter.js'; export * from '../../pxe.js'; -export * from '../../hooks/index.js'; export * from '../../config/index.js'; export * from '../../error_enriching.js'; export * from '../../storage/index.js'; From 36c849fc99c2c312ebfdfa67501c2518c4eb74d3 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 12 Jun 2026 18:27:43 -0300 Subject: [PATCH 03/14] refactor(pxe): address review feedback on delivery privacy preference --- .../docs/aztec-nr/debugging.md | 2 +- .../framework-description/note_delivery.md | 6 +- .../pxe/execution_hooks.md | 34 ++++++----- .../messages/delivery/privacy_preference.nr | 22 ++++--- .../src/oracle/delivery_privacy_preference.nr | 36 ------------ .../src/test/helpers/test_environment.nr | 58 ++++++++++++++++--- .../src/test/helpers/test_environment/test.nr | 1 + .../test/delivery_privacy_preference.nr | 36 ++++++++++++ .../oracle/private_execution_oracle.test.ts | 25 ++++---- yarn-project/pxe/src/hooks/execution_hooks.ts | 2 +- yarn-project/txe/src/txe_session.ts | 22 ++++--- 11 files changed, 143 insertions(+), 101 deletions(-) create mode 100644 noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr diff --git a/docs/docs-developers/docs/aztec-nr/debugging.md b/docs/docs-developers/docs/aztec-nr/debugging.md index 8843d304498b..55e80cac1a58 100644 --- a/docs/docs-developers/docs/aztec-nr/debugging.md +++ b/docs/docs-developers/docs/aztec-nr/debugging.md @@ -44,7 +44,7 @@ a malicious or compromised contract could leak private information to an untrust contract utility calls by default and requires explicit authorization via an execution hook. Calls to standard contracts (such as the HandshakeRegistry, which is queried during every contract's sync) are always automatically authorized. -When a contract executes a utility function that calls into a different contract, PXE asks an [execution hook](../foundational-topics/pxe/execution_hooks.md) whether the call should be allowed. If no hook is configured, or the hook denies the request, you will see: +When a contract executes a utility function that calls into a different contract, PXE asks the wallet through an [execution hook](../foundational-topics/pxe/execution_hooks.md) whether the call should be allowed. If no hook is configured, or the wallet denies the request, you will see: ``` Cross-contract utility call denied: . attempted to call : (). diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index c23229fe80f4..60d6ef64ce95 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -172,14 +172,14 @@ The preference is consulted whenever a message needs a tagging secret and the co ### Max privacy vs best effort -- **Max privacy** (the PXE default): nothing that could link sender and recipient is ever published, and delivery relies on sender-recipient coordination. Unconstrained delivery uses a secret derived from the sender and recipient addresses, which leaves no onchain trace, but the recipient only finds the message if they registered the sender in their PXE. Constrained delivery requires an interactive handshake with the recipient, and fails when none exists, because there is no privacy-preserving way to establish a secret on the fly. +- **Max privacy** (the PXE default): nothing that could link sender and recipient is ever published, and delivery relies on sender-recipient coordination. Unconstrained delivery uses a secret derived from the sender and recipient addresses, which leaves no onchain trace, but the recipient only finds the message if they registered the sender in their PXE. Constrained delivery requires an interactive handshake with the recipient: when none exists the transaction itself cannot be proven, because there is no privacy-preserving way to establish a secret on the fly. - **Best effort**: tags are derived from a non-interactive handshake, reusing an existing one or establishing it onchain as part of the send. The recipient discovers the message without knowing the sender in advance or coordinating with them in any other way, at the cost of publishing a handshake that reveals information about the recipient. | | Max privacy | Best effort | |---|---|---| | Onchain footprint when establishing a secret | None | A handshake revealing information about the recipient | | Unconstrained delivery to an unknown recipient | Found only if the recipient registered the sender | Found without sender-recipient coordination | -| Constrained delivery | Requires an interactive handshake signed by the recipient | Works without recipient involvement | +| Constrained delivery | Transaction is unprovable without an interactive handshake signed by the recipient | Works without recipient involvement | ### Configuring the preference @@ -189,7 +189,7 @@ The defaults differ by environment: - **PXE**: max privacy. A bare PXE makes the conservative choice and never leaks without opt-in. - **Embedded wallet** (`@aztec/wallets/embedded`): best effort. It targets development scenarios where delivery working out of the box matters more than handshake privacy. Override it by passing your own `hooks.getDeliveryPrivacyPreference` in its `pxe` options. -- **TXE tests**: max privacy, matching the bare PXE. Tests opt into best effort via `env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort())`. +- **`TestEnvironment` tests**: max privacy, matching the bare PXE. Tests opt into best effort by passing `TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort())` to `TestEnvironment::new_opts`. ## Note Discovery and the Sender diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 02bd59f70064..913f6daf875c 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -5,7 +5,7 @@ tags: [pxe, wallets] description: How wallets use PXE execution hooks to apply custom policies during client-side simulation. --- -Execution hooks are callbacks that the PXE invokes during client-side simulation when an operation needs a decision from the wallet. They let the wallet apply its own policies before execution proceeds, such as prompting the user, consulting a dynamic allowlist, or inspecting call arguments. Every hook is optional, and when a hook is absent the PXE applies a safe default. +Execution hooks are callbacks that the PXE invokes during client-side simulation when an operation needs a decision from the wallet. They let the wallet apply its own policies before execution proceeds, such as prompting the user, consulting a dynamic allowlist, or inspecting call arguments. All hooks are optional, and when a hook is absent the PXE applies a safe default. ## Configuring hooks @@ -33,17 +33,11 @@ const pxe = await createPXE(node, config, { Called whenever a utility function makes a cross-contract call. A call made by a malicious contract could leak private information, so the hook lets the wallet decide, per call, whether to allow it. A static allowlist would not work here because neither the app nor the wallet can predict ahead of time which contracts will be invoked during execution: permission must be asked after execution has begun. Calls to standard contracts (such as the HandshakeRegistry, which is queried during every contract's sync) bypass this hook and are always authorized. -Unlike [authentication witnesses (authwits)](../../aztec-js/how_to_use_authwit.md), the hook is invoked live, while execution is underway. Authwits can be recorded during simulation and signed once at the end, but the PXE cannot predict what a utility call would return, so it must ask before continuing. Most of the time the wallet can answer on its own, for example against a list of audited or previously trusted contracts, without involving the user. - -### In production - -Pass an `authorizeUtilityCall` hook when [creating the PXE](#configuring-hooks). It receives a `UtilityCallAuthorizationRequest` with the caller and target addresses, their contract class IDs, the function selector, the function name, the arguments, and the caller context (`'private'`, `'private view'`, or `'utility'`). Return `{ authorized: true }` to allow the call, or `{ authorized: false, reason: '...' }` to deny it with a message. - -When the hook is absent, cross-contract utility calls are denied. See [Cross-contract utility call denied](../../aztec-nr/debugging.md#cross-contract-utility-call-denied) for the resulting error. +Unlike [authentication witnesses (authwits)](../../aztec-js/how_to_use_authwit.md), the hook is invoked live, while execution is underway. Authwits can be recorded during simulation and signed once at the end, but the PXE cannot predict what a utility call would return, so it must ask before continuing. Most of the time the wallet should answer on its own, for example against a list of audited or previously trusted contracts, to avoid interrupting execution multiple times asking the user for confirmation. ### In Noir tests -The hook only exists on a real PXE. When testing cross-contract utility calls in the Noir test environment (TXE), use `with_authorized_utility_call_targets` on your call options: +When testing cross-contract utility calls in Noir using `TestEnvironment`, use `with_authorized_utility_call_targets` on your call options: ```rust // For private calls: @@ -67,20 +61,28 @@ env.execute_utility_opts( ); ``` -## `getDeliveryPrivacyPreference` +### In production -Called when message delivery needs a tagging secret and the executing contract has not pinned a tag-secret derivation. The hook lets the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination; see [Delivery privacy preference](../../aztec-nr/framework-description/note_delivery.md#delivery-privacy-preference) for the trade-offs and the defaults in each environment. +Pass an `authorizeUtilityCall` hook when [creating the PXE](#configuring-hooks). It receives a `UtilityCallAuthorizationRequest` with the caller and target addresses, their contract class IDs, the function selector, the function name, the arguments, and the caller context (`'private'`, `'private view'`, or `'utility'`). Return `{ authorized: true }` to allow the call, or `{ authorized: false, reason: '...' }` to deny it with a message. -### In production +When the hook is absent, cross-contract utility calls are denied. See [Cross-contract utility call denied](../../aztec-nr/debugging.md#cross-contract-utility-call-denied) for the resulting error. -Pass a `getDeliveryPrivacyPreference` hook when [creating the PXE](#configuring-hooks). It receives a `DeliveryPrivacyPreferenceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. +## `getDeliveryPrivacyPreference` -When the hook is absent, the PXE assumes `DeliveryPrivacyPreference.MAX_PRIVACY`, so privacy is never weakened without the wallet opting in. +Called when message delivery needs a tagging secret and the executing contract has not pinned a tag-secret derivation. The hook lets the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination; see [Delivery privacy preference](../../aztec-nr/framework-description/note_delivery.md#delivery-privacy-preference) for the trade-offs and the defaults in each environment. ### In Noir tests -The hook only exists on a real PXE. In the Noir test environment (TXE), which defaults to max privacy like a bare PXE, set the preference on the test environment; it affects message delivery in subsequent private executions: +When testing in Noir, `TestEnvironment` defaults to max privacy like a bare PXE. Set the preference when creating the environment; it affects message delivery in private executions: ```rust -env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()); +let env = TestEnvironment::new_opts( + TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()), +); ``` + +### In production + +Pass a `getDeliveryPrivacyPreference` hook when [creating the PXE](#configuring-hooks). It receives a `DeliveryPrivacyPreferenceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. + +When the hook is absent, the PXE assumes `DeliveryPrivacyPreference.MAX_PRIVACY`, so privacy is never weakened without the wallet opting in. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr index 10372c5bf3d0..c2e38f86be81 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr @@ -1,4 +1,4 @@ -use crate::protocol::{traits::Deserialize, utils::reader::Reader}; +use crate::protocol::{traits::{Deserialize, Serialize}, utils::reader::Reader}; /// A wallet-owned policy that selects the tag-secret derivation used by message delivery when the contract does not /// pin one. @@ -30,6 +30,7 @@ use crate::protocol::{traits::Deserialize, utils::reader::Reader}; /// - [`best_effort`](Self::best_effort): tags are derived from a non-interactive handshake, reusing an existing one /// or establishing it on the fly, letting the recipient discover the message without any prior sender-recipient /// coordination. Establishing it publishes an on-chain handshake that reveals information about the recipient. +#[derive(Eq, Serialize)] pub struct DeliveryPrivacyPreference { inner: u8, } @@ -57,7 +58,7 @@ impl DeliveryPrivacyPreference { let preference = Self { inner }; assert( (preference == Self::max_privacy()) | (preference == Self::best_effort()), - "unrecognized delivery privacy preference", + f"unrecognized delivery privacy preference: {inner}", ); preference } @@ -75,20 +76,17 @@ impl Deserialize for DeliveryPrivacyPreference { } } -impl Eq for DeliveryPrivacyPreference { - fn eq(self, other: Self) -> bool { - self.inner == other.inner - } -} - mod test { - use crate::protocol::traits::Deserialize; + use crate::protocol::traits::{Deserialize, Serialize}; use super::DeliveryPrivacyPreference; #[test] - fn deserialize_roundtrips_valid_preferences() { - assert(DeliveryPrivacyPreference::deserialize([1]) == DeliveryPrivacyPreference::max_privacy()); - assert(DeliveryPrivacyPreference::deserialize([2]) == DeliveryPrivacyPreference::best_effort()); + fn preference_roundtrips_through_serialization() { + let max_privacy = DeliveryPrivacyPreference::max_privacy(); + let best_effort = DeliveryPrivacyPreference::best_effort(); + + assert(DeliveryPrivacyPreference::deserialize(max_privacy.serialize()) == max_privacy); + assert(DeliveryPrivacyPreference::deserialize(best_effort.serialize()) == best_effort); } #[test(should_fail_with = "unrecognized delivery privacy preference")] diff --git a/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr index b484f03723a4..ae00350df9ea 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr @@ -17,39 +17,3 @@ unconstrained fn get_delivery_privacy_preference_oracle( _recipient: AztecAddress, _mode: OnchainDeliveryMode, ) -> DeliveryPrivacyPreference {} - -mod test { - use crate::messages::delivery::{DeliveryPrivacyPreference, OnchainDeliveryMode}; - use crate::protocol::{address::AztecAddress, traits::FromField}; - use crate::test::helpers::test_environment::TestEnvironment; - use super::get_delivery_privacy_preference; - - #[test] - unconstrained fn defaults_to_max_privacy() { - let env = TestEnvironment::new(); - - env.private_context(|_| { - let preference = get_delivery_privacy_preference( - AztecAddress::from_field(1), - AztecAddress::from_field(2), - OnchainDeliveryMode::onchain_unconstrained(), - ); - assert_eq(preference, DeliveryPrivacyPreference::max_privacy()); - }); - } - - #[test] - unconstrained fn returns_the_preference_set_in_the_test_environment() { - let env = TestEnvironment::new(); - env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()); - - env.private_context(|_| { - let preference = get_delivery_privacy_preference( - AztecAddress::from_field(1), - AztecAddress::from_field(2), - OnchainDeliveryMode::onchain_constrained(), - ); - assert_eq(preference, DeliveryPrivacyPreference::best_effort()); - }); - } -} 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 09ba33acbd57..c2441c622ccd 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 @@ -85,6 +85,37 @@ pub struct TestEnvironment { contract_deployment_salt: Counter, } +/// Configuration values for [`TestEnvironment::new_opts`]. Meant to be used by calling `new` and then chaining +/// methods setting each value, e.g.: +/// ```noir +/// let env = TestEnvironment::new_opts( +/// TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()), +/// ); +/// ``` +pub struct TestEnvironmentOptions { + delivery_privacy_preference: Option, +} + +impl TestEnvironmentOptions { + /// Creates a new `TestEnvironmentOptions` with default values, i.e. the same as if using + /// [`TestEnvironment::new`] instead of [`TestEnvironment::new_opts`]. Use + /// [`with_delivery_privacy_preference`](TestEnvironmentOptions::with_delivery_privacy_preference) and other + /// methods to set the desired configuration values. + pub fn new() -> Self { + Self { delivery_privacy_preference: Option::none() } + } + + /// Sets the [`DeliveryPrivacyPreference`](crate::messages::delivery::DeliveryPrivacyPreference) that the wallet + /// reports through the oracle in [`delivery_privacy_preference`](crate::oracle::delivery_privacy_preference), + /// affecting message delivery in private executions. + /// + /// If not set, defaults to [`DeliveryPrivacyPreference::max_privacy`]. + pub fn with_delivery_privacy_preference(&mut self, preference: DeliveryPrivacyPreference) -> Self { + self.delivery_privacy_preference = Option::some(preference); + *self + } +} + /// Configuration values for [`TestEnvironment::private_context_opts`]. Meant to be used by calling `new` and then /// chaining methods setting each value, e.g.: /// ```noir @@ -560,9 +591,29 @@ fn resolve_gas_settings( impl TestEnvironment { /// Creates a new `TestEnvironment`. This function should only be called once per test. + /// + /// See [`new_opts`](TestEnvironment::new_opts) for a variant that allows configuring the environment. pub unconstrained fn new() -> Self { + Self::new_opts(TestEnvironmentOptions::new()) + } + + /// Creates a new `TestEnvironment` with the configuration values in `options`. This function should only be + /// called once per test. + /// + /// ### Sample usage + /// ```noir + /// let env = TestEnvironment::new_opts( + /// TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()), + /// ); + /// ``` + pub unconstrained fn new_opts(options: TestEnvironmentOptions) -> Self { assert_compatible_oracle_version(); txe_oracles::assert_compatible_txe_oracle_version(); + + if options.delivery_privacy_preference.is_some() { + txe_oracles::set_delivery_privacy_preference(options.delivery_privacy_preference.unwrap()); + } + Self { // Use an offset to avoid secret collision with account secrets. Without this, when // deploying multiple contracts and creating accounts, they could end up with identical @@ -811,13 +862,6 @@ impl TestEnvironment { txe_oracles::advance_timestamp_by(duration); } - /// Sets the [`DeliveryPrivacyPreference`](crate::messages::delivery::DeliveryPrivacyPreference) that the wallet - /// reports through the oracle in [`delivery_privacy_preference`](crate::oracle::delivery_privacy_preference), - /// affecting message delivery in subsequent private executions. - pub unconstrained fn set_delivery_privacy_preference(_self: Self, preference: DeliveryPrivacyPreference) { - txe_oracles::set_delivery_privacy_preference(preference); - } - /// Creates a new account that can be used as the `from` parameter in contract calls, e.g. in `private_call` or /// `public_call`, or be made the owner or recipient of notes in `private_context`. /// diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr index d45456950b0b..6943d08f792c 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr @@ -7,4 +7,5 @@ mod public_context; mod utility_context; mod notes; mod time; +mod delivery_privacy_preference; mod misc; diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr new file mode 100644 index 000000000000..29b86b7ba5ae --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr @@ -0,0 +1,36 @@ +use crate::{ + messages::delivery::{DeliveryPrivacyPreference, OnchainDeliveryMode}, + oracle::delivery_privacy_preference::get_delivery_privacy_preference, + protocol::{address::AztecAddress, traits::FromField}, + test::helpers::test_environment::{TestEnvironment, TestEnvironmentOptions}, +}; + +#[test] +unconstrained fn delivery_privacy_preference_defaults_to_max_privacy() { + let env = TestEnvironment::new(); + + env.private_context(|_| { + let preference = get_delivery_privacy_preference( + AztecAddress::from_field(1), + AztecAddress::from_field(2), + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(preference, DeliveryPrivacyPreference::max_privacy()); + }); +} + +#[test] +unconstrained fn reports_the_delivery_privacy_preference_set_in_the_options() { + let env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_delivery_privacy_preference( + DeliveryPrivacyPreference::best_effort(), + )); + + env.private_context(|_| { + let preference = get_delivery_privacy_preference( + AztecAddress::from_field(1), + AztecAddress::from_field(2), + OnchainDeliveryMode::onchain_constrained(), + ); + assert_eq(preference, DeliveryPrivacyPreference::best_effort()); + }); +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index fe9c6a3ec271..44ec4c2ddf7a 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -10,12 +10,13 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { type BlockHeader, CallContext, type Capsule, TxContext } from '@aztec/stdlib/tx'; +import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { DeliveryPrivacyPreference, - type DeliveryPrivacyPreferenceRequest, + type GetDeliveryPrivacyPreference, } from '../../hooks/get_delivery_privacy_preference.js'; import type { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; @@ -88,22 +89,20 @@ describe('PrivateExecutionOracle', () => { }); it('returns the preference reported by the wallet hook', async () => { - const requests: DeliveryPrivacyPreferenceRequest[] = []; - const oracle = makeOracle({ - hooks: { - getDeliveryPrivacyPreference: request => { - requests.push(request); - return Promise.resolve(DeliveryPrivacyPreference.BEST_EFFORT); - }, - }, - }); + const getDeliveryPrivacyPreference = jest + .fn() + .mockResolvedValue(DeliveryPrivacyPreference.BEST_EFFORT); + const oracle = makeOracle({ hooks: { getDeliveryPrivacyPreference } }); await expect( oracle.getDeliveryPrivacyPreference(sender, recipient, AppTaggingSecretKind.UNCONSTRAINED), ).resolves.toEqual(DeliveryPrivacyPreference.BEST_EFFORT); - expect(requests).toEqual([ - { contractAddress, sender, recipient, deliveryMode: AppTaggingSecretKind.UNCONSTRAINED }, - ]); + expect(getDeliveryPrivacyPreference).toHaveBeenCalledWith({ + contractAddress, + sender, + recipient, + deliveryMode: AppTaggingSecretKind.UNCONSTRAINED, + }); }); }); diff --git a/yarn-project/pxe/src/hooks/execution_hooks.ts b/yarn-project/pxe/src/hooks/execution_hooks.ts index 7dec5b2a82cf..2a57aae89b63 100644 --- a/yarn-project/pxe/src/hooks/execution_hooks.ts +++ b/yarn-project/pxe/src/hooks/execution_hooks.ts @@ -5,7 +5,7 @@ import type { GetDeliveryPrivacyPreference } from './get_delivery_privacy_prefer * Hooks that PXE invokes during client-side simulation to gate or steer operations that the protocol * does not restrict on its own. They give the wallet a chance to apply custom policies (e.g. * prompting the user, consulting a dynamic allowlist, or inspecting call arguments) before the - * execution proceeds. Every hook is optional, and when a hook is absent PXE applies a safe default. + * execution proceeds. All hooks are optional, and when a hook is absent PXE applies a safe default. * * For example, {@link authorizeUtilityCall} is called whenever a utility function makes a cross-contract call. A call * made by a malicious contract could leak private information, so the hook lets the wallet decide, per-call, whether diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index eb1e03dbae49..00b283c93572 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -841,18 +841,16 @@ export class TXESession implements TXESessionStateHandler { } // Note that while all public and private contexts do is build a single block that we then process when exiting - // those, the top level context performs a large number of actions not captured in the following 'close' call. Among - // others, it will create empty blocks (via `advanceBlocksBy` and `deploy`), create blocks with transactions via - // `privateCallNewFlow` and `publicCallNewFlow`, add accounts to PXE via `addAccount`, etc. This is a - // slight inconsistency in the working model of this class, but is not too bad. - // TODO: it's quite unfortunate that we need to capture the authwits created to later pass them again when the top - // level context is re-created. This is because authwits create a temporary utility context that'd otherwise reset - // the authwits if not persisted, so we'd not be able to pass more than one per execution. - // Ideally authwits would be passed alongside a contract call instead of pre-seeded. - // - // The oracle handler is discarded on every state transition, so `close` hands back the session-scoped values that - // top-level cheatcodes may have mutated (next block timestamp, authwits, delivery privacy preference) for the - // session to seed into the contexts it creates later. + // them, the top level context does most of its work as it goes: it creates empty blocks (via `advanceBlocksBy` + // and `deploy`), creates blocks with transactions (via `privateCallNewFlow` and `publicCallNewFlow`), adds + // accounts to PXE (via `addAccount`), etc. This is a slight inconsistency in the working model of this class, but + // is not too bad. The `close` call below therefore only hands back the session-scoped values that top-level + // cheatcodes may have mutated (next block timestamp, authwits, delivery privacy preference). The oracle handler is + // discarded on every state transition, so the session must seed these values into the contexts it creates later. + + // TODO: persisting authwits this way is quite unfortunate: they create a temporary utility context that would + // otherwise reset them, so we'd not be able to pass more than one per execution. Ideally authwits would be passed + // alongside a contract call instead of pre-seeded. [this.nextBlockTimestamp, this.authwits, this.deliveryPrivacyPreference] = ( this.oracleHandler as TXEOracleTopLevelContext ).close(); From f5d14acbe7fd632292eaaa6e951d81b053730a32 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Mon, 15 Jun 2026 19:37:57 -0300 Subject: [PATCH 04/14] docs: clarify delivery preference is consulted only when establishing a secret --- .../aztec-nr/framework-description/note_delivery.md | 4 +++- .../docs/foundational-topics/pxe/execution_hooks.md | 2 +- yarn-project/pxe/src/hooks/execution_hooks.ts | 11 ++++++----- .../pxe/src/hooks/get_delivery_privacy_preference.ts | 9 +++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index 60d6ef64ce95..dcd15ca65f14 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -168,7 +168,7 @@ Onchain delivery tags every message so the recipient can find it efficiently (se - **Contracts** choose a delivery mode, and can optionally pin a tag-secret derivation via the `MessageDelivery` builders. By default they pin nothing and delegate the decision to the wallet. This is the recommended setting unless the contract requires a specific mechanism to work. - **Wallets** answer that delegation with the **delivery privacy preference**: a wallet-level setting with two values, **max privacy** and **best effort**. It decides how much privacy the user is willing to trade so that delivery works with less sender-recipient coordination. -The preference is consulted whenever a message needs a tagging secret and the contract has not pinned a derivation. +The preference is consulted only when delivery must establish a *new* tagging secret rather than reuse an existing handshake, and the contract has not pinned a derivation. ### Max privacy vs best effort @@ -181,6 +181,8 @@ The preference is consulted whenever a message needs a tagging secret and the co | Unconstrained delivery to an unknown recipient | Found only if the recipient registered the sender | Found without sender-recipient coordination | | Constrained delivery | Transaction is unprovable without an interactive handshake signed by the recipient | Works without recipient involvement | +Reuse is independent of the preference. If a non-interactive handshake was already established for a sender, recipient, and mode (for example by an earlier best effort send, or by another application), later messages reuse that secret even under max privacy. The preference governs how a secret is established, not whether an existing one is reused, so switching to max privacy does not retract a handshake that already exists. + ### Configuring the preference Wallets configure the preference through the `getDeliveryPrivacyPreference` [execution hook](../../foundational-topics/pxe/execution_hooks.md) when creating their PXE. The hook receives the message context (executing contract, sender, recipient and delivery mode), so a wallet can answer per message instead of with a fixed value. diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 913f6daf875c..1bb93e8b8ee6 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -69,7 +69,7 @@ When the hook is absent, cross-contract utility calls are denied. See [Cross-con ## `getDeliveryPrivacyPreference` -Called when message delivery needs a tagging secret and the executing contract has not pinned a tag-secret derivation. The hook lets the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination; see [Delivery privacy preference](../../aztec-nr/framework-description/note_delivery.md#delivery-privacy-preference) for the trade-offs and the defaults in each environment. +Called when message delivery must establish a new tagging secret rather than reuse an existing handshake, and the executing contract has not pinned a tag-secret derivation. An existing handshake is reused without invoking the hook. The hook lets the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination; see [Delivery privacy preference](../../aztec-nr/framework-description/note_delivery.md#delivery-privacy-preference) for the trade-offs and the defaults in each environment. ### In Noir tests diff --git a/yarn-project/pxe/src/hooks/execution_hooks.ts b/yarn-project/pxe/src/hooks/execution_hooks.ts index 2a57aae89b63..b56ff0384771 100644 --- a/yarn-project/pxe/src/hooks/execution_hooks.ts +++ b/yarn-project/pxe/src/hooks/execution_hooks.ts @@ -13,9 +13,10 @@ import type { GetDeliveryPrivacyPreference } from './get_delivery_privacy_prefer * time which contracts will be invoked during execution. Calls to standard contracts (such as the HandshakeRegistry) * bypass this hook and are always authorized. When the hook is absent, cross-contract utility calls are denied. * - * Similarly, {@link getDeliveryPrivacyPreference} is called when message delivery needs a tagging secret and the - * contract has not pinned a tag-secret derivation, letting the wallet choose between maximum privacy and delivery - * that requires no sender-recipient coordination. When the hook is absent, PXE assumes maximum privacy. + * Similarly, {@link getDeliveryPrivacyPreference} is called when message delivery must establish a new tagging + * secret rather than reuse an existing handshake, and the contract has not pinned a tag-secret derivation, letting + * the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination. An existing + * handshake is reused without invoking the hook. When the hook is absent, PXE assumes maximum privacy. * * Note: hooks are unrelated to authentication witnesses (authwits). Authwits are an on-chain * mechanism where a contract verifies that a caller was authorized by a specific account; hooks @@ -43,8 +44,8 @@ export interface ExecutionHooks { /** Called when a contract attempts a cross-contract utility call. Calls are denied when absent. */ authorizeUtilityCall?: AuthorizeUtilityCall; /** - * Called when message delivery needs a tagging secret and the contract has not pinned a tag-secret derivation. - * Maximum privacy is assumed when absent. + * Called when message delivery must establish a new tagging secret rather than reuse an existing handshake, and + * the contract has not pinned a tag-secret derivation. Maximum privacy is assumed when absent. */ getDeliveryPrivacyPreference?: GetDeliveryPrivacyPreference; } diff --git a/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts b/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts index 66b12a0de1ed..6aeabaacdb18 100644 --- a/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts +++ b/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts @@ -6,7 +6,7 @@ import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; * does not pin one. Max privacy never leaks information and instead relies on sender-recipient coordination for * delivery. Best effort accepts a privacy leak so that delivery requires no sender-recipient coordination at all. * - * The send flow consults this value whenever a message needs a tagging secret. + * The send flow consults this value only when it must establish a new tagging secret; an existing handshake is reused as-is. */ export enum DeliveryPrivacyPreference { /** @@ -50,9 +50,10 @@ export type DeliveryPrivacyPreferenceRequest = { }; /** - * Hook called when message delivery needs a tagging secret and the executing contract has not pinned a tag-secret - * derivation, letting the wallet choose between maximum privacy and delivery that requires no sender-recipient - * coordination (see {@link DeliveryPrivacyPreference} for the trade-offs involved). + * Hook called when message delivery must establish a new tagging secret rather than reuse an existing handshake, and + * the executing contract has not pinned a tag-secret derivation, letting the wallet choose between maximum privacy + * and delivery that requires no sender-recipient coordination (see {@link DeliveryPrivacyPreference} for the + * trade-offs involved). An existing handshake is reused without invoking the hook. * * The request identifies the message (executing contract, sender, recipient and delivery mode), so wallets can apply * per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. From 2ea81d084165dcdfd0b981bfd2b808745f4f1c9e Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Mon, 15 Jun 2026 20:20:23 -0300 Subject: [PATCH 05/14] docs: explain the cross-contract risk authorizeUtilityCall guards against --- .../docs/foundational-topics/pxe/execution_hooks.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 1bb93e8b8ee6..08382c0bd532 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -35,6 +35,14 @@ Called whenever a utility function makes a cross-contract call. A call made by a Unlike [authentication witnesses (authwits)](../../aztec-js/how_to_use_authwit.md), the hook is invoked live, while execution is underway. Authwits can be recorded during simulation and signed once at the end, but the PXE cannot predict what a utility call would return, so it must ask before continuing. Most of the time the wallet should answer on its own, for example against a list of audited or previously trusted contracts, to avoid interrupting execution multiple times asking the user for confirmation. +### Deciding what to authorize + +Private state is siloed per contract: a utility function runs on your device with access to its own contract's private state, and nothing else. Reading your own balance through a token contract's utility function is fine, and the hook never fires, because no contract boundary is crossed. The risk appears only when one contract's utility function calls into a *different* contract, because that call can reach private state the caller could not read on its own. + +Consider a single cross-contract operation, reading your token balance, made by two different callers. When a DeFi router calls the token's balance utility to quote you a swap, that is a legitimate cross-contract read, and you want it allowed. When an unknown, possibly malicious contract makes the very same call to snoop your balance, you want it denied. The exposed data is identical in both cases; the only thing that differs is *who is making the call*, which is exactly the decision the hook delegates to the wallet. + +The wallet makes that decision by inspecting the request, which identifies the caller and target by both address and contract class ID, to judge whether the call is safe to authorize. + ### In Noir tests When testing cross-contract utility calls in Noir using `TestEnvironment`, use `with_authorized_utility_call_targets` on your call options: From 4454c2ca07269f8ec2dccf528b84667b7cbe57b4 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 15:40:36 -0300 Subject: [PATCH 06/14] feat(pxe): resolve tagging secret source via wallet hook --- .../framework-description/note_delivery.md | 36 +++---- .../pxe/execution_hooks.md | 17 ++-- .../aztec/src/messages/delivery/mod.nr | 4 +- .../messages/delivery/privacy_preference.nr | 96 ------------------- .../delivery/tagging_secret_source.nr | 75 +++++++++++++++ .../src/oracle/delivery_privacy_preference.nr | 19 ---- .../aztec-nr/aztec/src/oracle/mod.nr | 2 +- .../src/oracle/resolve_tagging_secret.nr | 88 +++++++++++++++++ .../src/test/helpers/test_environment.nr | 31 +++--- .../src/test/helpers/test_environment/test.nr | 2 +- .../test/delivery_privacy_preference.nr | 36 ------- .../test/resolve_tagging_secret.nr | 61 ++++++++++++ .../aztec/src/test/helpers/txe_oracles.nr | 6 +- .../src/contract_function_simulator/index.ts | 2 +- .../noir-structs/tagging_secret_source.ts | 45 +++++++++ .../oracle/oracle_registry.ts | 10 +- .../oracle/oracle_type_mappings.ts | 18 ++-- .../oracle/private_execution_oracle.test.ts | 51 ++++++---- .../oracle/private_execution_oracle.ts | 50 +++++++--- yarn-project/pxe/src/hooks/execution_hooks.ts | 19 ++-- .../hooks/get_delivery_privacy_preference.ts | 66 ------------- yarn-project/pxe/src/hooks/index.ts | 9 +- .../pxe/src/hooks/resolve_tagging_secret.ts | 26 +++++ yarn-project/txe/src/oracle/interfaces.ts | 5 +- .../txe/src/oracle/txe_oracle_registry.ts | 6 +- .../oracle/txe_oracle_top_level_context.ts | 17 ++-- yarn-project/txe/src/rpc_translator.ts | 12 +-- yarn-project/txe/src/txe_session.ts | 15 +-- .../wallets/src/embedded/embedded_wallet.ts | 20 +--- .../src/embedded/entrypoints/browser.ts | 8 +- .../wallets/src/embedded/entrypoints/node.ts | 8 +- 31 files changed, 466 insertions(+), 394 deletions(-) delete mode 100644 noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr create mode 100644 noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr delete mode 100644 noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr create mode 100644 noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr delete mode 100644 noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr create mode 100644 noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_secret.nr create mode 100644 yarn-project/pxe/src/contract_function_simulator/noir-structs/tagging_secret_source.ts delete mode 100644 yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts create mode 100644 yarn-project/pxe/src/hooks/resolve_tagging_secret.ts diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index dcd15ca65f14..ea0e0448e5f0 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -161,37 +161,31 @@ Ask yourself: **"Is the sender incentivized to deliver this note correctly?"** - **Yes, but they cannot or prefer not to contact them offchain or you don't want to implement offchain delivery** Use `ONCHAIN_UNCONSTRAINED` - **No, the sender might not deliver correctly** Use `ONCHAIN_CONSTRAINED` -## Delivery privacy preference +## Tagging secret source -Onchain delivery tags every message so the recipient can find it efficiently (see [note discovery](#note-discovery-and-the-sender) below). Computing a tag requires a secret shared between sender and recipient. That secret can be derived in several ways, and the choice involves a privacy trade-off. Each party involved in message delivery owns a different part of the decision: +Onchain delivery tags every message so the recipient can find it efficiently (see [note discovery](#note-discovery-and-the-sender) below). Computing a tag requires a secret shared between sender and recipient, and there is more than one way for the two parties to come to share it. When one has already been established for the pair, it is reused directly. Otherwise the wallet decides how to proceed, since it knows which secrets it holds and how it wants to reach the recipient. -- **Contracts** choose a delivery mode, and can optionally pin a tag-secret derivation via the `MessageDelivery` builders. By default they pin nothing and delegate the decision to the wallet. This is the recommended setting unless the contract requires a specific mechanism to work. -- **Wallets** answer that delegation with the **delivery privacy preference**: a wallet-level setting with two values, **max privacy** and **best effort**. It decides how much privacy the user is willing to trade so that delivery works with less sender-recipient coordination. +The wallet's answer is a concrete **tagging secret source**. There are two sources today: -The preference is consulted only when delivery must establish a *new* tagging secret rather than reuse an existing handshake, and the contract has not pinned a derivation. +- **Non-interactive handshake**: the secret comes from a handshake published onchain that the recipient can derive. This reveals information about the recipient, but lets them discover the message without any prior sender-recipient coordination. Works for both constrained and unconstrained delivery. +- **Shared secret**: a secret the two parties already share offchain, having coordinated out of band to agree on it (for example derived via Diffie-Hellman from each other's address keys). It leaves no onchain trace, but because nothing onchain proves the recipient knows it, it is only sound for unconstrained delivery. -### Max privacy vs best effort - -- **Max privacy** (the PXE default): nothing that could link sender and recipient is ever published, and delivery relies on sender-recipient coordination. Unconstrained delivery uses a secret derived from the sender and recipient addresses, which leaves no onchain trace, but the recipient only finds the message if they registered the sender in their PXE. Constrained delivery requires an interactive handshake with the recipient: when none exists the transaction itself cannot be proven, because there is no privacy-preserving way to establish a secret on the fly. -- **Best effort**: tags are derived from a non-interactive handshake, reusing an existing one or establishing it onchain as part of the send. The recipient discovers the message without knowing the sender in advance or coordinating with them in any other way, at the cost of publishing a handshake that reveals information about the recipient. - -| | Max privacy | Best effort | +| | Non-interactive handshake | Shared secret | |---|---|---| -| Onchain footprint when establishing a secret | None | A handshake revealing information about the recipient | -| Unconstrained delivery to an unknown recipient | Found only if the recipient registered the sender | Found without sender-recipient coordination | -| Constrained delivery | Transaction is unprovable without an interactive handshake signed by the recipient | Works without recipient involvement | +| Onchain footprint when establishing | A handshake revealing information about the recipient | None | +| Unconstrained delivery to an unknown recipient | Found without sender-recipient coordination | Found only if the recipient can derive the same secret | +| Constrained delivery | Supported | Not sound: nothing proves the recipient knows the secret | -Reuse is independent of the preference. If a non-interactive handshake was already established for a sender, recipient, and mode (for example by an earlier best effort send, or by another application), later messages reuse that secret even under max privacy. The preference governs how a secret is established, not whether an existing one is reused, so switching to max privacy does not retract a handshake that already exists. +### Defaults -### Configuring the preference +When no `resolveTaggingSecret` hook is configured, the PXE applies a privacy-safe default: -Wallets configure the preference through the `getDeliveryPrivacyPreference` [execution hook](../../foundational-topics/pxe/execution_hooks.md) when creating their PXE. The hook receives the message context (executing contract, sender, recipient and delivery mode), so a wallet can answer per message instead of with a fixed value. +- **Unconstrained delivery**: an address-derived (Diffie-Hellman) shared secret. It leaves no onchain trace, but the recipient only finds the message if they registered the sender in their PXE. +- **Constrained delivery**: fails, rather than silently revealing the recipient through a non-interactive handshake. -The defaults differ by environment: +### Configuring the source -- **PXE**: max privacy. A bare PXE makes the conservative choice and never leaks without opt-in. -- **Embedded wallet** (`@aztec/wallets/embedded`): best effort. It targets development scenarios where delivery working out of the box matters more than handshake privacy. Override it by passing your own `hooks.getDeliveryPrivacyPreference` in its `pxe` options. -- **`TestEnvironment` tests**: max privacy, matching the bare PXE. Tests opt into best effort by passing `TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort())` to `TestEnvironment::new_opts`. +Wallets provide the source through the `resolveTaggingSecret` [execution hook](../../foundational-topics/pxe/execution_hooks.md) when creating their PXE. The hook receives the message context (executing contract, sender, recipient and delivery mode), so a wallet can answer per message instead of with a fixed value. That page also covers how to configure a source in Noir tests. ## Note Discovery and the Sender diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 08382c0bd532..5d5363f253c3 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -13,7 +13,6 @@ Pass a `hooks` object when creating the PXE: ```typescript import { createPXE } from "@aztec/pxe/server"; -import { DeliveryPrivacyPreference } from "@aztec/pxe/config"; const pxe = await createPXE(node, config, { hooks: { @@ -23,8 +22,8 @@ const pxe = await createPXE(node, config, { ? { authorized: true } : { authorized: false, reason: "Unknown target" }; }, - // Accept the privacy leak of on-the-fly handshakes so messages reach recipients that haven't registered the sender. - getDeliveryPrivacyPreference: async () => DeliveryPrivacyPreference.BEST_EFFORT, + // When there's no established way to reach the recipient, fall back to a non-interactive handshake. + resolveTaggingSecret: async () => ({ type: "non-interactive-handshake" }), }, }); ``` @@ -75,22 +74,22 @@ Pass an `authorizeUtilityCall` hook when [creating the PXE](#configuring-hooks). When the hook is absent, cross-contract utility calls are denied. See [Cross-contract utility call denied](../../aztec-nr/debugging.md#cross-contract-utility-call-denied) for the resulting error. -## `getDeliveryPrivacyPreference` +## `resolveTaggingSecret` -Called when message delivery must establish a new tagging secret rather than reuse an existing handshake, and the executing contract has not pinned a tag-secret derivation. An existing handshake is reused without invoking the hook. The hook lets the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination; see [Delivery privacy preference](../../aztec-nr/framework-description/note_delivery.md#delivery-privacy-preference) for the trade-offs and the defaults in each environment. +Called as a fallback when message delivery has no established tagging secret to reuse for a sender-recipient pair: an established secret is reused without invoking the hook, so it only fires when none exists yet. The wallet returns a concrete `TaggingSecretSource` (and any material the chosen derivation needs); see [Tagging secret source](../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source) for the variants, the trade-offs, and the defaults in each environment. ### In Noir tests -When testing in Noir, `TestEnvironment` defaults to max privacy like a bare PXE. Set the preference when creating the environment; it affects message delivery in private executions: +When testing in Noir, leaving the source unset makes `TestEnvironment` fall back to the bare PXE default. Set a source when creating the environment to exercise a specific one; it affects message delivery in private executions: ```rust let env = TestEnvironment::new_opts( - TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()), + TestEnvironmentOptions::new().with_tagging_secret_source(TaggingSecretSource::non_interactive_handshake()), ); ``` ### In production -Pass a `getDeliveryPrivacyPreference` hook when [creating the PXE](#configuring-hooks). It receives a `DeliveryPrivacyPreferenceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. +Pass a `resolveTaggingSecret` hook when [creating the PXE](#configuring-hooks). It receives a `TaggingSecretSourceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. -When the hook is absent, the PXE assumes `DeliveryPrivacyPreference.MAX_PRIVACY`, so privacy is never weakened without the wallet opting in. +When the hook is absent, the PXE applies a privacy-safe default: unconstrained delivery uses an address-derived (Diffie-Hellman) shared secret, which leaves no on-chain trace, while constrained delivery fails rather than silently revealing the recipient through a non-interactive handshake. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr index 9952c98b0b7d..31747eaa247b 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr @@ -1,7 +1,7 @@ mod builder; mod mode; -mod privacy_preference; mod tag_secret_derivation; +mod tagging_secret_source; pub mod handshake; @@ -22,7 +22,7 @@ pub use builder::{ MessageDelivery, MessageDeliveryBuilder, OffchainDelivery, OnchainConstrainedDelivery, OnchainUnconstrainedDelivery, }; pub use mode::OnchainDeliveryMode; -pub use privacy_preference::DeliveryPrivacyPreference; +pub use tagging_secret_source::TaggingSecretSource; /// Performs private delivery of a message to `recipient` according to `delivery_mode`. /// diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr deleted file mode 100644 index c2e38f86be81..000000000000 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/privacy_preference.nr +++ /dev/null @@ -1,96 +0,0 @@ -use crate::protocol::{traits::{Deserialize, Serialize}, utils::reader::Reader}; - -/// A wallet-owned policy that selects the tag-secret derivation used by message delivery when the contract does not -/// pin one. -/// -/// Max privacy never leaks information and instead relies on sender-recipient coordination for delivery. Best effort -/// accepts a privacy leak so that delivery requires no sender-recipient coordination at all. -/// -/// ## Who configures what -/// -/// Each party involved in message delivery owns a different knob: -/// -/// - **Contracts** choose a delivery mode and can optionally pin a tag-secret derivation via the `via_*` methods on -/// the [`MessageDelivery`](crate::messages::delivery::MessageDelivery) builders (see -/// [`OnchainUnconstrainedDelivery`](crate::messages::delivery::OnchainUnconstrainedDelivery) and -/// [`OnchainConstrainedDelivery`](crate::messages::delivery::OnchainConstrainedDelivery)). When they pin nothing, -/// the decision is delegated to the wallet, which is the recommended default. -/// - **Wallets** answer that delegation with this preference, reported through the -/// [`get_delivery_privacy_preference`](crate::oracle::delivery_privacy_preference::get_delivery_privacy_preference) -/// oracle. -/// -/// ## Privacy -/// -/// The two variants trade off as follows: -/// -/// - [`max_privacy`](Self::max_privacy): nothing that could link sender and recipient is published, and delivery -/// relies on sender-recipient coordination. Unconstrained delivery uses the address-pair (ECDH) secret, which is -/// derived locally and leaves no on-chain trace, but the recipient only finds the message if they registered the -/// sender. Constrained delivery requires an interactive handshake with the recipient, and fails when none exists. -/// - [`best_effort`](Self::best_effort): tags are derived from a non-interactive handshake, reusing an existing one -/// or establishing it on the fly, letting the recipient discover the message without any prior sender-recipient -/// coordination. Establishing it publishes an on-chain handshake that reveals information about the recipient. -#[derive(Eq, Serialize)] -pub struct DeliveryPrivacyPreference { - inner: u8, -} - -impl DeliveryPrivacyPreference { - /// Never leak information to obtain a tagging secret. - /// - /// Delivery relies on sender-recipient coordination instead: unconstrained messages are only found by recipients - /// who registered the sender, and constrained delivery requires an interactive handshake. - pub fn max_privacy() -> Self { - Self { inner: 1 } - } - - /// Accept a privacy leak to deliver without sender-recipient coordination. - /// - /// Tags are derived from a non-interactive handshake, reused when one exists and otherwise established on the - /// fly. The handshake is published on-chain and reveals information about the recipient. In exchange, the - /// recipient discovers the message without coordinating with the sender in advance. - pub fn best_effort() -> Self { - Self { inner: 2 } - } - - /// Validates a raw discriminant, as deserialization must always reject unknown values. - fn from_u8(inner: u8) -> Self { - let preference = Self { inner }; - assert( - (preference == Self::max_privacy()) | (preference == Self::best_effort()), - f"unrecognized delivery privacy preference: {inner}", - ); - preference - } -} - -impl Deserialize for DeliveryPrivacyPreference { - let N: u32 = ::N; - - fn deserialize(fields: [Field; Self::N]) -> Self { - Self::from_u8(::deserialize(fields)) - } - - fn stream_deserialize(reader: &mut Reader) -> Self { - Self::from_u8(reader.read() as u8) - } -} - -mod test { - use crate::protocol::traits::{Deserialize, Serialize}; - use super::DeliveryPrivacyPreference; - - #[test] - fn preference_roundtrips_through_serialization() { - let max_privacy = DeliveryPrivacyPreference::max_privacy(); - let best_effort = DeliveryPrivacyPreference::best_effort(); - - assert(DeliveryPrivacyPreference::deserialize(max_privacy.serialize()) == max_privacy); - assert(DeliveryPrivacyPreference::deserialize(best_effort.serialize()) == best_effort); - } - - #[test(should_fail_with = "unrecognized delivery privacy preference")] - fn deserializing_invalid_preference_fails() { - let _ = DeliveryPrivacyPreference::deserialize([99]); - } -} diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr new file mode 100644 index 000000000000..bef158d5b08b --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr @@ -0,0 +1,75 @@ +use crate::protocol::{traits::{Deserialize, Serialize}, utils::reader::Reader}; + +global NON_INTERACTIVE_HANDSHAKE: u8 = 1; +global SHARED_SECRET: u8 = 2; + +/// How the tagging secret for a message is sourced, as decided by the wallet through the +/// [`resolve_tagging_secret`](crate::oracle::resolve_tagging_secret) oracle. +/// +/// Message delivery tags each on-chain message so the recipient can find it without scanning every log. The tag is +/// derived from a secret shared between sender and recipient. When one has already been established for the pair it is +/// reused. Otherwise, the wallet must decide how to source it: this type is that decision, carrying any material the +/// chosen derivation needs. +#[derive(Eq, Serialize)] +pub struct TaggingSecretSource { + kind: u8, + secret: Field, +} + +impl TaggingSecretSource { + /// A secret established through a non-interactive handshake, derivable by the recipient from the on-chain handshake + /// registry. + pub fn non_interactive_handshake() -> Self { + Self { kind: NON_INTERACTIVE_HANDSHAKE, secret: 0 } + } + + /// A secret the two parties already share off-chain: they must have coordinated somehow to both know it (e.g. + /// derived via Diffie-Hellman from each other's address keys). Because nothing on-chain proves the recipient knows + /// it, this is only sound for unconstrained delivery. + pub fn shared_secret(secret: Field) -> Self { + Self { kind: SHARED_SECRET, secret } + } + + /// Validates a raw discriminant, as deserialization must always reject unknown values. + fn from_parts(kind: u8, secret: Field) -> Self { + let source = Self { kind, secret }; + assert( + (kind == NON_INTERACTIVE_HANDSHAKE) | (kind == SHARED_SECRET), + f"unrecognized tagging secret source kind: {kind}", + ); + source + } +} + +impl Deserialize for TaggingSecretSource { + let N: u32 = 2; + + fn deserialize(fields: [Field; Self::N]) -> Self { + Self::from_parts(fields[0] as u8, fields[1]) + } + + fn stream_deserialize(reader: &mut Reader) -> Self { + let kind = reader.read() as u8; + let secret = reader.read(); + Self::from_parts(kind, secret) + } +} + +mod test { + use crate::protocol::traits::{Deserialize, Serialize}; + use super::TaggingSecretSource; + + #[test] + fn source_roundtrips_through_serialization() { + let non_interactive = TaggingSecretSource::non_interactive_handshake(); + let shared = TaggingSecretSource::shared_secret(42); + + assert(TaggingSecretSource::deserialize(non_interactive.serialize()) == non_interactive); + assert(TaggingSecretSource::deserialize(shared.serialize()) == shared); + } + + #[test(should_fail_with = "unrecognized tagging secret source kind")] + fn deserializing_invalid_kind_fails() { + let _ = TaggingSecretSource::deserialize([99, 0]); + } +} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr deleted file mode 100644 index ae00350df9ea..000000000000 --- a/noir-projects/aztec-nr/aztec/src/oracle/delivery_privacy_preference.nr +++ /dev/null @@ -1,19 +0,0 @@ -use crate::messages::delivery::{DeliveryPrivacyPreference, OnchainDeliveryMode}; -use crate::protocol::address::AztecAddress; - -/// Returns the wallet's [`DeliveryPrivacyPreference`] for a message sent from `sender` to `recipient` with the given -/// delivery `mode`. -pub(crate) unconstrained fn get_delivery_privacy_preference( - sender: AztecAddress, - recipient: AztecAddress, - mode: OnchainDeliveryMode, -) -> DeliveryPrivacyPreference { - get_delivery_privacy_preference_oracle(sender, recipient, mode) -} - -#[oracle(aztec_prv_getDeliveryPrivacyPreference)] -unconstrained fn get_delivery_privacy_preference_oracle( - _sender: AztecAddress, - _recipient: AztecAddress, - _mode: OnchainDeliveryMode, -) -> DeliveryPrivacyPreference {} diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 75773b8fdd1c..1cb4fad367d4 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -10,8 +10,8 @@ pub mod capsules; pub(crate) mod ephemeral_oracles; pub(crate) mod transient_oracles; pub mod contract_sync; -pub mod delivery_privacy_preference; pub mod public_call; +pub mod resolve_tagging_secret; pub mod tx_phase; pub mod execution; pub mod execution_cache; diff --git a/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr b/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr new file mode 100644 index 000000000000..6ba9f5f99b27 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr @@ -0,0 +1,88 @@ +use crate::messages::delivery::{OnchainDeliveryMode, TaggingSecretSource}; +use crate::oracle::random::random; +use crate::protocol::address::AztecAddress; + +/// Sources the tagging secret for a message to `recipient`. +/// +/// Consulted only when no tagging secret has been established for the `(sender, recipient)` pair yet; an established +/// secret is reused without asking the wallet. See [`TaggingSecretSource`] for the variants and their trade-offs. +/// +/// An invalid recipient has no shared secret to derive. Unconstrained delivery returns a random source (an +/// undiscoverable tag) rather than letting a malformed recipient abort the send; constrained delivery instead fails, +/// since it cannot be served and must not silently emit an undiscoverable tag. +pub(crate) unconstrained fn resolve_tagging_secret( + sender: AztecAddress, + recipient: AztecAddress, + mode: OnchainDeliveryMode, +) -> TaggingSecretSource { + if recipient.is_valid() { + resolve_tagging_secret_oracle(sender, recipient, mode) + } else if mode == OnchainDeliveryMode::onchain_constrained() { + // Constrained delivery to an invalid recipient cannot be served, so fail. + panic( + "Cannot resolve a constrained tagging secret for an invalid recipient", + ) + } else { + // Unconstrained is best-effort: a random source yields an undiscoverable tag instead of failing the send. + TaggingSecretSource::shared_secret(random()) + } +} + +#[oracle(aztec_prv_resolveTaggingSecret)] +unconstrained fn resolve_tagging_secret_oracle( + _sender: AztecAddress, + _recipient: AztecAddress, + _mode: OnchainDeliveryMode, +) -> TaggingSecretSource {} + +mod test { + use crate::messages::delivery::{OnchainDeliveryMode, TaggingSecretSource}; + use crate::protocol::{address::AztecAddress, traits::FromField}; + use super::resolve_tagging_secret; + use std::test::OracleMock; + + // x = 8 is a valid address (8^3 - 17 is a residue); x = 3 is not (see AztecAddress::is_valid). + global VALID_RECIPIENT: AztecAddress = AztecAddress::from_field(8); + global INVALID_RECIPIENT: AztecAddress = AztecAddress::from_field(3); + + #[test] + unconstrained fn asks_the_wallet_for_a_valid_recipient() { + let source = TaggingSecretSource::shared_secret(123); + let _ = OracleMock::mock("aztec_prv_resolveTaggingSecret").returns(source); + + let resolved = resolve_tagging_secret( + AztecAddress::from_field(1), + VALID_RECIPIENT, + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(resolved, source); + } + + #[test] + unconstrained fn uses_a_random_source_for_an_invalid_recipient_when_unconstrained() { + let random_secret = 999; + let _ = OracleMock::mock("aztec_misc_getRandomField").returns(random_secret); + let resolve_oracle = OracleMock::mock("aztec_prv_resolveTaggingSecret").returns( + TaggingSecretSource::non_interactive_handshake(), + ); + + let resolved = resolve_tagging_secret( + AztecAddress::from_field(1), + INVALID_RECIPIENT, + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(resolved, TaggingSecretSource::shared_secret(random_secret)); + + // An invalid recipient must short-circuit to the random source without consulting the wallet. + assert_eq(resolve_oracle.times_called(), 0); + } + + #[test(should_fail_with = "Cannot resolve a constrained tagging secret for an invalid recipient")] + unconstrained fn fails_for_an_invalid_recipient_when_constrained() { + let _ = resolve_tagging_secret( + AztecAddress::from_field(1), + INVALID_RECIPIENT, + OnchainDeliveryMode::onchain_constrained(), + ); + } +} 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 c2441c622ccd..31e532c2f41e 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment.nr @@ -13,7 +13,7 @@ use crate::{ event::{event_interface::EventInterface, EventMessage}, hash::hash_args, messages::{ - delivery::DeliveryPrivacyPreference, + delivery::TaggingSecretSource, discovery::{ ComputeNoteHash, ComputeNoteNullifier, CustomMessageHandler, process_message::process_message_plaintext, }, @@ -89,29 +89,27 @@ pub struct TestEnvironment { /// methods setting each value, e.g.: /// ```noir /// let env = TestEnvironment::new_opts( -/// TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()), +/// TestEnvironmentOptions::new().with_tagging_secret_source(TaggingSecretSource::non_interactive_handshake()), /// ); /// ``` pub struct TestEnvironmentOptions { - delivery_privacy_preference: Option, + tagging_secret_source: Option, } impl TestEnvironmentOptions { /// Creates a new `TestEnvironmentOptions` with default values, i.e. the same as if using - /// [`TestEnvironment::new`] instead of [`TestEnvironment::new_opts`]. Use - /// [`with_delivery_privacy_preference`](TestEnvironmentOptions::with_delivery_privacy_preference) and other - /// methods to set the desired configuration values. + /// [`TestEnvironment::new`] instead of [`TestEnvironment::new_opts`]. pub fn new() -> Self { - Self { delivery_privacy_preference: Option::none() } + Self { tagging_secret_source: Option::none() } } - /// Sets the [`DeliveryPrivacyPreference`](crate::messages::delivery::DeliveryPrivacyPreference) that the wallet - /// reports through the oracle in [`delivery_privacy_preference`](crate::oracle::delivery_privacy_preference), - /// affecting message delivery in private executions. + /// Sets the [`TaggingSecretSource`](crate::messages::delivery::TaggingSecretSource) that the wallet reports through + /// the oracle in [`resolve_tagging_secret`](crate::oracle::resolve_tagging_secret), affecting message delivery in + /// private executions. /// - /// If not set, defaults to [`DeliveryPrivacyPreference::max_privacy`]. - pub fn with_delivery_privacy_preference(&mut self, preference: DeliveryPrivacyPreference) -> Self { - self.delivery_privacy_preference = Option::some(preference); + /// If not set, the wallet hook is left unconfigured and the private execution environment applies its own default. + pub fn with_tagging_secret_source(&mut self, source: TaggingSecretSource) -> Self { + self.tagging_secret_source = Option::some(source); *self } } @@ -603,16 +601,15 @@ impl TestEnvironment { /// ### Sample usage /// ```noir /// let env = TestEnvironment::new_opts( - /// TestEnvironmentOptions::new().with_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort()), + /// TestEnvironmentOptions::new().with_tagging_secret_source(TaggingSecretSource::non_interactive_handshake()), /// ); /// ``` pub unconstrained fn new_opts(options: TestEnvironmentOptions) -> Self { assert_compatible_oracle_version(); txe_oracles::assert_compatible_txe_oracle_version(); - if options.delivery_privacy_preference.is_some() { - txe_oracles::set_delivery_privacy_preference(options.delivery_privacy_preference.unwrap()); - } + // Forward the configured source to the wallet, clearing it (None) when unset. + txe_oracles::set_tagging_secret_source(options.tagging_secret_source); Self { // Use an offset to avoid secret collision with account secrets. Without this, when diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr index 6943d08f792c..1a6bd0e8cf2b 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test.nr @@ -7,5 +7,5 @@ mod public_context; mod utility_context; mod notes; mod time; -mod delivery_privacy_preference; mod misc; +mod resolve_tagging_secret; diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr deleted file mode 100644 index 29b86b7ba5ae..000000000000 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/delivery_privacy_preference.nr +++ /dev/null @@ -1,36 +0,0 @@ -use crate::{ - messages::delivery::{DeliveryPrivacyPreference, OnchainDeliveryMode}, - oracle::delivery_privacy_preference::get_delivery_privacy_preference, - protocol::{address::AztecAddress, traits::FromField}, - test::helpers::test_environment::{TestEnvironment, TestEnvironmentOptions}, -}; - -#[test] -unconstrained fn delivery_privacy_preference_defaults_to_max_privacy() { - let env = TestEnvironment::new(); - - env.private_context(|_| { - let preference = get_delivery_privacy_preference( - AztecAddress::from_field(1), - AztecAddress::from_field(2), - OnchainDeliveryMode::onchain_unconstrained(), - ); - assert_eq(preference, DeliveryPrivacyPreference::max_privacy()); - }); -} - -#[test] -unconstrained fn reports_the_delivery_privacy_preference_set_in_the_options() { - let env = TestEnvironment::new_opts(TestEnvironmentOptions::new().with_delivery_privacy_preference( - DeliveryPrivacyPreference::best_effort(), - )); - - env.private_context(|_| { - let preference = get_delivery_privacy_preference( - AztecAddress::from_field(1), - AztecAddress::from_field(2), - OnchainDeliveryMode::onchain_constrained(), - ); - assert_eq(preference, DeliveryPrivacyPreference::best_effort()); - }); -} diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_secret.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_secret.nr new file mode 100644 index 000000000000..ef01b1cc4176 --- /dev/null +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/test_environment/test/resolve_tagging_secret.nr @@ -0,0 +1,61 @@ +use crate::{ + messages::delivery::{OnchainDeliveryMode, TaggingSecretSource}, + oracle::{notes::get_app_tagging_secret, resolve_tagging_secret::resolve_tagging_secret}, + protocol::{address::AztecAddress, traits::FromField}, + test::helpers::test_environment::{TestEnvironment, TestEnvironmentOptions}, +}; + +#[test] +unconstrained fn defaults_unconstrained_delivery_to_the_address_derived_shared_secret() { + let mut env = TestEnvironment::new(); + let sender = env.create_light_account(); + let recipient = env.create_light_account(); + + env.private_context(|_| { + let resolved = resolve_tagging_secret( + sender, + recipient, + OnchainDeliveryMode::onchain_unconstrained(), + ); + + // With no source configured, the default derives the address-based (Diffie-Hellman) shared secret. + let expected_secret = get_app_tagging_secret(sender, recipient).unwrap(); + assert_eq(resolved, TaggingSecretSource::shared_secret(expected_secret)); + }); +} + +#[test(should_fail_with = "requires a configured resolveTaggingSecret hook")] +unconstrained fn constrained_delivery_fails_without_a_configured_source() { + let mut env = TestEnvironment::new(); + let sender = env.create_light_account(); + let recipient = env.create_light_account(); + + env.private_context(|_| { + let _ = resolve_tagging_secret( + sender, + recipient, + OnchainDeliveryMode::onchain_constrained(), + ); + }); +} + +#[test] +unconstrained fn returns_the_shared_secret_source_set_in_the_options() { + let source = TaggingSecretSource::shared_secret(42); + let mut env = TestEnvironment::new_opts( + TestEnvironmentOptions::new().with_tagging_secret_source(source), + ); + // The hook path looks up the executing contract's class ID, so run in a deployed contract's context. The recipient + // is valid, so resolution consults the wallet rather than falling back to a random source. + let app = env.create_contract_account(); + let recipient = env.create_light_account(); + + env.private_context_at(app, |_| { + let resolved = resolve_tagging_secret( + AztecAddress::from_field(1), + recipient, + OnchainDeliveryMode::onchain_unconstrained(), + ); + assert_eq(resolved, source); + }); +} diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr index c610c4ba2b73..6a9a202dfbed 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/txe_oracles.nr @@ -1,7 +1,7 @@ use crate::{ context::inputs::PrivateContextInputs, event::EventSelector, - messages::{delivery::DeliveryPrivacyPreference, encoding::MESSAGE_CIPHERTEXT_LEN}, + messages::{delivery::TaggingSecretSource, encoding::MESSAGE_CIPHERTEXT_LEN}, test::helpers::utils::TestAccount, }; @@ -255,8 +255,8 @@ pub unconstrained fn add_account(secret: Field) -> TestAccount {} #[oracle(aztec_txe_addAuthWitness)] pub unconstrained fn add_authwit(address: AztecAddress, message_hash: Field) {} -#[oracle(aztec_txe_setDeliveryPrivacyPreference)] -pub unconstrained fn set_delivery_privacy_preference(preference: DeliveryPrivacyPreference) {} +#[oracle(aztec_txe_setTaggingSecretSource)] +pub unconstrained fn set_tagging_secret_source(source: Option) {} #[oracle(aztec_txe_privateCallNewFlow)] unconstrained fn private_call_new_flow_oracle( diff --git a/yarn-project/pxe/src/contract_function_simulator/index.ts b/yarn-project/pxe/src/contract_function_simulator/index.ts index 3db30bf7b675..ba1a19aa7e44 100644 --- a/yarn-project/pxe/src/contract_function_simulator/index.ts +++ b/yarn-project/pxe/src/contract_function_simulator/index.ts @@ -17,7 +17,7 @@ export { BUFFER, BYTE, DELIVERY_MODE, - DELIVERY_PRIVACY_PREFERENCE, + TAGGING_SECRET_SOURCE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, FIELD, diff --git a/yarn-project/pxe/src/contract_function_simulator/noir-structs/tagging_secret_source.ts b/yarn-project/pxe/src/contract_function_simulator/noir-structs/tagging_secret_source.ts new file mode 100644 index 000000000000..ffbceb3d66c6 --- /dev/null +++ b/yarn-project/pxe/src/contract_function_simulator/noir-structs/tagging_secret_source.ts @@ -0,0 +1,45 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; + +const NON_INTERACTIVE_HANDSHAKE = 1; +const SHARED_SECRET = 2; + +/** + * How a message's tagging secret is sourced, decided by the wallet's `resolveTaggingSecret` hook when no secret is + * already established for the sender/recipient pair. Each variant carries whatever material its derivation needs. + */ +export type TaggingSecretSource = + | { + /** Establish a fresh non-interactive handshake via the on-chain registry; reveals the recipient on-chain. */ + type: 'non-interactive-handshake'; + } + | { + /** + * A secret the two parties already share off-chain (e.g. via Diffie-Hellman); only sound for unconstrained + * delivery, since nothing on-chain proves the recipient knows it. + */ + type: 'shared-secret'; + /** The shared secret to derive the tag from. */ + secret: Fr; + }; + +/** Serializes a {@link TaggingSecretSource} to its Noir `[kind, secret]` field layout, zero-filling absent fields. */ +export function taggingSecretSourceToFields(source: TaggingSecretSource): Fr[] { + switch (source.type) { + case 'non-interactive-handshake': + return [new Fr(NON_INTERACTIVE_HANDSHAKE), Fr.ZERO]; + case 'shared-secret': + return [new Fr(SHARED_SECRET), source.secret]; + } +} + +/** Deserializes a {@link TaggingSecretSource} from its `kind` discriminant and `secret` field. */ +export function taggingSecretSourceFromFields(kind: number, secret: Fr): TaggingSecretSource { + switch (kind) { + case NON_INTERACTIVE_HANDSHAKE: + return { type: 'non-interactive-handshake' }; + case SHARED_SECRET: + return { type: 'shared-secret', secret }; + default: + throw new Error(`Unrecognized tagging secret source kind: ${kind}`); + } +} diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts index 2820a235ae00..01d60c926abd 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_registry.ts @@ -18,7 +18,6 @@ import { CONTRACT_CLASS_LOG_INPUT, CONTRACT_INSTANCE, DELIVERY_MODE, - DELIVERY_PRIVACY_PREFERENCE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, FIELD, @@ -43,6 +42,7 @@ import { PUBLIC_DATA_WITNESS, PUBLIC_KEYS_AND_PARTIAL_ADDRESS, STR, + TAGGING_SECRET_SOURCE, TX_EFFECT, TX_HASH, type TypeMapping, @@ -61,7 +61,7 @@ export { BUFFER, BYTE, DELIVERY_MODE, - DELIVERY_PRIVACY_PREFERENCE, + TAGGING_SECRET_SOURCE, EPHEMERAL_ARRAY, EVENT_VALIDATION_REQUEST, FIELD, @@ -144,7 +144,7 @@ type OracleRegistryName = | 'aztec_prv_getAppTaggingSecret' | 'aztec_prv_getNextTaggingIndex' | 'aztec_prv_getSenderForTags' - | 'aztec_prv_getDeliveryPrivacyPreference'; + | 'aztec_prv_resolveTaggingSecret'; type OracleRegistry = Record; @@ -570,13 +570,13 @@ export const ORACLE_REGISTRY: OracleRegistry = { aztec_prv_getSenderForTags: makeEntry({ returnType: OPTION(AZTEC_ADDRESS) }), - aztec_prv_getDeliveryPrivacyPreference: makeEntry({ + aztec_prv_resolveTaggingSecret: makeEntry({ params: [ { name: 'sender', type: AZTEC_ADDRESS }, { name: 'recipient', type: AZTEC_ADDRESS }, { name: 'deliveryMode', type: DELIVERY_MODE }, ], - returnType: DELIVERY_PRIVACY_PREFERENCE, + returnType: TAGGING_SECRET_SOURCE, }), }; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts index a0824b9ffcaf..d4701a83147c 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_type_mappings.ts @@ -37,10 +37,6 @@ import { import { NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; import { BlockHeader, TxEffect, TxHash } from '@aztec/stdlib/tx'; -import { - type DeliveryPrivacyPreference, - deliveryPrivacyPreferenceFromNumber, -} from '../../hooks/get_delivery_privacy_preference.js'; import { BoundedVec } from '../noir-structs/bounded_vec.js'; import { EphemeralArray } from '../noir-structs/ephemeral_array.js'; import { EventValidationRequest } from '../noir-structs/event_validation_request.js'; @@ -50,6 +46,11 @@ import type { NoteData } from '../noir-structs/note_data.js'; import { NoteValidationRequest } from '../noir-structs/note_validation_request.js'; import { Option } from '../noir-structs/option.js'; import { ProvidedSecret } from '../noir-structs/provided_secret.js'; +import { + type TaggingSecretSource, + taggingSecretSourceFromFields, + taggingSecretSourceToFields, +} from '../noir-structs/tagging_secret_source.js'; import { UtilityContext } from '../noir-structs/utility_context.js'; import { MessageLoadOracleInputs } from './message_load_oracle_inputs.js'; import { packAsHintedNote } from './note_packing_utils.js'; @@ -155,11 +156,12 @@ export const DELIVERY_MODE: TypeMapping = { }, }; -export const DELIVERY_PRIVACY_PREFERENCE: TypeMapping = { - serialization: { fn: preference => [new Fr(preference)] }, +export const TAGGING_SECRET_SOURCE: TypeMapping = { + serialization: { fn: source => taggingSecretSourceToFields(source) }, deserialization: { - fn: readers => deliveryPrivacyPreferenceFromNumber(BYTE.deserialization!.fn(readers)), - slots: BYTE.deserialization!.slots, + fn: ([kindReader, secretReader]) => + taggingSecretSourceFromFields(kindReader.readField().toNumber(), secretReader.readField()), + slots: 2, }, }; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index 2c30b641c23e..7218a3aa1dc4 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -6,6 +6,7 @@ import { FunctionSelector } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; +import { SerializableContractInstance } from '@aztec/stdlib/contract'; import { Gas, GasFees, GasSettings } from '@aztec/stdlib/gas'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; @@ -15,10 +16,7 @@ import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; -import { - DeliveryPrivacyPreference, - type GetDeliveryPrivacyPreference, -} from '../../hooks/get_delivery_privacy_preference.js'; +import type { ResolveTaggingSecret, TaggingSecretSource } from '../../hooks/resolve_tagging_secret.js'; import type { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; import { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; @@ -32,6 +30,7 @@ import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagg import { ExecutionNoteCache } from '../execution_note_cache.js'; import { ExecutionTaggingIndexCache } from '../execution_tagging_index_cache.js'; import { HashedValuesCache } from '../hashed_values_cache.js'; +import { Option } from '../noir-structs/option.js'; import { TransientArrayService } from '../transient_array_service.js'; import { PrivateExecutionOracle, type PrivateExecutionOracleArgs } from './private_execution_oracle.js'; @@ -75,7 +74,7 @@ describe('PrivateExecutionOracle', () => { }); }); - describe('deliveryPrivacyPreference', () => { + describe('resolveTaggingSecret', () => { let sender: AztecAddress; let recipient: AztecAddress; @@ -84,28 +83,42 @@ describe('PrivateExecutionOracle', () => { recipient = await AztecAddress.random(); }); - it('defaults to max privacy when no hooks are configured', async () => { + it('defaults unconstrained delivery to an address-derived shared secret when no hooks are configured', async () => { const oracle = makeOracle(); + const secret = Fr.random(); + jest.spyOn(oracle, 'getAppTaggingSecret').mockResolvedValue(Option.some(secret)); - await expect( - oracle.getDeliveryPrivacyPreference(sender, recipient, AppTaggingSecretKind.CONSTRAINED), - ).resolves.toEqual(DeliveryPrivacyPreference.MAX_PRIVACY); + await expect(oracle.resolveTaggingSecret(sender, recipient, AppTaggingSecretKind.UNCONSTRAINED)).resolves.toEqual( + { type: 'shared-secret', secret }, + ); }); - it('returns the preference reported by the wallet hook', async () => { - const getDeliveryPrivacyPreference = jest - .fn() - .mockResolvedValue(DeliveryPrivacyPreference.BEST_EFFORT); - const oracle = makeOracle({ hooks: { getDeliveryPrivacyPreference } }); + it('fails constrained delivery when no hooks are configured', async () => { + const oracle = makeOracle(); + + await expect(oracle.resolveTaggingSecret(sender, recipient, AppTaggingSecretKind.CONSTRAINED)).rejects.toThrow( + /requires a configured resolveTaggingSecret hook/, + ); + }); + + it('returns the source reported by the wallet hook', async () => { + const source: TaggingSecretSource = { type: 'non-interactive-handshake' }; + const resolveTaggingSecret = jest.fn().mockResolvedValue(source); + const oracle = makeOracle({ hooks: { resolveTaggingSecret } }); + const contractClassId = Fr.random(); + jest + .spyOn(oracle, 'getContractInstance') + .mockResolvedValue(await SerializableContractInstance.random({ currentContractClassId: contractClassId })); - await expect( - oracle.getDeliveryPrivacyPreference(sender, recipient, AppTaggingSecretKind.UNCONSTRAINED), - ).resolves.toEqual(DeliveryPrivacyPreference.BEST_EFFORT); - expect(getDeliveryPrivacyPreference).toHaveBeenCalledWith({ + await expect(oracle.resolveTaggingSecret(sender, recipient, AppTaggingSecretKind.CONSTRAINED)).resolves.toEqual( + source, + ); + expect(resolveTaggingSecret).toHaveBeenCalledWith({ contractAddress, + contractClassId, sender, recipient, - deliveryMode: AppTaggingSecretKind.UNCONSTRAINED, + deliveryMode: AppTaggingSecretKind.CONSTRAINED, }); }); }); 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 aa8516c9b7f9..e594f97ca133 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 @@ -28,7 +28,7 @@ import { type TxContext, } from '@aztec/stdlib/tx'; -import { DeliveryPrivacyPreference } from '../../hooks/get_delivery_privacy_preference.js'; +import type { TaggingSecretSource } from '../../hooks/resolve_tagging_secret.js'; import { NoteService } from '../../notes/note_service.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { syncSenderTaggingIndexes } from '../../tagging/index.js'; @@ -188,28 +188,48 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP } /** - * Returns the wallet's delivery privacy preference, which message delivery consults when it must establish a new - * tagging secret with a recipient and the executing contract has not pinned a delivery mechanism (see - * {@link DeliveryPrivacyPreference} for the trade-offs involved). - * - * The value is sourced from the wallet's `getDeliveryPrivacyPreference` execution hook, which receives the executing - * contract plus the message's sender, recipient and delivery mode so it can answer per message instead of with a - * fixed policy. When no hook is configured this defaults to max privacy, so that privacy is never weakened without - * the wallet opting in. + * Resolves the {@link TaggingSecretSource} for a message via the wallet's `resolveTaggingSecret` hook. When no hook + * is configured, applies a privacy-safe default. */ - public getDeliveryPrivacyPreference( + public async resolveTaggingSecret( sender: AztecAddress, recipient: AztecAddress, deliveryMode: AppTaggingSecretKind, - ): Promise { - return ( - this.hooks?.getDeliveryPrivacyPreference?.({ + ): Promise { + const hook = this.hooks?.resolveTaggingSecret; + if (hook) { + const { currentContractClassId } = await this.getContractInstance(this.contractAddress); + return hook({ contractAddress: this.contractAddress, + contractClassId: currentContractClassId, sender, recipient, deliveryMode, - }) ?? Promise.resolve(DeliveryPrivacyPreference.MAX_PRIVACY) - ); + }); + } + return this.#defaultTaggingSecretSource(sender, recipient, deliveryMode); + } + + /** The privacy-safe tagging secret source used when no `resolveTaggingSecret` hook is configured. */ + async #defaultTaggingSecretSource( + sender: AztecAddress, + recipient: AztecAddress, + deliveryMode: AppTaggingSecretKind, + ): Promise { + if (deliveryMode === AppTaggingSecretKind.CONSTRAINED) { + // Constrained delivery has no "safe" default: a non-interactive handshake would reveal the recipient on-chain, + // and an interactive handshake always needs a wallet interaction (i.e. a configured hook). With no hook there is + // nothing safe to fall back to, so we always fail. + throw new Error(`Constrained delivery to ${recipient} requires a configured resolveTaggingSecret hook.`); + } + + // Unconstrained default: an address-derived (Diffie-Hellman) shared secret, which leaves no on-chain trace. Callers + // must validate the recipient in-circuit before reaching here, so an invalid one is unexpected. + const secret = await this.getAppTaggingSecret(sender, recipient); + if (!secret.isSome()) { + throw new Error(`Cannot derive a default tagging secret for invalid recipient ${recipient}`); + } + return { type: 'shared-secret', secret: secret.value }; } /** diff --git a/yarn-project/pxe/src/hooks/execution_hooks.ts b/yarn-project/pxe/src/hooks/execution_hooks.ts index b56ff0384771..4516e0fdf1ab 100644 --- a/yarn-project/pxe/src/hooks/execution_hooks.ts +++ b/yarn-project/pxe/src/hooks/execution_hooks.ts @@ -1,5 +1,5 @@ import type { AuthorizeUtilityCall } from './authorize_utility_call.js'; -import type { GetDeliveryPrivacyPreference } from './get_delivery_privacy_preference.js'; +import type { ResolveTaggingSecret } from './resolve_tagging_secret.js'; /** * Hooks that PXE invokes during client-side simulation to gate or steer operations that the protocol @@ -13,11 +13,6 @@ import type { GetDeliveryPrivacyPreference } from './get_delivery_privacy_prefer * time which contracts will be invoked during execution. Calls to standard contracts (such as the HandshakeRegistry) * bypass this hook and are always authorized. When the hook is absent, cross-contract utility calls are denied. * - * Similarly, {@link getDeliveryPrivacyPreference} is called when message delivery must establish a new tagging - * secret rather than reuse an existing handshake, and the contract has not pinned a tag-secret derivation, letting - * the wallet choose between maximum privacy and delivery that requires no sender-recipient coordination. An existing - * handshake is reused without invoking the hook. When the hook is absent, PXE assumes maximum privacy. - * * Note: hooks are unrelated to authentication witnesses (authwits). Authwits are an on-chain * mechanism where a contract verifies that a caller was authorized by a specific account; hooks * are a client-side PXE concern that gates execution before it proceeds. @@ -33,9 +28,8 @@ import type { GetDeliveryPrivacyPreference } from './get_delivery_privacy_prefer * ? { authorized: true } * : { authorized: false, reason: 'Unknown target' }; * }, - * // Accept the privacy leak of on-the-fly handshakes so messages reach recipients that haven't registered - * // the sender. - * getDeliveryPrivacyPreference: async () => DeliveryPrivacyPreference.BEST_EFFORT, + * // When there's no established way to reach the recipient, fall back to a non-interactive handshake. + * resolveTaggingSecret: async () => ({ type: 'non-interactive-handshake' }), * }, * }); * ``` @@ -44,10 +38,11 @@ export interface ExecutionHooks { /** Called when a contract attempts a cross-contract utility call. Calls are denied when absent. */ authorizeUtilityCall?: AuthorizeUtilityCall; /** - * Called when message delivery must establish a new tagging secret rather than reuse an existing handshake, and - * the contract has not pinned a tag-secret derivation. Maximum privacy is assumed when absent. + * Resolves a message's tagging secret when none is already established for the sender/recipient pair, letting the + * wallet apply per-recipient policy. PXE applies a privacy-safe default when absent. + * See {@link ResolveTaggingSecret} for the request shape and defaults. */ - getDeliveryPrivacyPreference?: GetDeliveryPrivacyPreference; + resolveTaggingSecret?: ResolveTaggingSecret; } /** diff --git a/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts b/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts deleted file mode 100644 index 6aeabaacdb18..000000000000 --- a/yarn-project/pxe/src/hooks/get_delivery_privacy_preference.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; - -/** - * A wallet-owned policy that selects the tag-secret derivation used by message delivery when the executing contract - * does not pin one. Max privacy never leaks information and instead relies on sender-recipient coordination for - * delivery. Best effort accepts a privacy leak so that delivery requires no sender-recipient coordination at all. - * - * The send flow consults this value only when it must establish a new tagging secret; an existing handshake is reused as-is. - */ -export enum DeliveryPrivacyPreference { - /** - * Never publish anything that could link sender and recipient in order to obtain a tagging secret; delivery relies - * on sender-recipient coordination instead. Unconstrained delivery uses the locally-derived address-pair (ECDH) - * secret, but the recipient only finds the message if they registered the sender; constrained delivery requires an - * interactive handshake with the recipient, and fails when none exists. - */ - MAX_PRIVACY = 1, - /** - * Derive tags from a non-interactive handshake, reusing an existing one or establishing it on the fly, letting the - * recipient discover the message without any prior sender-recipient coordination. Establishing it publishes an - * on-chain handshake that reveals information about the recipient. - */ - BEST_EFFORT = 2, -} - -/** Checks whether `value` is a known {@link DeliveryPrivacyPreference} discriminant. */ -function isDeliveryPrivacyPreference(value: number): value is DeliveryPrivacyPreference { - return value in DeliveryPrivacyPreference; -} - -/** Validates that `value` is a known {@link DeliveryPrivacyPreference} discriminant and narrows it to the enum. */ -export function deliveryPrivacyPreferenceFromNumber(value: number): DeliveryPrivacyPreference { - if (!isDeliveryPrivacyPreference(value)) { - throw new Error(`Unrecognized delivery privacy preference: ${value}`); - } - return value; -} - -/** Information about the message delivery requesting the preference. */ -export type DeliveryPrivacyPreferenceRequest = { - /** The contract whose execution is sending the message. */ - contractAddress: AztecAddress; - /** The sender of the message, i.e. the local side of the tagging secret that would be established. */ - sender: AztecAddress; - /** The recipient of the message, i.e. the party an on-chain handshake would reveal information about. */ - recipient: AztecAddress; - /** Whether the message is delivered with constrained or unconstrained tagging. */ - deliveryMode: AppTaggingSecretKind; -}; - -/** - * Hook called when message delivery must establish a new tagging secret rather than reuse an existing handshake, and - * the executing contract has not pinned a tag-secret derivation, letting the wallet choose between maximum privacy - * and delivery that requires no sender-recipient coordination (see {@link DeliveryPrivacyPreference} for the - * trade-offs involved). An existing handshake is reused without invoking the hook. - * - * The request identifies the message (executing contract, sender, recipient and delivery mode), so wallets can apply - * per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. - * - * When the hook is not configured, PXE defaults to {@link DeliveryPrivacyPreference.MAX_PRIVACY} so that privacy is - * never weakened without the wallet opting in. - */ -export type GetDeliveryPrivacyPreference = ( - request: DeliveryPrivacyPreferenceRequest, -) => Promise; diff --git a/yarn-project/pxe/src/hooks/index.ts b/yarn-project/pxe/src/hooks/index.ts index 351677e80089..75a37541e830 100644 --- a/yarn-project/pxe/src/hooks/index.ts +++ b/yarn-project/pxe/src/hooks/index.ts @@ -5,8 +5,7 @@ export type { } from './authorize_utility_call.js'; export { type ExecutionHooks, composeHooks } from './execution_hooks.js'; export { - DeliveryPrivacyPreference, - type DeliveryPrivacyPreferenceRequest, - type GetDeliveryPrivacyPreference, - deliveryPrivacyPreferenceFromNumber, -} from './get_delivery_privacy_preference.js'; + type ResolveTaggingSecret, + type TaggingSecretSource, + type TaggingSecretSourceRequest, +} from './resolve_tagging_secret.js'; diff --git a/yarn-project/pxe/src/hooks/resolve_tagging_secret.ts b/yarn-project/pxe/src/hooks/resolve_tagging_secret.ts new file mode 100644 index 000000000000..579476c36cf1 --- /dev/null +++ b/yarn-project/pxe/src/hooks/resolve_tagging_secret.ts @@ -0,0 +1,26 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +import type { TaggingSecretSource } from '../contract_function_simulator/noir-structs/tagging_secret_source.js'; + +export type { TaggingSecretSource }; + +/** Information about the message delivery requesting a tagging secret source. */ +export type TaggingSecretSourceRequest = { + contractAddress: AztecAddress; + /** + * The contract class ID of the executing contract, so wallets can apply class-level (not just per-address) policy. + */ + contractClassId: Fr; + sender: AztecAddress; + recipient: AztecAddress; + deliveryMode: AppTaggingSecretKind; +}; + +/** + * Hook returning the {@link TaggingSecretSource} for an outgoing message. Lets a wallet apply per-application or + * per-recipient policy; when absent, PXE applies a privacy-safe default. See {@link TaggingSecretSource} for the + * variants and trade-offs. + */ +export type ResolveTaggingSecret = (request: TaggingSecretSourceRequest) => Promise; diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index d78f32d7c745..f8558f7debc8 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -2,7 +2,8 @@ import { CompleteAddress } from '@aztec/aztec.js/addresses'; import { TxHash } from '@aztec/aztec.js/tx'; import { BlockNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; -import type { DeliveryPrivacyPreference } from '@aztec/pxe/server'; +import type { TaggingSecretSource } from '@aztec/pxe/server'; +import type { Option } from '@aztec/pxe/simulator'; import type { EventSelector, FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { GasSettings } from '@aztec/stdlib/gas'; @@ -70,7 +71,7 @@ export interface ITxeExecutionOracle { createAccount(secret: Fr): Promise; addAccount(secret: Fr): Promise; addAuthWitness(address: AztecAddress, messageHash: Fr): Promise; - setDeliveryPrivacyPreference(preference: DeliveryPrivacyPreference): void; + setTaggingSecretSource(source: Option): void; getLastBlockTimestamp(): Promise; getLastTxEffects(): Promise<{ txHash: TxHash; diff --git a/yarn-project/txe/src/oracle/txe_oracle_registry.ts b/yarn-project/txe/src/oracle/txe_oracle_registry.ts index b8bb8573c2ff..4a11e8645a31 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_registry.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_registry.ts @@ -14,7 +14,6 @@ import { BIGINT, BLOCK_NUMBER, BOOL, - DELIVERY_PRIVACY_PREFERENCE, FIELD, FUNCTION_SELECTOR, type InputSlot, @@ -24,6 +23,7 @@ import { type OracleRegistryEntry, type ParamTypes, STR, + TAGGING_SECRET_SOURCE, type TypeMapping, U32, makeEntry, @@ -220,8 +220,8 @@ export const TXE_ORACLE_REGISTRY: Record = { ], }), - aztec_txe_setDeliveryPrivacyPreference: makeEntry({ - params: [{ name: 'preference', type: DELIVERY_PRIVACY_PREFERENCE }], + aztec_txe_setTaggingSecretSource: makeEntry({ + params: [{ name: 'source', type: OPTION(TAGGING_SECRET_SOURCE) }], }), aztec_txe_getLastBlockTimestamp: makeEntry({ 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 95c05c63ed43..89ff81c69ff0 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 @@ -14,7 +14,6 @@ import { CapsuleService, CapsuleStore, type ContractStore, - type DeliveryPrivacyPreference, type ExecutionHooks, NoteStore, ORACLE_VERSION_MAJOR, @@ -22,6 +21,7 @@ import { RecipientTaggingStore, SenderAddressBookStore, SenderTaggingStore, + type TaggingSecretSource, composeHooks, enrichPublicSimulationError, } from '@aztec/pxe/server'; @@ -30,6 +30,7 @@ import { ExecutionTaggingIndexCache, HashedValuesCache, type IMiscOracle, + type Option, PrivateExecutionOracle, TransientArrayService, UtilityExecutionOracle, @@ -114,7 +115,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl private version: Fr, private chainId: Fr, private authwits: Map, - private deliveryPrivacyPreference: DeliveryPrivacyPreference, + private taggingSecretSource: TaggingSecretSource | undefined, private readonly artifactResolver: TXEArtifactResolver, private readonly rootPath: string, private readonly packageName: string, @@ -354,8 +355,8 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl this.authwits.set(authWitness.requestHash.toString(), authWitness); } - setDeliveryPrivacyPreference(preference: DeliveryPrivacyPreference): void { - this.deliveryPrivacyPreference = preference; + setTaggingSecretSource(source: Option): void { + this.taggingSecretSource = source.value; } async mineBlock(options: { nullifiers?: Fr[] } = {}) { @@ -443,6 +444,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl const simulator = new WASMSimulator(); const transientArrayService = new TransientArrayService(); + const taggingSecretSource = this.taggingSecretSource; const privateExecutionOracle = new PrivateExecutionOracle({ argsHash, txContext, @@ -480,7 +482,8 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl isStaticCall ? 'private view' : 'private', authorizedUtilityCallTargets, ), - getDeliveryPrivacyPreference: () => Promise.resolve(this.deliveryPrivacyPreference), + // Only configure the hook when a source was explicitly set, so that otherwise the default tagging secret source is exercised. + resolveTaggingSecret: taggingSecretSource ? () => Promise.resolve(taggingSecretSource) : undefined, }), transientArrayService, }); @@ -885,9 +888,9 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } } - close(): [bigint, Map, DeliveryPrivacyPreference] { + close(): [bigint, Map, TaggingSecretSource | undefined] { this.logger.debug('Exiting Top Level Context'); - return [this.nextBlockTimestamp, this.authwits, this.deliveryPrivacyPreference]; + return [this.nextBlockTimestamp, this.authwits, this.taggingSecretSource]; } private async getLastBlockNumber(): Promise { diff --git a/yarn-project/txe/src/rpc_translator.ts b/yarn-project/txe/src/rpc_translator.ts index 35a36f9e6dd5..6525184ab97b 100644 --- a/yarn-project/txe/src/rpc_translator.ts +++ b/yarn-project/txe/src/rpc_translator.ts @@ -211,11 +211,11 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - aztec_txe_setDeliveryPrivacyPreference(...inputs: ForeignCallArgs) { + aztec_txe_setTaggingSecretSource(...inputs: ForeignCallArgs) { return callTxeHandler({ - oracle: 'aztec_txe_setDeliveryPrivacyPreference', + oracle: 'aztec_txe_setTaggingSecretSource', inputs, - handler: ([preference]) => this.handlerAsTxe().setDeliveryPrivacyPreference(preference), + handler: ([source]) => this.handlerAsTxe().setTaggingSecretSource(source), }); } @@ -1083,12 +1083,12 @@ export class RPCTranslator { } // eslint-disable-next-line camelcase - aztec_prv_getDeliveryPrivacyPreference(...inputs: ForeignCallArgs) { + aztec_prv_resolveTaggingSecret(...inputs: ForeignCallArgs) { return callTxeHandler({ - oracle: 'aztec_prv_getDeliveryPrivacyPreference', + oracle: 'aztec_prv_resolveTaggingSecret', inputs, handler: ([sender, recipient, deliveryMode]) => - this.handlerAsPrivate().getDeliveryPrivacyPreference(sender, recipient, deliveryMode), + this.handlerAsPrivate().resolveTaggingSecret(sender, recipient, deliveryMode), }); } diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 00b283c93572..ce5fe70540b2 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -10,7 +10,6 @@ import { CapsuleService, CapsuleStore, ContractStore, - DeliveryPrivacyPreference, JobCoordinator, NoteService, NoteStore, @@ -18,6 +17,7 @@ import { RecipientTaggingStore, SenderAddressBookStore, SenderTaggingStore, + type TaggingSecretSource, composeHooks, } from '@aztec/pxe/server'; import { @@ -231,7 +231,7 @@ function emptyLastCallState(): LastCallState { export class TXESession implements TXESessionStateHandler { private state: SessionState = { name: 'TOP_LEVEL' }; private authwits: Map = new Map(); - private deliveryPrivacyPreference: DeliveryPrivacyPreference = DeliveryPrivacyPreference.MAX_PRIVACY; + private taggingSecretSource: TaggingSecretSource | undefined = undefined; private lastCallInfo: LastCallState = emptyLastCallState(); private txeOracleVersion: { major: number; minor: number } | undefined; @@ -347,7 +347,7 @@ export class TXESession implements TXESessionStateHandler { version, chainId, new Map(), - DeliveryPrivacyPreference.MAX_PRIVACY, + undefined, artifactResolver, rootPath, packageName, @@ -661,7 +661,7 @@ export class TXESession implements TXESessionStateHandler { this.version, this.chainId, this.authwits, - this.deliveryPrivacyPreference, + this.taggingSecretSource, this.artifactResolver, this.rootPath, this.packageName, @@ -706,6 +706,7 @@ export class TXESession implements TXESessionStateHandler { const utilityExecutor = this.utilityExecutorForContractSync(anchorBlock); const transientArrayService = new TransientArrayService(); + const taggingSecretSource = this.taggingSecretSource; this.oracleHandler = new TXEPrivateExecutionOracle({ argsHash: Fr.ZERO, txContext: new TxContext(this.chainId, this.version, gasSettings), @@ -734,7 +735,7 @@ export class TXESession implements TXESessionStateHandler { messageContextService: this.stateMachine.messageContextService, simulator: new WASMSimulator(), hooks: composeHooks({ - getDeliveryPrivacyPreference: () => Promise.resolve(this.deliveryPrivacyPreference), + resolveTaggingSecret: taggingSecretSource ? () => Promise.resolve(taggingSecretSource) : undefined, }), transientArrayService, }); @@ -845,13 +846,13 @@ export class TXESession implements TXESessionStateHandler { // and `deploy`), creates blocks with transactions (via `privateCallNewFlow` and `publicCallNewFlow`), adds // accounts to PXE (via `addAccount`), etc. This is a slight inconsistency in the working model of this class, but // is not too bad. The `close` call below therefore only hands back the session-scoped values that top-level - // cheatcodes may have mutated (next block timestamp, authwits, delivery privacy preference). The oracle handler is + // cheatcodes may have mutated (next block timestamp, authwits, tagging secret source). The oracle handler is // discarded on every state transition, so the session must seed these values into the contexts it creates later. // TODO: persisting authwits this way is quite unfortunate: they create a temporary utility context that would // otherwise reset them, so we'd not be able to pass more than one per execution. Ideally authwits would be passed // alongside a contract call instead of pre-seeded. - [this.nextBlockTimestamp, this.authwits, this.deliveryPrivacyPreference] = ( + [this.nextBlockTimestamp, this.authwits, this.taggingSecretSource] = ( this.oracleHandler as TXEOracleTopLevelContext ).close(); } diff --git a/yarn-project/wallets/src/embedded/embedded_wallet.ts b/yarn-project/wallets/src/embedded/embedded_wallet.ts index fad41a837786..79f9eb32db24 100644 --- a/yarn-project/wallets/src/embedded/embedded_wallet.ts +++ b/yarn-project/wallets/src/embedded/embedded_wallet.ts @@ -25,7 +25,6 @@ import { Fq, Fr } from '@aztec/foundation/curves/bn254'; import type { Logger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import type { PXEConfig, PXECreationOptions } from '@aztec/pxe/client/lazy'; -import { DeliveryPrivacyPreference, type ExecutionHooks } from '@aztec/pxe/config'; import type { PXE } from '@aztec/pxe/server'; import type { ContractArtifact, EventMetadataDefinition, FunctionCall } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -49,13 +48,7 @@ import { BaseWallet, type SimulateViaEntrypointOptions, getGasLimits } from '@az import type { AccountContractsProvider } from './account-contract-providers/types.js'; import { type AccountType, WalletDB } from './wallet_db.js'; -/** - * Options for the PXE instance created by the EmbeddedWallet. - * - * Unless overridden via `hooks.getDeliveryPrivacyPreference`, the embedded wallet reports a best-effort delivery - * privacy preference (a bare PXE defaults to max privacy), trading the privacy cost of on-chain handshakes for - * message delivery that works without sender and recipient having to coordinate. - */ +/** Options for the PXE instance created by the EmbeddedWallet. */ export type EmbeddedWalletPXEOptions = Partial & PXECreationOptions; /** Splits a unified EmbeddedWalletPXEOptions into PXEConfig overrides and PXECreationOptions. */ @@ -74,17 +67,6 @@ export function splitPxeOptions(pxe?: EmbeddedWalletPXEOptions): { }; } -/** - * Applies the embedded wallet's default execution hooks on top of user-provided ones, defaulting the delivery - * privacy preference to best effort (see {@link EmbeddedWalletPXEOptions} for why). - */ -export function applyEmbeddedWalletHookDefaults(hooks: ExecutionHooks | undefined): ExecutionHooks { - return { - getDeliveryPrivacyPreference: () => Promise.resolve(DeliveryPrivacyPreference.BEST_EFFORT), - ...hooks, - }; -} - /** Options for the EmbeddedWallet's own DB (accounts, senders — distinct from PXE state). */ export type EmbeddedWalletDBOptions = { /** Override the wallet DB backend. If omitted, an IndexedDB (browser) / LMDB (node) store is created. */ diff --git a/yarn-project/wallets/src/embedded/entrypoints/browser.ts b/yarn-project/wallets/src/embedded/entrypoints/browser.ts index 268a649ba210..aeca41194a67 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/browser.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/browser.ts @@ -9,12 +9,7 @@ import { getStandardMultiCallEntrypoint } from '@aztec/standard-contracts/multi- import { LazyAccountContractsProvider } from '../account-contract-providers/lazy.js'; import type { AccountContractsProvider } from '../account-contract-providers/types.js'; -import { - EmbeddedWallet, - type EmbeddedWalletOptions, - applyEmbeddedWalletHookDefaults, - splitPxeOptions, -} from '../embedded_wallet.js'; +import { EmbeddedWallet, type EmbeddedWalletOptions, splitPxeOptions } from '../embedded_wallet.js'; import { WalletDB } from '../wallet_db.js'; export class BrowserEmbeddedWallet extends EmbeddedWallet { @@ -59,7 +54,6 @@ export class BrowserEmbeddedWallet extends EmbeddedWallet { await getStandardHandshakeRegistry(), ], }, - hooks: applyEmbeddedWalletHookDefaults(mergedCreationOverrides.hooks), loggers: { store: rootLogger.createChild('pxe:data'), pxe: rootLogger.createChild('pxe:service'), diff --git a/yarn-project/wallets/src/embedded/entrypoints/node.ts b/yarn-project/wallets/src/embedded/entrypoints/node.ts index 22a32ad85ca8..38eed84e259a 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/node.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/node.ts @@ -10,12 +10,7 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { BundleAccountContractsProvider } from '../account-contract-providers/bundle.js'; import type { AccountContractsProvider } from '../account-contract-providers/types.js'; -import { - EmbeddedWallet, - type EmbeddedWalletOptions, - applyEmbeddedWalletHookDefaults, - splitPxeOptions, -} from '../embedded_wallet.js'; +import { EmbeddedWallet, type EmbeddedWalletOptions, splitPxeOptions } from '../embedded_wallet.js'; import { WalletDB } from '../wallet_db.js'; export class NodeEmbeddedWallet extends EmbeddedWallet { @@ -60,7 +55,6 @@ export class NodeEmbeddedWallet extends EmbeddedWallet { await getStandardHandshakeRegistry(), ], }, - hooks: applyEmbeddedWalletHookDefaults(mergedCreationOverrides.hooks), loggers: { store: rootLogger.createChild('pxe:data'), pxe: rootLogger.createChild('pxe:service'), From f04873c82b60fc4ada44d537e9d7b321e5f38e68 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 16:01:15 -0300 Subject: [PATCH 07/14] test(aztec-nr): wire oracle tests for resolve_tagging_secret --- noir-projects/aztec-nr/aztec/src/oracle/mod.nr | 5 +++++ noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr | 1 + 2 files changed, 6 insertions(+) diff --git a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr index 7bdd1e97268a..b2d9c53dc2ad 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/mod.nr @@ -43,6 +43,11 @@ pub(crate) mod transient_oracles; pub mod contract_sync; #[generate_oracle_tests] pub mod public_call; +#[generate_oracle_tests_excluding( + @[ + quote { resolve_tagging_secret_oracle }, // TODO: implement once we support more complex types + ], +)] pub mod resolve_tagging_secret; #[generate_oracle_tests] pub mod tx_phase; diff --git a/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr b/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr index 2bb6a5abcd98..e00e8a66c1f2 100644 --- a/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr +++ b/noir-projects/aztec-nr/aztec/src/test/helpers/mod.nr @@ -13,6 +13,7 @@ pub mod test_environment; quote { private_call_new_flow_oracle }, // TODO: implement once we support more complex types quote { public_call_new_flow_oracle }, // TODO: implement once we support more complex types quote { set_private_txe_context_oracle }, // TODO: implement once we support more complex types + quote { set_tagging_secret_source }, // TODO: implement once we support more complex types ], )] pub mod txe_oracles; From d550918fac40c33b4959f8e4f6a7fb5f5c027a16 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 16:15:29 -0300 Subject: [PATCH 08/14] fix(pxe): refresh oracle interface hash after merge --- yarn-project/pxe/src/oracle_version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/pxe/src/oracle_version.ts b/yarn-project/pxe/src/oracle_version.ts index 9f6fc74d226f..1c0eb5ed1337 100644 --- a/yarn-project/pxe/src/oracle_version.ts +++ b/yarn-project/pxe/src/oracle_version.ts @@ -19,4 +19,4 @@ export const ORACLE_VERSION_MINOR = 2; /// - increment only `ORACLE_VERSION_MINOR` if the change is additive (a new oracle was added). /// /// These constants must be kept in sync between this file and `noir-projects/aztec-nr/aztec/src/oracle/version.nr`. -export const ORACLE_INTERFACE_HASH = 'bd605eeb3034e2bdb88255effc2d4237e3bf7ecdf3678e9edf021a736f4f127f'; +export const ORACLE_INTERFACE_HASH = '9174c3095f83f2fb6d852c538ee2e556eb2304687517f0a95364f166d0bfcbed'; From 5fc1e29e0d7584d83df2cfe5528be2188d7527d8 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 16:23:19 -0300 Subject: [PATCH 09/14] fix(txe): refresh TXE oracle interface hash after merge --- yarn-project/txe/src/oracle/txe_oracle_version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index 0df77c2b7a9c..be6c4b418389 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -14,4 +14,4 @@ export const TXE_ORACLE_VERSION_MINOR = 3; * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '10681b22123e70ebc0239a4ee1a1606dad624a2e9cc7f06e981c4d9f58b8e06a'; +export const TXE_ORACLE_INTERFACE_HASH = '771fe20f95190c534f0a11f3efb9214ffcb4bc1cea14ea0c62fb60526e073d87'; From a9d6a84e8c97da0adeab05afdea0d284aca5de58 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 16:34:52 -0300 Subject: [PATCH 10/14] docs: use onchain to satisfy cspell forbidden word --- .../docs/foundational-topics/pxe/execution_hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 5d5363f253c3..89074e7d314e 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -92,4 +92,4 @@ let env = TestEnvironment::new_opts( Pass a `resolveTaggingSecret` hook when [creating the PXE](#configuring-hooks). It receives a `TaggingSecretSourceRequest` with the executing contract's address and the message's sender, recipient, and delivery mode (`'constrained'` or `'unconstrained'`), so a wallet can apply per-application or per-recipient policies, or surface the decision to the user, instead of returning a fixed value. -When the hook is absent, the PXE applies a privacy-safe default: unconstrained delivery uses an address-derived (Diffie-Hellman) shared secret, which leaves no on-chain trace, while constrained delivery fails rather than silently revealing the recipient through a non-interactive handshake. +When the hook is absent, the PXE applies a privacy-safe default: unconstrained delivery uses an address-derived (Diffie-Hellman) shared secret, which leaves no onchain trace, while constrained delivery fails rather than silently revealing the recipient through a non-interactive handshake. From 19ba78d98f5877f6de92c97fbc3a539767f1b06d Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 18:53:45 -0300 Subject: [PATCH 11/14] fix(txe): refresh TXE oracle interface hash after merge --- yarn-project/txe/src/oracle/txe_oracle_version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/txe/src/oracle/txe_oracle_version.ts b/yarn-project/txe/src/oracle/txe_oracle_version.ts index c9b5684b8f14..12409a4aeb9a 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_version.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_version.ts @@ -14,4 +14,4 @@ export const TXE_ORACLE_VERSION_MINOR = 1; * - TXE_ORACLE_VERSION_MAJOR (and reset MINOR to 0) for breaking changes, or * - TXE_ORACLE_VERSION_MINOR for additive changes (new oracle method added). */ -export const TXE_ORACLE_INTERFACE_HASH = '408fdcb7d24ff0570366b3bc1514678b48890cd70f2621e4cee12dc3153e3923'; +export const TXE_ORACLE_INTERFACE_HASH = '2546a11d0ca93a0f10b604beec8a30f5a04523ed4a75f6a2bf67481b6aea75e2'; From 3e5e02166d7c2e0fbc01940ddeb3b8312c5876db Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Fri, 19 Jun 2026 19:20:08 -0300 Subject: [PATCH 12/14] docs(aztec-nr): clarify tagging secret handshake reuse --- .../framework-description/note_delivery.md | 2 +- .../pxe/execution_hooks.md | 4 ++-- .../delivery/tagging_secret_source.nr | 22 +++++++++++-------- .../src/oracle/resolve_tagging_secret.nr | 5 +++-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index ea0e0448e5f0..f004a70b376f 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -163,7 +163,7 @@ Ask yourself: **"Is the sender incentivized to deliver this note correctly?"** ## Tagging secret source -Onchain delivery tags every message so the recipient can find it efficiently (see [note discovery](#note-discovery-and-the-sender) below). Computing a tag requires a secret shared between sender and recipient, and there is more than one way for the two parties to come to share it. When one has already been established for the pair, it is reused directly. Otherwise the wallet decides how to proceed, since it knows which secrets it holds and how it wants to reach the recipient. +Onchain delivery tags every message so the recipient can find it efficiently (see [note discovery](#note-discovery-and-the-sender) below). Computing a tag requires a secret shared between sender and recipient, and there is more than one way for the two parties to come to share it. When an onchain handshake has been registered for the pair, the secret derived from it is reused directly. Otherwise the wallet decides how to proceed, since it knows which secrets it holds and how it wants to reach the recipient. The wallet's answer is a concrete **tagging secret source**. There are two sources today: diff --git a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md index 89074e7d314e..2d5e65af93ac 100644 --- a/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md +++ b/docs/docs-developers/docs/foundational-topics/pxe/execution_hooks.md @@ -22,7 +22,7 @@ const pxe = await createPXE(node, config, { ? { authorized: true } : { authorized: false, reason: "Unknown target" }; }, - // When there's no established way to reach the recipient, fall back to a non-interactive handshake. + // When no onchain handshake is registered for the recipient, fall back to a non-interactive handshake. resolveTaggingSecret: async () => ({ type: "non-interactive-handshake" }), }, }); @@ -76,7 +76,7 @@ When the hook is absent, cross-contract utility calls are denied. See [Cross-con ## `resolveTaggingSecret` -Called as a fallback when message delivery has no established tagging secret to reuse for a sender-recipient pair: an established secret is reused without invoking the hook, so it only fires when none exists yet. The wallet returns a concrete `TaggingSecretSource` (and any material the chosen derivation needs); see [Tagging secret source](../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source) for the variants, the trade-offs, and the defaults in each environment. +Called as a fallback for message delivery: a registered onchain handshake's secret is reused directly, so this hook only fires when the sender-recipient pair has none yet. The wallet returns a concrete `TaggingSecretSource` (and any material the chosen derivation needs); see [Tagging secret source](../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source) for the variants, the trade-offs, and the defaults in each environment. ### In Noir tests diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr index bef158d5b08b..391a9048f033 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tagging_secret_source.nr @@ -3,13 +3,15 @@ use crate::protocol::{traits::{Deserialize, Serialize}, utils::reader::Reader}; global NON_INTERACTIVE_HANDSHAKE: u8 = 1; global SHARED_SECRET: u8 = 2; -/// How the tagging secret for a message is sourced, as decided by the wallet through the +/// How a message's tagging secret is sourced. +/// +/// The wallet makes this choice through the /// [`resolve_tagging_secret`](crate::oracle::resolve_tagging_secret) oracle. /// /// Message delivery tags each on-chain message so the recipient can find it without scanning every log. The tag is -/// derived from a secret shared between sender and recipient. When one has already been established for the pair it is -/// reused. Otherwise, the wallet must decide how to source it: this type is that decision, carrying any material the -/// chosen derivation needs. +/// derived from a secret shared between sender and recipient. When an on-chain handshake has been registered for the +/// pair the secret derived from it is reused. Otherwise, the wallet must decide how to source it: this type is that +/// decision, carrying any material the chosen derivation needs. #[derive(Eq, Serialize)] pub struct TaggingSecretSource { kind: u8, @@ -17,15 +19,17 @@ pub struct TaggingSecretSource { } impl TaggingSecretSource { - /// A secret established through a non-interactive handshake, derivable by the recipient from the on-chain handshake - /// registry. + /// A secret established through a non-interactive handshake. + /// + /// The recipient can derive it from the on-chain handshake registry. pub fn non_interactive_handshake() -> Self { Self { kind: NON_INTERACTIVE_HANDSHAKE, secret: 0 } } - /// A secret the two parties already share off-chain: they must have coordinated somehow to both know it (e.g. - /// derived via Diffie-Hellman from each other's address keys). Because nothing on-chain proves the recipient knows - /// it, this is only sound for unconstrained delivery. + /// A secret the two parties already share off-chain. + /// + /// They must have coordinated somehow to both know it (e.g. derived via Diffie-Hellman from each other's address + /// keys). Because nothing on-chain proves the recipient knows it, this is only sound for unconstrained delivery. pub fn shared_secret(secret: Field) -> Self { Self { kind: SHARED_SECRET, secret } } diff --git a/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr b/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr index 6ba9f5f99b27..a4a597d513e7 100644 --- a/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr +++ b/noir-projects/aztec-nr/aztec/src/oracle/resolve_tagging_secret.nr @@ -4,8 +4,9 @@ use crate::protocol::address::AztecAddress; /// Sources the tagging secret for a message to `recipient`. /// -/// Consulted only when no tagging secret has been established for the `(sender, recipient)` pair yet; an established -/// secret is reused without asking the wallet. See [`TaggingSecretSource`] for the variants and their trade-offs. +/// Consulted only when no on-chain handshake has been registered for the `(sender, recipient)` pair yet; the secret +/// from a registered handshake is reused without asking the wallet. See [`TaggingSecretSource`] for the variants and +/// their trade-offs. /// /// An invalid recipient has no shared secret to derive. Unconstrained delivery returns a random source (an /// undiscoverable tag) rather than letting a malformed recipient abort the send; constrained delivery instead fails, From f00d8ebf0bae26ba4f42f949a645475d118c2e71 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Sat, 20 Jun 2026 18:34:18 -0300 Subject: [PATCH 13/14] docs: clarify tagging secret source in note delivery/discovery --- .../framework-description/note_delivery.md | 19 ++---------- .../advanced/storage/note_discovery.md | 29 +++++++++---------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md index f004a70b376f..554df899dba8 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/note_delivery.md @@ -195,28 +195,15 @@ When a note is delivered, recipients need to discover it among all the encrypted The "sender" for note discovery is **not the contract calling `.deliver()`**. Instead, it's the **account contract** that initiated the transaction. -When your wallet submits a transaction, it tells PXE which address to use as the sender for tags (typically the originating account). This sender address is then used along with the recipient address to compute a shared secret (via [Diffie-Hellman key exchange](https://www.geeksforgeeks.org/computer-networks/diffie-hellman-key-exchange-and-perfect-forward-secrecy/)), which generates the tag that allows recipients to efficiently find their notes. Contracts can override the sender at message delivery via the `with_sender` builder method, e.g. `MessageDelivery::onchain_unconstrained().with_sender(address)`. +When your wallet submits a transaction, it tells PXE which address to use as the sender for tags (typically the originating account). The tag recipients use to find their notes is computed from a secret shared between the sender and recipient, and there is [more than one way to establish that secret](#tagging-secret-source), chosen by the wallet. Contracts can override the sender at message delivery via the `with_sender` builder method, which works for both constrained and unconstrained delivery, e.g. `MessageDelivery::onchain_constrained().with_sender(address)`. **Example:** If Alice uses her account contract to call a token contract that mints tokens to Bob, the "sender for tags" is Alice's account contract address, not the token contract address. ### Discovering Notes from Unknown Senders -**You cannot receive notes from an unknown sender** without additional mechanisms. The tagging system requires you to know the sender's address in advance to compute the shared secret needed to find the note (i.e., the sender needs to be added to your wallet). +When the tag is derived from an address-based shared secret, you cannot compute it for a sender you haven't registered in advance, so you cannot receive those notes from an unknown sender. Handshake protocols let the two parties agree on the secret another way and lift this restriction. -There are three approaches to solve this: - -**a) Brute force search** - Download every log and attempt to decrypt it. This becomes prohibitively expensive as the network grows. - -**b) Known sender tagging** (current implementation) - Only receive notes from senders whose addresses you've registered in your PXE. This is very fast and allows you to block spammers by removing them from your sender list. However, you must know who might send you notes in advance. - -**c) Handshaking protocols** (not yet implemented) - A two-phase approach where senders first perform a "handshake" that notifies you of their existence, then use regular tagging afterward. This trades off either privacy (public handshake events) or performance (scanning all handshake logs). - -**Workarounds for receiving notes from unknown senders:** -- Require senders to register in a contract first, then search for notes from all registered senders -- Share sender addresses through offchain communication -- Implement a custom discovery mechanism in your contract - -See the [Note Discovery](../../foundational-topics/advanced/storage/note_discovery.md) documentation for technical details on the tagging mechanism. +See [You cannot receive address-secret tagged notes from an unknown sender](../../foundational-topics/advanced/storage/note_discovery.md#you-cannot-receive-address-secret-tagged-notes-from-an-unknown-sender) in the note discovery documentation for the approaches and workarounds. ## Delivering to Someone Other Than the Note Owner diff --git a/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md b/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md index 519919c626e8..633dd09b8b0d 100644 --- a/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md +++ b/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md @@ -23,7 +23,13 @@ In Aztec, each emitted log is an array of fields, e.g. `[tag, x, y, z]`. The fir #### Tag derivation -The sender and recipient share a predictable scheme for generating tags. Tags are derived through a layered hashing process that makes them specific to a particular sender-recipient pair, contract, and sequence number. +Every tag is derived the same way: `poseidon2(secret, index)`. + +What varies is how the sender and recipient come to share `secret`. This is the [tagging secret source](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source), chosen by the wallet. + +##### Address-derived secret + +When the secret is derived from addresses, the sender and recipient compute a value specific to their pair and the contract through a layered hashing process: ```mermaid flowchart LR @@ -53,9 +59,9 @@ When the log is emitted, the protocol kernel **siloes** the tag with the contrac #### The sender in note tagging -The "sender" in note tagging is **not necessarily the transaction sender**. It's the **sender for tags**, which the wallet supplies as a default (typically the originating account address). Contracts can override this at message delivery by using `MessageDelivery::onchain_unconstrained().with_sender(address)`. +The "sender" in note tagging is **not necessarily the transaction sender**. It's the **sender for tags**, which the wallet supplies as a default (typically the originating account address). Contracts can override this at message delivery by using `with_sender`, for both constrained and unconstrained delivery, e.g. `MessageDelivery::onchain_constrained().with_sender(address)`. -This sender address is used along with the recipient address to compute the shared secret via Diffie-Hellman key exchange, which is then used to derive the tag. +This sender address, along with the recipient address, determines which tagging secret is used to derive the tag. #### Registering known senders @@ -96,26 +102,17 @@ This means there's a practical limit on how many logs a single sender can emit t ### Limitations and solutions -#### You cannot receive tagged notes from an unknown sender +#### You cannot receive address-secret tagged notes from an unknown sender -Without knowing the sender's address, you cannot create the shared secret needed to derive the note tag. This is a fundamental limitation of the current tagging scheme. +When the tag's secret is [address-derived](#address-derived-secret), you cannot compute it without knowing the sender's address, so you cannot discover those notes from a sender you haven't registered. This is a limitation of address-secret tagging, not of tagging in general. There are three broad families of solutions to this problem: **a) Brute force search** - Scan every single log and test if it decrypts. This has obvious performance issues as the network grows and becomes prohibitively expensive. -**b) Tagging with known sender** (current implementation) - You know who will send you messages and search for those specifically. This is very fast and allows you to remove senders who spam you. However, we don't currently have a mechanism for constraining this (i.e., guaranteeing that the recipient will find the message). - -**c) Tagging with handshaking** - An intermediate solution where you can be notified of new senders. A handshake occurs onchain that lets the recipient discover a new sender, and from that point on there's regular tagging. This design either: -- Is fast but leaks privacy (e.g., a public event with "new handshake for Alice!") -- Is slow but doesn't leak (you brute force scan all logs from a handshake contract, testing if any handshakes are for you) - -The handshaking design space is large — for example, you could set up infrastructure where a server searches handshakes for you, trading off infrastructure requirements for performance. +**b) Tagging with known sender** - You know who will send you messages and search for those specifically. This is very fast and allows you to remove senders who spam you. However, it cannot be constrained, i.e., it cannot guarantee that the recipient will find the message. It also requires registering each sender's address in advance with `wallet.registerSender(address)`, so you must learn that address first. -**Handshaking is not currently implemented in Aztec.nr.** For now, if you need to receive notes from unknown senders, potential workarounds include: -- Having senders register themselves in a contract first, allowing recipients to search for note tags from all registered senders -- Using offchain communication to share sender addresses with recipients, who then call `wallet.registerSender(address)` to enable discovery -- Implementing a custom discovery mechanism in your contract +**c) Tagging with a handshake** - The sender and recipient execute a handshake to agree on a tagging secret, after which regular tagging works, so the recipient can discover messages without having registered the sender in advance. A handshake can be interactive (the two coordinate off-chain) or non-interactive (published onchain, which needs no prior coordination but reveals information about the recipient). The wallet is the one that determines the type of handshake to use (see [tagging secret source](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source)). See the [Note Delivery](../../../aztec-nr/framework-description/note_delivery.md) documentation for more details on how the sender is used when delivering notes. From 48a022f8b3293e0607e51b750bdbef033822d713 Mon Sep 17 00:00:00 2001 From: Nico Chamo Date: Sat, 20 Jun 2026 18:42:21 -0300 Subject: [PATCH 14/14] docs: fix off-chain spelling to satisfy cspell --- .../docs/foundational-topics/advanced/storage/note_discovery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md b/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md index 633dd09b8b0d..267fba4a0a39 100644 --- a/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md +++ b/docs/docs-developers/docs/foundational-topics/advanced/storage/note_discovery.md @@ -112,7 +112,7 @@ There are three broad families of solutions to this problem: **b) Tagging with known sender** - You know who will send you messages and search for those specifically. This is very fast and allows you to remove senders who spam you. However, it cannot be constrained, i.e., it cannot guarantee that the recipient will find the message. It also requires registering each sender's address in advance with `wallet.registerSender(address)`, so you must learn that address first. -**c) Tagging with a handshake** - The sender and recipient execute a handshake to agree on a tagging secret, after which regular tagging works, so the recipient can discover messages without having registered the sender in advance. A handshake can be interactive (the two coordinate off-chain) or non-interactive (published onchain, which needs no prior coordination but reveals information about the recipient). The wallet is the one that determines the type of handshake to use (see [tagging secret source](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source)). +**c) Tagging with a handshake** - The sender and recipient execute a handshake to agree on a tagging secret, after which regular tagging works, so the recipient can discover messages without having registered the sender in advance. A handshake can be interactive (the two coordinate offchain) or non-interactive (published onchain, which needs no prior coordination but reveals information about the recipient). The wallet is the one that determines the type of handshake to use (see [tagging secret source](../../../aztec-nr/framework-description/note_delivery.md#tagging-secret-source)). See the [Note Delivery](../../../aztec-nr/framework-description/note_delivery.md) documentation for more details on how the sender is used when delivering notes.