Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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 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: <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,32 @@ 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`

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When one has already been established for the pair, it is reused directly

you mean when an onchain handshake exists, correct? not necessarily that any shared secret has already been used, rather that one has been registered (an ecdh handshake would not be registered)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, I made it clearer


The wallet's answer is a concrete **tagging secret source**. There are two sources today:

- **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.

| | Non-interactive handshake | Shared secret |
|---|---|---|
| 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 |

### Defaults

When no `resolveTaggingSecret` hook is configured, the PXE applies a privacy-safe default:

- **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.

### Configuring the source

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

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,95 @@
---
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. All hooks are 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";

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" };
},
// When there's no established way to reach the recipient, fall back to a non-interactive handshake.
resolveTaggingSecret: async () => ({ type: "non-interactive-handshake" }),
},
});
```

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

```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(),
);
```

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

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

### In Noir tests

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_tagging_secret_source(TaggingSecretSource::non_interactive_handshake()),
);
```

### In production

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 onchain trace, while constrained delivery fails rather than silently revealing the recipient through a non-interactive handshake.
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,6 +1,7 @@
mod builder;
mod mode;
mod tag_secret_derivation;
mod tagging_secret_source;

pub mod handshake;

Expand All @@ -21,6 +22,7 @@ pub use builder::{
MessageDelivery, MessageDeliveryBuilder, OffchainDelivery, OnchainConstrainedDelivery, OnchainUnconstrainedDelivery,
};
pub use mode::OnchainDeliveryMode;
pub use tagging_secret_source::TaggingSecretSource;

/// 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,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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be source and secret, correct? Because we do have secret here.

Also try to make it so public nr docs have their first paragraph always be a very short single line (e.g. 'a tagging shared secret and its source'), expanding in future paragraphs. since the first par is what the docsite renders on a preview (see e.g. how the 'modules' list here is fairly clean https://docs.aztec.network/aztec-nr-api/mainnet/noir_aztec/index.html, while this is messier https://docs.aztec.network/aztec-nr-api/mainnet/noir_aztec/oracle/notes/).

The same advice applies to all fns in the impl below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I improved the docs, but I'm not sure what you mean by:

This would be source and secret, correct? Because we do have secret here.

Are you suggesting we rename 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<let K: u32>(reader: &mut Reader<K>) -> 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]);
}
}
6 changes: 6 additions & 0 deletions noir-projects/aztec-nr/aztec/src/oracle/mod.nr
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ 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;
pub mod execution;
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
);
}
}
Loading
Loading