From 9c4765518a11b65bb7381d1cd5d28e46236ca300 Mon Sep 17 00:00:00 2001 From: Alejo Amiras Date: Wed, 19 Aug 2026 14:48:02 +0000 Subject: [PATCH] feat: add cancel_authwit to Token, NFT and MultiToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream-parity function present in every upstream reference token contract but absent from all three of ours. Without it, a private authwit that has been granted but not yet consumed cannot be revoked. Ported verbatim from the upstream Token/NFT contracts (aztec-packages v5.1.0): #[external("private")] fn cancel_authwit(inner_hash: Field) { let on_behalf_of = self.msg_sender(); let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); self.context.push_nullifier_unsafe(nullifier); } Cancellation pre-emits the same authwit nullifier the consume path emits, so a later authwit-gated call fails with a duplicate nullifier. Three tests per contract: the negative case (cancel then attempt to consume -> duplicate nullifiers), a positive control (same flow without the cancel succeeds, attributing the failure to the cancel), and caller isolation (a foreign account cancelling with the owner's exact inner hash does NOT revoke the owner's authwit, since the nullifier is bound to msg_sender). READMEs list the new function. Additive: a new selector, no existing ABI touched. Validated: token 84 / nft 64 / multitoken 80 Noir tests, aztec compile OK. Codex adversarial review: safe and upstream-equivalent; its two suggestions (tighten the negative assertion, add the caller-isolation test) are applied — note its proposed error string 'already present' was empirically wrong, the matchable revert is 'duplicate nullifiers'. Co-Authored-By: Claude Fable 5 --- src/multitoken_contract/README.md | 1 + src/multitoken_contract/src/main.nr | 12 ++ src/multitoken_contract/src/test.nr | 1 + .../src/test/cancel_authwit.nr | 127 ++++++++++++++++++ src/nft_contract/README.md | 10 ++ src/nft_contract/src/main.nr | 12 ++ src/nft_contract/src/test.nr | 1 + src/nft_contract/src/test/cancel_authwit.nr | 94 +++++++++++++ src/token_contract/README.md | 10 ++ src/token_contract/src/main.nr | 12 ++ src/token_contract/src/test.nr | 1 + src/token_contract/src/test/cancel_authwit.nr | 111 +++++++++++++++ 12 files changed, 392 insertions(+) create mode 100644 src/multitoken_contract/src/test/cancel_authwit.nr create mode 100644 src/nft_contract/src/test/cancel_authwit.nr create mode 100644 src/token_contract/src/test/cancel_authwit.nr diff --git a/src/multitoken_contract/README.md b/src/multitoken_contract/README.md index 6036d073..1a8385cf 100644 --- a/src/multitoken_contract/README.md +++ b/src/multitoken_contract/README.md @@ -82,6 +82,7 @@ All addresses are `AztecAddress`; `id` is a `Field`, `amount` is a `u128`, and ` - `initialize_transfer_commitment(to, completer) -> Field` — Creates a partial note (privacy entrance) to be completed by later transfers/mints. Id-agnostic: the completer binds `id` and `amount`. See [Commitment trust model](#commitment-trust-model) before using a commitment as a payment guarantee. - `mint_to_private(to, id, amount)` — Minter mints `id` into a private balance. Fully private. - `burn_private(from, id, amount, nonce)` — Burns `id` from a private balance. Fully private. +- `cancel_authwit(inner_hash)` — Cancels a private authwit the caller previously granted, by emitting its `(msg_sender, inner_hash)` nullifier so it can no longer be consumed. ### Public Functions diff --git a/src/multitoken_contract/src/main.nr b/src/multitoken_contract/src/main.nr index 72554317..2567c663 100644 --- a/src/multitoken_contract/src/main.nr +++ b/src/multitoken_contract/src/main.nr @@ -7,6 +7,7 @@ use aztec::macros::aztec; pub contract MultiToken { // aztec library use aztec::{ + authwit::auth::compute_authwit_nullifier, macros::{ events::event, functions::{authorize_once, external, initializer, internal, only_self, view}, @@ -450,6 +451,17 @@ pub contract MultiToken { self.emit(TransferSingle { from, to: AztecAddress::zero(), id, amount }); } + /// @notice Cancels a private authentication witness the caller previously granted + /// @dev Emits the authwit nullifier for `(msg_sender, inner_hash)`, so an authwit that has been + /// granted but not yet consumed can no longer be used. Matches the upstream token contracts. + /// @param inner_hash The inner hash of the authwit to cancel + #[external("private")] + fn cancel_authwit(inner_hash: Field) { + let on_behalf_of = self.msg_sender(); + let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); + self.context.push_nullifier_unsafe(nullifier); + } + /** ========================================================== * ================= TOKEN LIBRARIES ========================= * ======================================================== */ diff --git a/src/multitoken_contract/src/test.nr b/src/multitoken_contract/src/test.nr index db99ce37..0c5313cb 100644 --- a/src/multitoken_contract/src/test.nr +++ b/src/multitoken_contract/src/test.nr @@ -13,4 +13,5 @@ mod burn_public; mod authorization; mod balance_of; mod initialize_transfer_commitment; +mod cancel_authwit; pub mod utils; diff --git a/src/multitoken_contract/src/test/cancel_authwit.nr b/src/multitoken_contract/src/test/cancel_authwit.nr new file mode 100644 index 00000000..374a579a --- /dev/null +++ b/src/multitoken_contract/src/test/cancel_authwit.nr @@ -0,0 +1,127 @@ +use crate::MultiToken; +use crate::test::utils; +use aztec::authwit::auth::compute_inner_authwit_hash; +use aztec::hash::hash_args; +use aztec::protocol::traits::ToField; +use aztec::test::helpers::authwit as authwit_cheatcodes; +use generic_proxy::GenericProxy; + +// Proves that once `owner` cancels a private authwit, a caller holding it can no longer consume it: +// the cancellation pre-emits the authwit nullifier, so the later authwit-gated transfer fails when +// it tries to emit the same nullifier again. +#[test(should_fail_with = "duplicate nullifiers")] +unconstrained fn cancelled_authwit_cannot_be_consumed() { + let id: Field = 1; + let (mut env, multitoken_contract_address, owner, recipient, _minter, proxy) = + utils::setup_and_mint_to_private_with_proxy(id); + + let transfer_amount = (1_000 as u128); + let transfer_call = MultiToken::at(multitoken_contract_address).transfer_private_to_private( + owner, + recipient, + id, + transfer_amount, + 1, + ); + + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + let inner_hash = compute_inner_authwit_hash([ + proxy.to_field(), + transfer_call.selector.to_field(), + hash_args(transfer_call.args), + ]); + env.call_private(owner, MultiToken::at(multitoken_contract_address).cancel_authwit(inner_hash)); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_5( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); +} + +// Positive control: the same flow WITHOUT the cancel succeeds, attributing the failure above to the +// cancellation. +#[test] +unconstrained fn uncancelled_authwit_is_consumed() { + let id: Field = 1; + let (mut env, multitoken_contract_address, owner, recipient, _minter, proxy) = + utils::setup_and_mint_to_private_with_proxy(id); + + let transfer_amount = (1_000 as u128); + let transfer_call = MultiToken::at(multitoken_contract_address).transfer_private_to_private( + owner, + recipient, + id, + transfer_amount, + 1, + ); + + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_5( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); + + utils::check_private_balance( + env, + multitoken_contract_address, + recipient, + id, + transfer_amount, + ); +} + +// Caller isolation: a foreign account cancelling with the owner's exact inner hash does NOT revoke +// the owner's authwit — the nullifier is bound to `msg_sender`. +#[test] +unconstrained fn foreign_cancel_does_not_revoke_owner_authwit() { + let id: Field = 1; + let (mut env, multitoken_contract_address, owner, recipient, _minter, proxy) = + utils::setup_and_mint_to_private_with_proxy(id); + + let transfer_amount = (1_000 as u128); + let transfer_call = MultiToken::at(multitoken_contract_address).transfer_private_to_private( + owner, + recipient, + id, + transfer_amount, + 1, + ); + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + let inner_hash = compute_inner_authwit_hash([ + proxy.to_field(), + transfer_call.selector.to_field(), + hash_args(transfer_call.args), + ]); + // `recipient` (not the granter) attempts to cancel the owner's authwit + env.call_private( + recipient, + MultiToken::at(multitoken_contract_address).cancel_authwit(inner_hash), + ); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_5( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); + utils::check_private_balance( + env, + multitoken_contract_address, + recipient, + id, + transfer_amount, + ); +} diff --git a/src/nft_contract/README.md b/src/nft_contract/README.md index b6613871..97b43a96 100644 --- a/src/nft_contract/README.md +++ b/src/nft_contract/README.md @@ -169,6 +169,16 @@ fn mint_to_private(to: AztecAddress, token_id: Field) { /* ... */ } fn burn_private(from: AztecAddress, token_id: Field, _nonce: Field) { /* ... */ } ``` +### cancel_authwit +```rust +/// @notice Cancels a private authentication witness the caller previously granted +/// @dev Emits the authwit nullifier for `(msg_sender, inner_hash)`, so an authwit that has been +/// granted but not yet consumed can no longer be used +/// @param inner_hash The inner hash of the authwit to cancel +#[private] +fn cancel_authwit(inner_hash: Field) { /* ... */ } +``` + ## Public Functions ### transfer_public_to_public diff --git a/src/nft_contract/src/main.nr b/src/nft_contract/src/main.nr index 9a8db51a..12320e55 100644 --- a/src/nft_contract/src/main.nr +++ b/src/nft_contract/src/main.nr @@ -7,6 +7,7 @@ use aztec::macros::aztec; pub contract NFT { // aztec library use aztec::{ + authwit::auth::compute_authwit_nullifier, macros::{ events::event, functions::{authorize_once, external, initializer, internal, only_self, view}, @@ -408,6 +409,17 @@ pub contract NFT { self.emit(Transfer { from, to: AztecAddress::zero(), token_id }); } + /// @notice Cancels a private authentication witness the caller previously granted + /// @dev Emits the authwit nullifier for `(msg_sender, inner_hash)`, so an authwit that has been + /// granted but not yet consumed can no longer be used. Matches the upstream NFT contract. + /// @param inner_hash The inner hash of the authwit to cancel + #[external("private")] + fn cancel_authwit(inner_hash: Field) { + let on_behalf_of = self.msg_sender(); + let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); + self.context.push_nullifier_unsafe(nullifier); + } + /** ========================================================== * ================= TOKEN LIBRARIES ========================= * ======================================================== */ diff --git a/src/nft_contract/src/test.nr b/src/nft_contract/src/test.nr index 6528cd53..b4b8d0b9 100644 --- a/src/nft_contract/src/test.nr +++ b/src/nft_contract/src/test.nr @@ -10,5 +10,6 @@ mod transfer_private_to_public_with_commitment; mod transfer_private_to_public; mod transfer_public_to_private; mod transfer_public_to_public; +mod cancel_authwit; pub mod utils; mod view; diff --git a/src/nft_contract/src/test/cancel_authwit.nr b/src/nft_contract/src/test/cancel_authwit.nr new file mode 100644 index 00000000..17a35660 --- /dev/null +++ b/src/nft_contract/src/test/cancel_authwit.nr @@ -0,0 +1,94 @@ +use crate::NFT; +use crate::test::utils; +use aztec::authwit::auth::compute_inner_authwit_hash; +use aztec::hash::hash_args; +use aztec::protocol::traits::ToField; +use aztec::test::helpers::authwit as authwit_cheatcodes; +use generic_proxy::GenericProxy; + +// Proves that once `owner` cancels a private authwit, a caller holding it can no longer consume it: +// the cancellation pre-emits the authwit nullifier, so the later authwit-gated transfer fails when +// it tries to emit the same nullifier again. +#[test(should_fail_with = "duplicate nullifiers")] +unconstrained fn cancelled_authwit_cannot_be_consumed() { + let token_id = 1; + let (mut env, nft_contract_address, owner, _minter, recipient, proxy) = + utils::setup_and_mint_to_private_with_proxy(token_id); + + let transfer_call = + NFT::at(nft_contract_address).transfer_private_to_private(owner, recipient, token_id, 1); + + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + let inner_hash = compute_inner_authwit_hash([ + proxy.to_field(), + transfer_call.selector.to_field(), + hash_args(transfer_call.args), + ]); + env.call_private(owner, NFT::at(nft_contract_address).cancel_authwit(inner_hash)); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); +} + +// Positive control: the same flow WITHOUT the cancel succeeds, attributing the failure above to the +// cancellation. +#[test] +unconstrained fn uncancelled_authwit_is_consumed() { + let token_id = 1; + let (mut env, nft_contract_address, owner, _minter, recipient, proxy) = + utils::setup_and_mint_to_private_with_proxy(token_id); + + let transfer_call = + NFT::at(nft_contract_address).transfer_private_to_private(owner, recipient, token_id, 1); + + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); + + utils::assert_owns_private_nft(env, nft_contract_address, recipient, token_id); +} + +// Caller isolation: a foreign account cancelling with the owner's exact inner hash does NOT revoke +// the owner's authwit — the nullifier is bound to `msg_sender`. +#[test] +unconstrained fn foreign_cancel_does_not_revoke_owner_authwit() { + let token_id = 1; + let (mut env, nft_contract_address, owner, _minter, recipient, proxy) = + utils::setup_and_mint_to_private_with_proxy(token_id); + + let transfer_call = + NFT::at(nft_contract_address).transfer_private_to_private(owner, recipient, token_id, 1); + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + let inner_hash = compute_inner_authwit_hash([ + proxy.to_field(), + transfer_call.selector.to_field(), + hash_args(transfer_call.args), + ]); + // `recipient` (not the granter) attempts to cancel the owner's authwit + env.call_private(recipient, NFT::at(nft_contract_address).cancel_authwit(inner_hash)); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); + utils::assert_owns_private_nft(env, nft_contract_address, recipient, token_id); +} diff --git a/src/token_contract/README.md b/src/token_contract/README.md index 2cf938f1..3ea4c8ca 100644 --- a/src/token_contract/README.md +++ b/src/token_contract/README.md @@ -357,6 +357,16 @@ fn mint_to_private(to: AztecAddress, amount: u128) { /* ... */ } fn burn_private(from: AztecAddress, amount: u128, nonce: Field) { /* ... */ } ``` +### cancel_authwit +```rust +/// @notice Cancels a private authentication witness the caller previously granted +/// @dev Emits the authwit nullifier for `(msg_sender, inner_hash)`, so an authwit that has been +/// granted but not yet consumed can no longer be used +/// @param inner_hash The inner hash of the authwit to cancel +#[private] +fn cancel_authwit(inner_hash: Field) { /* ... */ } +``` + ## ARC-403 Authorization Hook The authorization contract address is set at construction via the `auth_contract` parameter on both constructors and stored as an immutable field. A zero address disables the hook. When set, each hooked function calls either `authorize_private(from, amount, selector)` or `authorize_public(from, amount, selector)` on the authorization contract after authwit validation and before any balance mutation. The token operation reverts if the authorization call reverts. The hook variant matches the calling function's context — private functions call `authorize_private`, public functions call `authorize_public` — and the `selector` passed is the calling function's own selector. diff --git a/src/token_contract/src/main.nr b/src/token_contract/src/main.nr index 3201ef61..fd57d2ae 100644 --- a/src/token_contract/src/main.nr +++ b/src/token_contract/src/main.nr @@ -6,6 +6,7 @@ use aztec::macros::aztec; pub contract Token { // aztec library use aztec::{ + authwit::auth::compute_authwit_nullifier, macros::{ events::event, functions::{authorize_once, external, initializer, internal, only_self, view}, @@ -491,6 +492,17 @@ pub contract Token { self.internal._burn_public(from, amount); } + /// @notice Cancels a private authentication witness the caller previously granted + /// @dev Emits the authwit nullifier for `(msg_sender, inner_hash)`, so an authwit that has been + /// granted but not yet consumed can no longer be used. Matches the upstream Token contract. + /// @param inner_hash The inner hash of the authwit to cancel + #[external("private")] + fn cancel_authwit(inner_hash: Field) { + let on_behalf_of = self.msg_sender(); + let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); + self.context.push_nullifier_unsafe(nullifier); + } + /// @notice Decreases the total supply by `amount` /// @param amount The amount of tokens to decrease the total supply by #[external("public")] diff --git a/src/token_contract/src/test.nr b/src/token_contract/src/test.nr index e6a695d3..a4f256f3 100644 --- a/src/token_contract/src/test.nr +++ b/src/token_contract/src/test.nr @@ -12,5 +12,6 @@ mod transfer_public_to_public; mod transfer_public_to_commitment; mod transfer_public_to_private; mod authorization; +mod cancel_authwit; mod view; pub mod utils; diff --git a/src/token_contract/src/test/cancel_authwit.nr b/src/token_contract/src/test/cancel_authwit.nr new file mode 100644 index 00000000..9ddb0d5c --- /dev/null +++ b/src/token_contract/src/test/cancel_authwit.nr @@ -0,0 +1,111 @@ +use crate::test::utils; +use crate::Token; +use aztec::authwit::auth::compute_inner_authwit_hash; +use aztec::hash::hash_args; +use aztec::protocol::traits::ToField; +use aztec::test::helpers::authwit as authwit_cheatcodes; +use generic_proxy::GenericProxy; + +// Proves that once `owner` cancels a private authwit, a caller holding that authwit can no longer +// consume it: the cancellation pre-emits the authwit nullifier, so the later authwit-gated transfer +// fails when it tries to emit the same nullifier again. +#[test(should_fail_with = "duplicate nullifiers")] +unconstrained fn cancelled_authwit_cannot_be_consumed() { + let (mut env, token_contract_address, owner, recipient, proxy) = + utils::setup_and_mint_to_private_with_proxy(); + + let transfer_amount = (1000 as u128); + let transfer_call = Token::at(token_contract_address).transfer_private_to_private( + owner, + recipient, + transfer_amount, + 1, + ); + + // owner grants the authwit to the proxy + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + // owner cancels it, using the same inner hash the authwit machinery derives + let inner_hash = compute_inner_authwit_hash([ + proxy.to_field(), + transfer_call.selector.to_field(), + hash_args(transfer_call.args), + ]); + env.call_private(owner, Token::at(token_contract_address).cancel_authwit(inner_hash)); + + // proxy now attempts the transfer with the cancelled authwit — must fail + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); +} + +// Sanity: the SAME flow WITHOUT the cancel succeeds, so the failure above is attributable to the +// cancellation and not to some unrelated setup problem. +#[test] +unconstrained fn uncancelled_authwit_is_consumed() { + let (mut env, token_contract_address, owner, recipient, proxy) = + utils::setup_and_mint_to_private_with_proxy(); + + let transfer_amount = (1000 as u128); + let transfer_call = Token::at(token_contract_address).transfer_private_to_private( + owner, + recipient, + transfer_amount, + 1, + ); + + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); + + utils::check_private_balance(env, token_contract_address, recipient, transfer_amount); +} + +// Caller isolation: a foreign account cancelling with the owner's exact inner hash does NOT revoke +// the owner's authwit — the nullifier is bound to `msg_sender`, so the foreign cancel nullifies a +// different (attacker-bound) value and the owner's grant to the proxy still consumes successfully. +#[test] +unconstrained fn foreign_cancel_does_not_revoke_owner_authwit() { + let (mut env, token_contract_address, owner, recipient, proxy) = + utils::setup_and_mint_to_private_with_proxy(); + + let transfer_amount = (1000 as u128); + let transfer_call = Token::at(token_contract_address).transfer_private_to_private( + owner, + recipient, + transfer_amount, + 1, + ); + authwit_cheatcodes::add_private_authwit_from_call(env, owner, proxy, transfer_call); + + let inner_hash = compute_inner_authwit_hash([ + proxy.to_field(), + transfer_call.selector.to_field(), + hash_args(transfer_call.args), + ]); + // `recipient` (not the granter) attempts to cancel the owner's authwit + env.call_private(recipient, Token::at(token_contract_address).cancel_authwit(inner_hash)); + + // The owner's authwit is unaffected: the proxy transfer still succeeds + env.call_private( + owner, + GenericProxy::at(proxy).forward_private_4( + transfer_call.target_contract, + transfer_call.selector, + transfer_call.args, + ), + ); + utils::check_private_balance(env, token_contract_address, recipient, transfer_amount); +}