Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
df57245
feat(pxe): add get_delivery_privacy_preference oracle
nchamo Jun 11, 2026
26917d5
fix(pxe): remove duplicate hooks re-export from server entrypoint
nchamo Jun 11, 2026
36c849f
refactor(pxe): address review feedback on delivery privacy preference
nchamo Jun 12, 2026
ea9bf23
Merge origin/merge-train/fairies-v5 into nchamo/f-699-pxewallet-get_d…
nchamo Jun 12, 2026
f5d14ac
docs: clarify delivery preference is consulted only when establishing…
nchamo Jun 15, 2026
2ea81d0
docs: explain the cross-contract risk authorizeUtilityCall guards aga…
nchamo Jun 15, 2026
4454c2c
feat(pxe): resolve tagging secret source via wallet hook
nchamo Jun 19, 2026
1c6c807
Merge origin/merge-train/fairies-v5 into nchamo/f-699-pxewallet-get_d…
nchamo Jun 19, 2026
f04873c
test(aztec-nr): wire oracle tests for resolve_tagging_secret
nchamo Jun 19, 2026
d550918
fix(pxe): refresh oracle interface hash after merge
nchamo Jun 19, 2026
5fc1e29
fix(txe): refresh TXE oracle interface hash after merge
nchamo Jun 19, 2026
a9d6a84
docs: use onchain to satisfy cspell forbidden word
nchamo Jun 19, 2026
a55134f
Merge origin/merge-train/fairies-v5 into nchamo/f-699-pxewallet-get_d…
nchamo Jun 19, 2026
19ba78d
fix(txe): refresh TXE oracle interface hash after merge
nchamo Jun 19, 2026
3e5e021
docs(aztec-nr): clarify tagging secret handshake reuse
nchamo Jun 19, 2026
f00d8eb
docs: clarify tagging secret source in note delivery/discovery
nchamo Jun 20, 2026
48a022f
docs: fix off-chain spelling to satisfy cspell
nchamo Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 2 additions & 46 deletions docs/docs-developers/docs/aztec-nr/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
nchamo marked this conversation as resolved.
Outdated

```
Cross-contract utility call denied: <reason>. <caller> attempted to call <target>:<selector> (<name>).
```

##### 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
nchamo marked this conversation as resolved.
Outdated
- **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.
Comment thread
nchamo marked this conversation as resolved.
Outdated

| | 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 |
Comment thread
nchamo marked this conversation as resolved.
Outdated

### 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.
Comment thread
nchamo marked this conversation as resolved.
Outdated
- **TXE tests**: max privacy, matching the bare PXE. Tests opt into best effort via `env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort())`.
Comment thread
nchamo marked this conversation as resolved.
Outdated

## 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
nchamo marked this conversation as resolved.
Outdated

## 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,
Comment thread
nchamo marked this conversation as resolved.
Outdated
},
});
```

## `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.
Comment thread
nchamo marked this conversation as resolved.

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.
Comment thread
nchamo marked this conversation as resolved.
Outdated

### In production
Comment thread
nchamo marked this conversation as resolved.
Outdated

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:
Comment thread
nchamo marked this conversation as resolved.
Outdated

```rust
// For private calls:
env.call_private_opts(
account,
CallPrivateOptions::new().with_authorized_utility_call_targets([target_address]),
Comment thread
nchamo marked this conversation as resolved.
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:
Comment thread
nchamo marked this conversation as resolved.
Outdated

```rust
env.set_delivery_privacy_preference(DeliveryPrivacyPreference::best_effort());
Comment thread
nchamo marked this conversation as resolved.
Outdated
```
2 changes: 2 additions & 0 deletions noir-projects/aztec-nr/aztec/src/messages/delivery/mod.nr
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod builder;
mod mode;
mod privacy_preference;
mod tag_secret_derivation;

pub mod handshake;
Expand All @@ -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`.
///
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
nchamo marked this conversation as resolved.
Outdated
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",
Comment thread
nchamo marked this conversation as resolved.
Outdated
);
preference
}
}

impl Deserialize for DeliveryPrivacyPreference {
Comment thread
nchamo marked this conversation as resolved.
Outdated
let N: u32 = <u8 as Deserialize>::N;

fn deserialize(fields: [Field; Self::N]) -> Self {
Self::from_u8(<u8 as Deserialize>::deserialize(fields))
}

fn stream_deserialize<let K: u32>(reader: &mut Reader<K>) -> Self {
Self::from_u8(reader.read() as u8)
}
}

impl Eq for DeliveryPrivacyPreference {
Comment thread
nchamo marked this conversation as resolved.
Outdated
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());
Comment thread
nchamo marked this conversation as resolved.
Outdated
}

#[test(should_fail_with = "unrecognized delivery privacy preference")]
fn deserializing_invalid_preference_fails() {
let _ = DeliveryPrivacyPreference::deserialize([99]);
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
nchamo marked this conversation as resolved.
Outdated
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());
});
}
}
1 change: 1 addition & 0 deletions noir-projects/aztec-nr/aztec/src/oracle/mod.nr
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion noir-projects/aztec-nr/aztec/src/oracle/version.nr
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading