Request for Comments: AIP-20: Aztec Token Standard

Abstract

This standard defines a unified API for token implementations within the Aztec protocol ecosystem. It establishes essential functionality for token transfers between accounts across both private and public execution contexts.

Due to Aztec’s sequential transaction processing architecture (private execution preceding public execution), transferring tokens from public to private state requires a preliminary transfer commitment. This commitment must be initialized from the private context and subsequently processed in the public context.

Introduction

Aztec is a zk-rollup providing humanity with privacy-preserving programmable digital money, identity solutions, games, and more. The key thing here is that users of apps built on top of it can choose which information to reveal to the world, and which to keep close. As you probably know, on Ethereum today, everything is publicly visible by everyone. if you need to summarize this in one phrase, the thing is that aztec brings privacy to ethereum. Now you have private functions AND public functions, composability and messaging between those two realities. We will introduce some foundational concepts bellow.

Public vs Private execution

Aztec transactions are executed in two different environments. Private environment, which happen on user devices (using the Private Execution Environment (PXE)) to maintain privacy and public environment by a network of nodes, similar to other blockchains.

While public function calls happen exclusively in public nodes, private function calls begin in the user’s device and are then relayed into the public network. This gives us the ability to execute public functions from private but not private functions from the public environment. These calls will get “queued” from the private environment, and be executed in order of appearance.

Notes

A Note is a cryptographically committed, privately owned data object representing a discrete unit of value or state. It’s the atomic unit of private state in the protocol.

A note is:

  • Committed to via a hash (note_commitment)
  • Encrypted and stored on-chain in a Merkle tree (the note tree)
  • Decrypted off-chain by the rightful owner using their secret viewing key
  • Spent by proving knowledge of its preimage and secret key, resulting in a nullifier

Notes encapsulate both the value being transferred and the authorization logic for who can spend them.

Usually, we can think of a Note as a struct that has the following fields:

This is not exactly the structure of a Note, but an example to get the mind around the concept.

struct Note {
    value: Field,
    owner_pub_key: AztecAddress,
    randomness: Field,
    contract_address: AztecAddress.
}

And the note commitment will be similar to: note_commitment = hash(value, asset_id, owner_pub_key, randomness).

If Bob receives a private payment of 25 DAI, then Bob scans the blockchain for notes encrypted to his key. When he finds this one, he can decrypt it and later spend it by proving knowledge of the commitment’s preimage and his secret key.

Nullifiers

A nullifier is a unique cryptographic fingerprint derived from a spent note. It acts as a non-linkable yet verifiable proof of spend, allowing the protocol to prevent double-spending while maintaining privacy.

Each nullifier is deterministically derived from a note and the secret key of the spender, but in such a way that:

  • It cannot be reverse-engineered to reveal the note or spender.
  • It does not link to the new notes being created in the same transaction.
  • It ensures that each note can be spent only once.

Nullifiers are inserted into a nullifier tree. A transaction is valid only if the nullifiers it generates are not already present in the tree.

Some properties are that they are:

  • One-to-one: Each note has exactly one nullifier
  • Non-malleable: Cannot be modified without invalidating the proof
  • Unlinkable: Does not reveal which note or who spent it
  • Deterministic and unique: Prevents reuse of a note

It will be computed as nullifier = H(note_secret_data || secret_key || constant), where note_secret_data includes value, randomness, and asset type, the secret_key is associated with the note’s owner, H is collision-resistant hash function and the constant is circuit-specific and ensures domain separation.

So, let’s say that Alice owns a note where:

  • value = 10
  • owner_pub_key = H(secret_key)
  • randomness = r1

When spending the note, she computes: nullifier = hash(H(10, r1, asset_id), secret_key, CONST). This nullifier is then inserted into the tree and any future attempt to spend the same note will result in a duplicate nullifier and cause the transaction to be rejected.

Commitment

A commitment is a Field element that acts as a placeholder for a yet-to-be-revealed Note. It serves as a bridge between the private and public execution contexts by allowing users to register the intent to transfer value without disclosing details like recipient or amount.

Public nodes, which cannot access private state or user secrets, use commitments to track these transfers in a verifiable but opaque way. Formally, a commitment is computed as a hash of one or more fields that would otherwise be used to construct a Note, such as:

commitment = hash(amount, recipient_public_key, randomness, asset_id, ...)

