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
1 Like

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.
1 Like

(I’m not following the entire discussion, just commenting on the current state of partial note support in aztec-nr and future plans).

As of today, partial notes are fairly limited in their use. They must be created during a transaction, because they need to create what is called their ‘validity commitment’ nullifier. Additionally, they must be completed by a completer that is known at creation (typically a contract), and the completer must only complete each partial note once (or else notes will be lost).

We plan to introduce a second flow at some point in April, which we internally call ‘no-setup partial notes’. These would work as follows:

  • Alice calls an #[external(“utility”)] contract function (i.e. runs a simulation), which returns a PartialNote object and creates an off-chain effect, notably a message with Alice as the recipient. This encrypted message contains the secret partial note preimage, which Alice requires in order to later spend the note.
  • Alice calls the canoncal offchain_receive utility function in the contract with the produced offchain message. (in the near future her wallet may do this automatically because it’ll detect there is a message for Alice, and the wallet knows about Alice).
  • Alice provides the PartialNote object to Bob via some off-chain channel, e.g. QR code, chat, email, etc.
  • Bob can now call functions that take this PartialNote(e.g. token transfer functions), resulting in payments to Alice. Notably, Bob does not know Alice’s address. Only those actors that have seen the encrypted message content’s (i.e. Alice) know of the underlying recipient.
  • Bob can complete the PartialNote repeatedly. All observers will know the recipient of all of these payments is the same one, but they won’t know the recipient’s address.
  • The PartialNote object contains an internal expiration_timestamp. The PartialNote can only be completed before a certain point in time, after which it expires and completion is impossible. This completion window would likely not be huge, e.g. 24 hours.
  • Bob is free to be naughty and mutate the PartialNote object, causing it to e.g. have a larger expiration_timestampand enabling post-expiry completion. Alice will not receive the note. There is no mechanism to detect that Bob has not tampered with a PartialNote provided by Alice (the alternative would be to use a setup partial note, i.e. what exists today, or to come up with some signing scheme to validate authenticity).
3 Likes

Thanks for pushing this forward — the hook is genuinely useful, and I’d rather
argue about the shape now than after it ships.

What surprises me is that a change this central to the token standard is
presented with a single design and no documented alternatives. Standards
processes usually insist on an “alternatives considered” section precisely
because an extension point is the hardest thing to change later — and this one
is unusually final.

Where the current design runs out of room

There are two distinct costs here, and they’re worth separating.

The policy is welded to the deployed instance. auth_contract is a
PublicImmutable initialized in the constructor, and initialize emits an
initialization nullifier — so it can be set exactly once and there is no
setter. Consequences:

  • One asset can carry exactly one policy, chosen before it has any holders.
  • A live token can never gain, drop, or change a policy. Adding compliance to
    an existing asset means deploying a new token and migrating every holder.
  • Two policies over the same asset are impossible. Wanting a second one means a
    second deployment — a different address, a different asset, not fungible with
    the first.
  • The only escape is making the authorization contract itself upgradeable. But
    that relocates the mutability problem into a contract the ARC doesn’t
    specify, and hands whoever controls it unilateral authority over every
    transfer of that token, permanently.

And the extension point itself is fixed at the source level. Swapping in a
different policy contract works, but changing the shape or number of
extension points requires editing the token:

  • Fixed payload. A policy needing the recipient, or arguments specific to
    one method, cannot get them. The thread notes to was deliberately removed —
    reasonable, but now frozen into every token that will ever use the hook.
  • Fixed call sites. The hook fires on the seven transfer variants plus
    burn_private / burn_public. Mint is not hooked, so a policy that must
    gate issuance requires editing the token.
  • One slot. Compliance plus transfer caps plus a fee rule must be merged
    into a single contract, or the token grows more call sites.
  • It already didn’t generalize. MultiToken could not reuse the interface
    and defines its own MultiTokenAuthorizationContract taking
    (from, id, amount, selector). Same concept, second interface, second token
    contract — the pattern repeating at n = 2.

So the combinatorics aren’t per implementation, they’re per concern, and they
are paid twice: once in forked source per combination of concerns, and again in
deployed assets, because every policy choice mints a distinct, non-fungible
token. Given Noir has no dynamic dispatch, both costs land on users of the
standard rather than on its authors.

Alternative: composition instead of hooks

An authenticator is a contract that implements the full ARC-20 interface
itself, applies its policy per method, and forwards to an inner token. The
inner token accepts state-changing calls only from a registered set of
authenticators.

The absence of dynamic dispatch is exactly why this works: the interface being
called is the fixed one (ARC-20), so the wrapper compiles against the
standard rather than against any policy. Nothing needs forking in either
direction.

  1. No per-policy fork. One canonical token implementation serves every policy,
    instead of a fork per combination of concerns.
  2. One authenticator implementation serves many tokens.
  3. Concerns compose by stacking wrappers — linear, not a fork per combination.
  4. The extension surface is the entire ARC-20 interface, mint included, rather
    than a fixed three-field payload.
  5. Policy becomes deployable after the fact. A wrapper is a separate contract,
    so an authenticator can be introduced over an existing inner token without
    touching it and without re-issuing the asset — which the hook, being
    immutable per instance, fundamentally cannot do.

