ARC-403: AuthToken

Aztec Request for Comment 403, aka AuthToken, an extension of the ARC-20, named after the Forbidden Error.

Motivation

Aztec’s blockchain infrastructure provides transaction confidentiality through its ARC-20 Token standard (aka Vanilla). Within this framework, users exercise granular control over their privacy, choosing between transferring private balances, visible only to intended recipients, or public balances, fully transparent to all network observers.

While this privacy-by-design approach fulfills Aztec’s core mission, many token issuers express a legitimate need for conditional privacy. Specifically, they seek mechanisms to ensure that transaction confidentiality privileges extend exclusively to validated participants, e.g. those who have completed KYC procedures, belong to permissible jurisdictions, or satisfy other compliance parameters.

This requirement becomes particularly critical in Aztec’s privacy-centric environment, where the attack surface for sybil manipulation is expansive. The blockchain’s privacy features mean that new account contracts remain invisible to observers, creating a significant challenge for implementing conventional compliance frameworks.

What’s needed is a cryptographic solution that preserves the integrity of Aztec’s privacy guarantees while enabling issuers to verify participant eligibility without compromising the token composability. Such a mechanism would allow legitimate regulatory requirements to coexist with privacy, rather than standing in opposition to it, while preserving full compatibility with ARC-20 specific apps.

Specs

Preserving the ARC-20 interface is important for being fully compatible with ARC-20 specific apps (e.g. an AMM), hence, none of the transfer / minting / burning interfaces should be modified to apply the compliance layer to the Token.

Leveraging the versatile ARC-20 constructor (used to initialize, for example, capped supply tokens or mintable ones), an address is being added to it, called transfer_authority, that in case is different than AztecAddress::zero(), it will execute an external call to it each time the token is being transferred.

The ARC-403 specifies the calling interface to the Transfer Authority, as well as invites compliance framework creators to jump into the conversation of what the ideal interface should look like and design transfer authority contracts that can adapt to multiple use cases.

// on each Token Transfer...
if (!transfer_authority.eq(AztecAddress::zero())) {
    ComplianceCheck::at(transfer_authority)
        .authorize_transfer_{private/public}(
		        context.msg_sender(), 
		        from, 
		        amount
				).call(context);
}

The Transfer Authority will then have available the following information:

fn authorize_transfer_{private/public}(_sender: AztecAddress, _from: AztecAddress, _amount: u128) {
		context.msg_sender(); // the Token being transferred
		_sender; // the requestor of the token transfer (e.g. the AMM)
		_from;   // the account from which the tokens are being transferred
		_amount; // the amount of tokens being transferred

		// only in private context 👇🏻
		unsafe { capsules::load(...) } // arbitrary data (e.g. a zk Proof)
}

To be noticed:

  • The Transfer Authority is, within this (opinionated) Token implementation, an immutable AztecAddress, that may be an upgradeable contract
  • The recipient of the transfer isn’t being passed in the call, yet whenever the recipient wants to spend its balance, it’ll need to be authorized (warning: this can lead to bricked funds)
  • The Transfer Authority may include any logic within the authorize_transfer_* call

Open questions:

  • Is authorize_transfer_public needed?

    This would mean authorizing, for example, an AMM contract, as when a user swaps token A for token B (being token B an AuthToken), the AMM will appear in the Transfer Authority as the “sender”.

  • Is the transfer nonce needed?

    The nonce is currently being used to disambiguate private transfer Authwits, it could be sent in the authorize_transfer_private call, in order to maintain its functionality, and disambiguate also proofs being generated to authorize transfers.

Possible implementations

This section is supposed to work as an inspiration to the logic that may live within the Transfer Authorization contracts. These are NOT to be implemented per-se, but showing what’s the possible scope of design of this feature.

  • Transfer accumulator:

    An entity could perform KYC on its users, and allow an amount to be authorized to be transferred, without the entity knowing which amount has each user transferred.

    • On KYC, the entity would create an initial note of H(user,allowance,nonce), let’s say H(alice, 10_000, 0).
    • When Alice spends some tokens (let’s say, spends 1000), she’ll nullify this Note, creating a new one: H(alice, 9000, 1), while the circuit will check that:
      • Another Note with H(alice, X, 1) doesn’t exist
      • The allowance amount hasn’t underflowed (Alice didn’t spend more than the allowed amount)
    • On the next transfer, Alice creates a new note: H(alice, 8000, 2), and the circuit enforces the same checks.
    • When Alice decides so, because she has depleted her allowance, or because she just wants to, she’ll return to the entity to reset her allowance (providing perhaps, once again, her KYC documents).
  • Leaking information:

    The Transfer Authority may not implement any checks, but enforce an encrypted log to themselves, in order to be able to provide information to authorities in case being asked.

  • Signature by Authority:

    The entity could create an off-chain channel in which the sender can request a signed authorization, that the circuit may enforce to be correct. This would allow the entity to control which transfers happen, while the users keep their privacy when interacting with the blockchain apps.

  • ZK Proving Identity:

    As mentioned in the motivation, ensuring participant eligibility for a service provision (as a Token may be understood) is important for the issuer entities, this is nowadays enabled by, for example, ZkPassport checks. Where an entity could chose to allow the token to be transferred by: elder than 18 yo, participants of a certain nationality, or other information provided by their SDK.

    It’s important to note, that an Account must be permanently linked to 1 Identity, to avoid these proofs being shared to skip these checks, while 1 Identity may have more than 1 Accounts.