Commitments are:

  • Unlinkable: their preimage is not exposed on-chain.
  • One-way: cannot be reversed or interpreted without secrets.
  • Verifiable: can be checked for inclusion and equality without revealing their contents.
  • Hybrid between public and private: support transitions between execution contexts.

Summary

A Fungible Token implementation, proposed by Wonderland, describing the design decisions taken from the one in Aztec Packages repository. This document aims to establish the interface methods, so that all future implementations can seamlessly adapt.

Details

This Token implements the following changes to the previous implementation:

  • Method namings

  • Mintable vs Non Mintable variations

  • Note fetching mechanism

  • Partial Notes implementation

Method Categories

Private Methods:

  • Transfer tokens from private state to private state
  • Transfer tokens from private state to public state
  • Transfer tokens from private state to commitment
  • Initialize a transfer commitment

Public Methods:

  • Transfer tokens from public state to public state
  • Transfer tokens from public state to commitment

This standard ensures interoperability between token implementations and applications in the Aztec ecosystem while maintaining the protocol’s privacy and execution guarantees.

Note: Transferring to a commitment (either from private or public) is executed in the public space, although the recipient address is being hidden, it leaks information about the action being taken and the transferred amount.

Specification

Optional Methods

Metadata Getters

fn get_name() -> FieldCompressedString
fn get_symbol() -> FieldCompressedString
fn get_decimals() -> u8

Required Methods

Private Getters [Utility Methods]

/// @notice Returns the total balance of Notes owned by the provided address
///         within the calling PXE's database
/// @dev    Balance of private can't be proven to return the correct value.
///         Users may provide the total or less than what they actually have.
/// @param  from The address to check the balance for
/// @return The aggregated balance from all Notes where owner=from
fn balance_of_private(
    from: AztecAddress
) -> u128

Public Getters

/// @notice Returns the total amount of tokens in circulation
///         (sum of public and private balances)
/// @return The total supply of tokens
fn total_supply() -> u128

/// @notice Returns the public balance of the provided address
/// @param  from The address to check the balance for
/// @return The public balance for the specified address
fn balance_of_public(
    from: AztecAddress
) -> u128

Private Methods

/// @notice Transfers tokens privately from one address to another
/// @param  from The sender's address
/// @param  to The recipient's address
/// @param  amount The amount of tokens to transfer
/// @param  nonce A unique nonce for this transfer
fn transfer_private_to_private(
    from: AztecAddress,
    to: AztecAddress,
    amount: u128,
    nonce: Field
)

/// @notice Transfers tokens from private balance to public balance
/// @param  from The sender's address
/// @param  to The recipient's address
/// @param  amount The amount of tokens to transfer
/// @param  nonce A unique nonce for this transfer
fn transfer_private_to_public(
    from: AztecAddress,
    to: AztecAddress,
    amount: u128,
    nonce: Field
)

/// @notice Transfers tokens from private balance to public balance and initializes a transfer commitment
/// @dev    *Combines a transfer to public state with the emission 
///         of a matching commitment, enabling proofs that link both transitions.*
/// @param  from The sender's address
/// @param  to The recipient's address
/// @param  amount The amount of tokens to transfer
/// @param  nonce A unique nonce for this transfer
/// @return The partial note representing the commitment
fn transfer_private_to_public_with_commitment(
    from: AztecAddress,
    to: AztecAddress,
    amount: u128,
    nonce: Field
) -> Field

/// @notice Transfers tokens from private balance to a commitment
/// @dev *Burns Notes in the private context and emits a commitment representing 
///      the recipient's future Note. This is useful when the sender does not 
///      have the recipient’s viewing key.*
/// @param  from The sender's address
/// @param  commitment The partial note representing the commitment
/// @param  amount The amount of tokens to transfer
/// @param  nonce A unique nonce for this transfer
fn transfer_private_to_commitment(
    from: AztecAddress,
    commitment: Field,
    amount: u128,
    nonce: Field
)

/// @notice Initializes a transfer commitment to be used for transfers/mints
/// @dev    *Returns a computed commitment from known inputs.* 
/// @param  from The sender's address
/// @param  to The recipient's address
/// @return The partial note representing the commitment
fn initialize_transfer_commitment(
    from: AztecAddress,
    to: AztecAddress
) -> Field

Public Methods

/// @notice Transfers tokens publicly from one address to another
/// @param  from The sender's address
/// @param  to The recipient's address
/// @param  amount The amount of tokens to transfer
/// @param  nonce A unique nonce for this transfer
fn transfer_public_to_public(
    from: AztecAddress,
    to: AztecAddress,
    amount: u128,
    nonce: Field
)