Worth noting the repo already ships GenericProxy with arity-enumerated
forward_private_0..N, so forwarding wrappers are an accepted pattern here
already. This is that shape, with the policy living in the wrapper.

What this asks of the token

To be clear that this is not free on the token side either: for policy to be
enforced rather than merely offered, the token has to restrict who may call
its state-changing methods — otherwise anyone routes around the authenticator
by calling the token directly. Today it has no such mechanism: access control
is an immutable minter for mints, plus authwit for spending from.

So this alternative is also an ask on the standard. The difference is what kind
of ask:

  • ARC-403 asks for a specific extension point — a policy interface with a
    fixed payload, wired into a fixed set of call sites, which had to be
    redefined once already for MultiToken.
  • Composition asks for a generic one — “these addresses may call
    state-changing methods.” No payload, no per-method call sites, nothing that
    varies per asset type, and it is reusable for purposes beyond authorization.

That primitive inherits the same governance question, and it should be answered
in the ARC rather than left to implementors: is the caller set immutable (in
which case it repeats the rigidity above), owner-governed, or opt-in per user?
My preference is per-user opt-in where the holder chooses which authenticator
governs their balance, since it keeps the trust decision with the person whose
funds are at stake.

Note the weaker form needs nothing at all: without any caller restriction,
wrappers still work through authwit and give opt-in policy — useful for
user-chosen controls like spending limits, but not for issuer-enforced
compliance. Only the enforced case requires the token’s cooperation.

Tradeoffs I’d want discussed, not glossed over

I don’t think this is free, and the differences are worth arguing explicitly:

  • Trust boundary. The inner token delegates authentication of from to the
    authenticator, so a registered authenticator can move any balance. ARC-403
    keeps the token itself authoritative. The registry becomes the critical
    governance surface.
  • Union, not intersection. With multiple authenticators active over one
    balance, the effective policy is the weakest one. If that’s not wanted, the
    registry has to be single-entry or capability-scoped.
  • Identity and UX. Users interact with the wrapper while notes live in the
    inner contract; wallets, PXE note discovery and explorers need a convention
    tying the two together. Two wrappers over one asset means two addresses for
    the same balance.
  • Cost. One extra call frame per operation, which is real proving cost in
    private.

Questions

  1. Can the ARC document the alternatives considered, and why a hook was chosen
    over composition?
  2. Is the immutable, single auth_contract intentional, versus a governed set?
  3. Should mint be in scope for authorization?
  4. Given MultiToken already needed a different payload, what’s the plan when
    the next asset type needs a fifth field?
  5. Would a generic caller-restriction primitive on the token — “only these
    addresses may call state-changing methods” — be considered, either instead
    of the hook or alongside it? It is a smaller surface than a policy
    interface, and it does not need revisiting each time a new policy wants
    different inputs.

Minor, but related to treating this as a real interface: the hook interface
currently ships from src/token_contract/src/test/test_authorization_contract
— a test crate. If ARC-403 lands, that should be a first-class interface crate
that implementors can depend on.

  1. Yes they were considered, composition is not yet a thing so shared exact contract (+side contract) was prioritized, as the rest of the features (mintable / fixed supply deployments)
  2. Yes, adding a setter requires also adding an owner
  3. Minting is already gated through a trusted party
  4. Auth can be extended, question there remains whether 1 auth per multitoken or per token ID
  5. We tried to keep the base token exactly the same across implementations, remember users will have to download the artifact

The auth token mechanism was created with the design goal of enabling the mechanism without rewriting the base ARC20 (in the scenario of updating every few weeks this became quite useful dev experience), I agree that composing or copy pasting into a new token contract provides more flexibility. The to was left behind because is impossible to cover every edge case (partial notes fulfillment) leaving always a window open. It has its limitations, but the extensions showcase the way how even with those limitations some useful functionalities can be added. In an ideal world, we’d have a few auth artifacts (differently set) and users won’t have to get a new artifact for each token they trade, but in the case of custom needs, forking the token and adapting it may save 100k gates on recursion, plus give you access to the token storage while running auth related logic.

“without rewriting” in software engineering context doesn’t mean two pieces of code are the same char by char. “without rewriting the base ARC20”, do they have same class ids? They are completely different contracts if you look at the lower level, they “embedded” or “static linked” totally different auth contract! “without rewriting the base ARC20” is very superficial sales words.

1 Like

My hot take is to do it like railgun did. Do all compliance off-chain and keep onchain neutral. It does not require any changes onchain, and very flexible. Bad actors can still make transactions, but their txs and withdraws will not be added to the offchain public db that shows the proofs of every tx being “compliant”.
Nice thing is that you can always add more protocols later, swap out proof system or anything you want. Since it’s not deployed onchain anyway. And a CEX with stricter requirements can fork the code and make their own requirements.

Ofc is you require no bad actor to ever be able to make a tx on your token, this might not be strict enough. But tbh it does not need to be that strict, railgun already does it like this and it deters and de-anonymize bad actors enough that there are no real issues here.
Being a bad actor and having every shielded tx and withdraw being labeled “no proof of innocence” is not a good look and wont get you far when laundering.
Besides the flexibility you gain as a dev, gives you more levers: to improve ux, compatibility with other shielded aztec contracts and to adapt to prevent abuse.
Besides it’s fork-able, respects user choice better, and there for far less centralized.