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); +}