/// @notice Transfers tokens from public balance to a commitment
/// @dev *Reduces public balance and creates a pending private Note commitment. 
///      The actual Note must be created off-chain by the recipient.*
/// @param  from The sender's address
/// @param  commitment The partial note representing the commitment
/// @param  amount The amount of tokens to transfer
/// @param  nonce A unique nonce for this transfer
fn transfer_public_to_commitment(
    from: AztecAddress,
    commitment: Field,
    amount: u128,
    nonce: Field
)

Extensions

Mintable

This section of the standard covers the minting interface, that is optionally present in tokens, depending on the constructor used to initialize the Token contract (read Supply Management below).

Private Methods:

/// @notice Mints tokens to a private balance
/// @dev    Method caller should be authorized
/// @param  from The message sender address (used for log encryption)
/// @param  to The recipient's address
/// @param  amount The amount of tokens to mint
fn mint_to_private(
    from: AztecAddress,
    to: AztecAddress,
    amount: u128
)

Public Methods:

/// @notice Mints tokens to a public balance
/// @dev    Method caller should be authorized
/// @param  to The recipient's address
/// @param  amount The amount of tokens to mint
fn mint_to_public(
    to: AztecAddress,
    amount: u128
)

/// @notice Mints tokens to a commitment
/// @dev    Method caller should be authorized
/// @dev    *Issues newly minted tokens into a commitment 
///         rather than a full Note or public balance. 
///         Typically used when minting to privacy-preserving destinations.*
/// @param  commitment The partial note representing the commitment
/// @param  amount The amount of tokens to mint
fn mint_to_commitment(
    commitment: Field
    amount: u128,
)

Burnable

This section of the standard covers the burning interface.

Private Methods:

/// @notice Burns tokens from a private balance
/// @param  from The address from which tokens will be burned
/// @param  amount The amount of tokens to burn
/// @param  nonce A unique nonce for this operation
fn burn_private(
    from: AztecAddress,
    amount: u128,
    nonce: Field
)

Public Methods:

/// @notice Burns tokens from a public balance
/// @param  from The address from which tokens will be burned
/// @param  amount The amount of tokens to burn
/// @param  nonce A unique nonce for this operation
fn burn_public(
    from: AztecAddress,
    amount: u128,
    nonce: Field
)

Opinionated Implementation

This section covers an opinionated implementation of the AzRC-20 standard, available in GitHub - defi-wonderland/aztec-standards repository.

  • Note Structure

    The standard defines operations for transferring balances, where private balances are implemented through the accumulation of Notes (a native Aztec concept). The interface remains agnostic to the internal structure of these Notes. In this implementation, Notes have the following structure:

    struct UintNote {
        owner: AztecAddress,
        amount: u128,
        random: Field
    }
    
  • Fetching and Processing Mechanism

    The PXE (Private eXecution Environment) must create proofs involving a number of Notes to be spent or burned. Since the number of Notes processed isn’t publicly revealed in ZK circuits, circuits must account for the maximum possible Notes processed. To optimize proof size:

    1. Initial Note Limit: The implementation sets an initial limit of 2 Notes per transfer operation.
    2. Recursive Mechanism: If the initial Notes don’t cover the total amount to transfer/burn, a recursive mechanism fetches and processes additional Notes in batches.
    3. Note Consolidation: Using 2 Notes initially helps reduce the total number of Notes a user holds over time. If only 1 Note were used instead (as initial note limit), almost every transfer would result in 1 Note spent and 1 change Note created, preventing consolidation.
  • Commitment Mechanism

    Like Notes, commitments can have multiple implementations while maintaining the same interface properties. This implementation leverages Aztec Noir’s Field package for the commitment mechanism required in public-to-private transfers.

  • Log Encryption

    This implementation uses AES-128 encryption for Note sharing, ensuring private information remains confidential when transmitting Notes between parties. The encryption is handled through Aztec’s encode_and_encrypt_note function.

    The encryption chosen needs to be the same that the Commitment Mechanism uses, because of fungibility between transferring to a commitment vs to a private address.

  • Supply Management

    • Fixed Supply Model: Using constructor_with_initial_supply creates a token with a predetermined initial supply assigned to a specified address. In this case, no minter is assigned, so all mintable methods will revert when called, effectively creating a capped supply token.
    • Managed Supply Model: Using constructor_with_minter assigns minting privileges to a designated address, allowing for dynamic supply management. This model supports ongoing minting operations through the mintable extension methods.
  • Authentication Witnesses
    The presence of a nonce parameter in many of the interface methods is specifically designed to support authentication witnesses. These witnesses serve as cryptographic proof that the owner of the Notes is indeed authorizing their spending.