2 Likes

eyey, some updates on the 403, after some community survey we received the following feedback (a) sender (self.msg_sender of the Token contract – i.e. the AMM) doesn’t make sense to send, no policy can be added that would leverage “who the requestor” is, and could be circumvented by triggering the call via another operator, (b) sending to would be a nice-to-have for completeness.

as for (a), we agree on not finding a policy that could leverage the sender, but as for (b) we’ve found several caveats of adding to that are worth mentioning, and explaining why we propose to move forward without.


ARC-403: Removing to from the Hook Interface

The Problem with Keeping to

The hook currently exposes (from, to, amount). For commitment-based transfers, to is sealed inside a hash preimage the sender never has. Three options exist for what to pass:

  1. PRIVATE_ADDRESS_MAGIC_VALUE sentinel — the current approach. Hook authors receive a constant that means “recipient unknown.” Recipient-based policy is silently skipped. Any actor bypasses recipient gating by routing through a commitment. The interface promises to but delivers a lie.

  2. The commitment field cast as an address — unique per transfer, but not a real address. No key material, no key registry entry. Any hook logic that emits a note or log to this “address” produces output no one can ever decrypt or discover. It looks like an address and behaves like a trap.

  3. The real to passed explicitly — impossible for public commitment functions (transfer_public_to_commitment, mint_to_commitment) without destroying the privacy guarantee. For transfer_private_to_commitment it is also impossible: the sender only holds the opaque Field, not the preimage.

None of these give hook authors a usable recipient identity for commitment flows.

Why a Commitment Creation Hook Does Not Fix This

initialize_transfer_commitment(to, completer) runs in private context and does know the real to. A dedicated hook here could gate commitment creation on the recipient. But:

  • completer is not from. The completer is the address authorized to call the transfer function — an operator or intermediary. The actual balance source (from) is set later by the sender. Any sender-based policy added to the creation hook is circumventable by routing through a clean operator. Only recipient gating is reliably enforced.

  • Amount is unknown. The amount is set at transfer time. Amount-based policy cannot be checked at creation time.

  • Bridging to public context is fragile. The creation hook runs in private. For its approval to be visible to public completion, the auth contract must enqueue a public write of commitment → authorized to its own storage. This works mechanically but creates a staleness window: the policy is evaluated once at creation, then frozen. If a recipient is later sanctioned, their pre-authorized commitment remains valid.

The creation hook is therefore a narrow, single-purpose primitive: it enforces recipient eligibility at commitment creation time, nothing more. It does not restore a general-purpose to to the hook interface, and it does not solve the sender or amount enforcement gaps.

Decision: Remove to

The hook interface becomes authorize_private(from, amount) and authorize_public(from, amount).

to is removed because it cannot be provided consistently. Keeping it forces hook authors to handle a sentinel or a fake address for commitment flows, creating the illusion of recipient enforcement where none exists. A hook that appears to screen recipients but silently passes all commitment-based transfers is more dangerous than one that makes no claim about recipients at all.

Sender gating and amount gating remain fully enforceable across all flows — commitment or otherwise — because from and amount are always known at transfer time. Recipient gating on commitment flows is explicitly out of scope for the hook interface. Deployments that require it must implement it outside the hook, at the application layer.


TL/DR

  • policies that use to can always be circumvented by creating a partial-note commitment and transferring to the commitment
  • hooking commitment creation doesn’t know who the from is (the account from where the funds will be deduced in the actual transfer)
  • hooking commitment creation has fragile storage management, the hook is private but may be needed to consume in public
  • a blocked-list address can receive funds (we don’t have the recipient at policy checking), but depending on the policy implemented it’d be never be able to move them

Thanks for the thorough analysis, which explains pretty well the challenges of properly enforcing rules on to in transfers. However, I slightly disagree with the conclusion of simply dropping the ability to enforce any rules on receiving funds. I think there is still value in providing some (limited) ability to do so.

Along the line of what’s mentioned above, I believe an interface like this could make sense:

fn authorize_transfer_to_address_private(from: AztecAddress, amount: u128, to: AztecAddress);
fn authorize_transfer_to_address_public(from: AztecAddress, amount: u128, to: AztecAddress);

fn authorize_transfer_to_commitment_private(from: AztecAddress, amount: u128, to: Field);
fn authorize_transfer_to_commitment_public(from: AztecAddress, amount: u128, to: Field);

// To be enforced on `initialize_transfer_commitment`
fn authorize_new_transfer_commitment(to: AztecAddress, commitment: Field);

This exposes more low-level wiring of token transfers, which could make it look more daunting to implement an authorizer. To combat that, we could also provide helpers to implement a simple authorizer (that doesn’t care about to) on top of this.

While it still lacks the ability to reliably enforce on to as addresses, the low-level wiring does afford some new capabilities, e.g.

  • Regulate commitment-based transfers on a high level. It can be quota based, or outright forbidden.
  • Restrict someone’s ability to receive funds from direct transfers, or from creating new commitments.
  • Perform private tracking on all transfers, either to address or to commitment, through private event emission and custom off-chain indexing.
    • This may sound scary, but it may be a requirement for certain tokens in some jurisdiction. At least it can be done privately such that only the token authority has access to said information.