This approach enables functionality similar to ERC-20’s transferFrom while preserving the privacy guarantees of the Aztec protocol and optimizing for the ZK context.
1. Ownership Verification: When a method is called with a from address that doesn’t match the msg_sender, the implementation must verify an authentication witness to ensure the caller has permission to spend from that address.
2. Nonce Mechanism: The nonce parameter prevents replay attacks by ensuring each authentication witness can only be used once. When the caller is the msg_sender (i.e., spending their own tokens), the nonce should be 0.
The nonce is added on the method interface (rather than popped out within the token via capsules), so that other smart-contracts can implement logic on top of the nonce (i.e., an AMM ensuring the nonce represents a “swap” rather than an “LP” approval).
3. Implementation Approach: Rather than using an “approve and transfer” pattern that relies on storage (as in ERC-20), this standard leverages authentication witnesses to authorize spending directly, reducing storage costs and maintaining privacy.

Implementation

An implementation that complies with the proposed interface was developed to be used by contracts that want to leverage this token or to be forked for contracts that want to implement their own logic.

6 Likes

When is it better to use a 2-step “public → private” flow instead of a single private transfer?

Hi everyone

I’m experimenting with this standard — which I find incredibly powerful and well-designed :fire:— and I want to congratulate you for their work :clap:. While exploring it, I came across two ways of moving funds from a public balance to a private note:

Flow Who signs Rough steps
Single private tx Only the sender transfer_public_to_private(sender, receiver, amount, nonce) — executed as a private function; the note is created directly in the sender’s PXE.
Two-step pattern :one: Receiver (private)
:two: Sender (public)
1. initialize_transfer_commitment(from, to)
2. transfer_public_to_commitment(sender, amount, commitment)

I saw that the people prefers the two-step pattern, but I’d love to better understand the trade-offs before choosing a design.


What I currently understand

  • A public function can’t directly create a private note for someone else — that’s why the commitment trick exists.
  • In a private function, the sender can create an encrypted note for the receiver locally using their PXE, without the receiver being online.
  • The entire private execution happens client-side in the PXE, and only the final proof is submitted to the sequencer.

What I’m still unclear about

  1. Is there any security risk in allowing the sender to create a private note for the receiver in a single private transaction ?
  2. If there’s an AMM contract, and Alice wants to deposit her private tokens into the AMM’s public state, the AMM would need to call the token contract with Alice’s AuthWit.
    Couldn’t these two chained calls, triggered by Alice, be executed entirely inside her PXE and then committed as one transaction? Why wouldn’t that work?

This feels like my key question, and I’d be really happy to understand how this works in practice, especially in cases where chaining private logic like this seems intuitive but might break Aztec’s model.

I find the transfer_private_to_public_with_commitment function particularly clever! since it prepares the return path in the same step as the deposit :rocket:

Would truly appreciate any insights or real-world patterns from folks building with Aztec :folded_hands:

Thanks in advance!

– Franco –

1 Like

Ey Franco, thanks for your reply, most of the design was taken from Aztec’s Token, meant for testing, but revealed most of the issues we plan here to address. In this case, the main difference between these 2 methods is syncronicity.

As you very well remark, transfer_public_to_private is a private tx, this means that the User, within the private context of the execution, instantly has disponibility of the funds he’s transferring from public to private, only that the reduction of public balance happens afterwards, in the public context of the execution.

On the other side, the initialize_transfer_commitment + transfer_public_to_commitment pattern happens (at least the 2nd call) on the public scope of execution, this means that if the User wants to use the funds that are sent to his private balance, he will have to perform another transaction afterwards.

These are meant for different purposes:

  • transfer public to private: shield a fixed amount of tokens to later spend them privately
  • initialize commitment + transfer to commitment: shield an unknown amount of tokens, that will be defined on the public context of the execution, for example, in the AMM case (where amount out is defined by the AMM’s liquidity)

Remember that the commitment + partial note finalization pattern is quite native to Aztec Noir, making it possible to create partial notes of any kind (not only UintNotes), to be partially completed later on from the public context (making the initialized part private to all).

1 Like