Implementing a Token Contract
Write your first Aztec contract and deploy it to the sandbox
Installation
Prerequisites:
Before you start, ensure you have a working environment. You need:
- Docker
aztec-up
- install by running
bash -i <(curl -s https://install.aztec.network)
- Node.js (minimum version 22)
For easier development, install the Noir extension/LSP for VSCode (download).
Once the prerequisites are met, run this command to install the Aztec toolchain:
aztec-up -v 3.0.0-nightly.20250908
(optional) To set up the LSP for Aztec programming, go to the Noir extension settings in VSCode and under Noir: Nargo Path
set this value to the location of aztec-nargo
installed in the command above (this should be in ~/.aztec/bin/aztec-nargo
). aztec-nargo
is the Noir build tool pinned at a version bundled with each Aztec release to ensure compatibility.
Project setup and structure
Clone the aztec-examples
repository and navigate to the starter-token/start-here
folder. This folder will be our main reference through out this tutorial.
This tutorial uses both Aztec.nr and Aztec.js to write code that interacts with the network.
Aztec.nr
Aztec.nr is the smart contract framework for building private applications on Aztec using Noir. Noir is a Rust-inspired DSL for writing zero-knowledge proofs; Aztec.nr extends it with Aztec-specific libraries for state management, privacy primitives, and protocol interactions.
It provides rails that helps you implement best practices and avoid potentially dangerous ones (eg letting a user spend another user's notes).
Aztec.js
Aztec.js is the library for interacting with the Aztec network. It provides tools for deploying contracts, managing accounts, sending transactions, and querying private state. The SDK handles encryption, note management, and proof generation, enabling developers to build privacy-first applications using familiar web development patterns.
Define a contract with Aztec.nr
First, take a quick look at Nargo.toml in contract/
[package]
name = "starter_token_contract"
authors = [""]
compiler_version = ">=1.0.0"
type = "contract"
[dependencies]
aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v3.0.0-nightly.20250908", directory = "noir-projects/aztec-nr/aztec" }
This file is required and defines the contract's metadata and dependencies (including Aztec.nr).
After reviewing Nargo.toml
, you can start reviewing the contract itself. Navigate to contract/src/main.nr
and review the code stub.
use aztec::macros::aztec;
#[aztec]
pub contract StarterToken {
// Start here !
}
This code is a bare-bones Aztec contract. It imports the aztec
macro, which defines and annotates a contract using the contract
keyword. Macros are very important and prevalent throughout Aztec contracts, because Aztec.nr uses them to inject code into and transform code of the structs and functions they decorate.
Building the Contract
Writing the Contract
In this section you will build a token contract with both public and private functionality on Aztec. You'll start with a basic public token (similar to an ERC-20), then add private state and functions, implement cross-domain interactions between private and public execution, and finally explore the composability of Aztec contracts through cross-contract interactions.
The token contract used in this tutorial has two basic operations: mint and transfer in both public and private. Only designated owners can mint, while anyone with a sufficient balance can transfer.
Please make sure to import any missing definitions as you come across them when following along with this tutorial. The imports and code snippets shared are assumed to be placed directly inside the contract struct, like so.
For example, if you need to use the state variable PublicMutable
, you can use the noir extension to automatically import it, or you can manually locate the specific type from Aztec.nr.
pub contract StarterToken {
use aztec::state_vars::public_mutable::PublicMutable,
...
}
Note any further required imports follow Rust formatting. If any further clarification is needed, please take a look at the completed imports section in the contract under the starter-token/reference
folder.
Part 1: Public token contract
Set up storage
First, define the contract's storage structure. To declare storage, use a Storage
struct with the #[storage]
macro:
#[storage]
struct Storage<Context> {
owner: PublicMutable<AztecAddress, Context>,
balances: Map<AztecAddress, PublicMutable<u128, Context>, Context>,
}
This creates:
owner
: A mutable public state variable that stores the contract owner's addressbalances
: A map from user addresses to their token balances (as a u128, the largest unsigned int type). Note that the value of this map is also a mutable public state variable
The #[storage]
macro injects code to expose the contents of this struct to:
- other functions defined in the contract
- any non-Noir consumer of the contract (like Aztec.js) via the artifact
The verbose <Context>
declarations are required by Noir's type system (inherited from Rust).
Initialize the contract
Use an initializer to set the contract owner when the contract is first deployed.
#[initializer]
#[public]
fn setup() {
// The deployer becomes the owner
storage.owner.write(context.msg_sender());
}
The #[initializer]
macro ensures that this function runs before any other functions in the contract. It is useful in situations where some setup is required before the other functions can operate correctly. In this case the initializer helps ensure that the owner is never undefined.
The #[public]
macro provides access to:
storage
: Your contract's state variablescontext
: Execution context includingmsg_sender()
Add minting
Add a mint function that only the owner can call:
#[public]
fn mint(to: AztecAddress, amount: u128) {
assert_eq(context.msg_sender(), storage.owner.read());
let recipient_balance = storage.balances.at(to).read();
storage.balances.at(to).write(recipient_balance + amount);
}
This function:
- Verifies the caller is the owner
- Reads the recipient's current balance
- Adds the minted amount to their balance
Note that you access the user value with the Map
via .at
, and like the above, the explicit use of .write()
to interact with the PublicMutable
.
Enable transfers
Enable token transfers between users:
#[public]
fn transfer(to: AztecAddress, amount: u128) {
let sender = context.msg_sender();
let sender_balance = storage.balances.at(sender).read();
assert(sender_balance >= amount, "Insufficient balance");
storage.balances.at(sender).write(sender_balance - amount);
let recipient_balance = storage.balances.at(to).read();
storage.balances.at(to).write(recipient_balance + amount);
}
This function:
- Reads the sender's current balance
- Verifies that the balance is greater than the amount being sent
- Sets the sender's balance to their current balance minus the amount being sent
- Reads the recipient's current balance
- Adds the transferred amount to their balance
Transfer ownership
Add a function to change the contract owner:
#[public]
fn transfer_ownership(new_owner: AztecAddress) {
assert_eq(context.msg_sender(), storage.owner.read());
storage.owner.write(new_owner);
}
This function:
- Verifies the caller is the owner
- Writes the new owner in contract storage
Checkpoint: You now have a basic public token contract with mint, transfer, and ownership functions. Next, add private functionality.
Part 2: Adding private state
Private state primer
Private state in Aztec uses a UTXO model with "notes" rather than account balances. In this token contract, each note represents a discrete amount of tokens owned by a specific address. When you transfer tokens privately, you create new notes for the recipient rather than updating their balance directly (since their balance is private and unknown to you). A user's total token balance is the sum of all their unspent notes, which only they can see and spend.
Update storage
As setup, first import easy_private_state
by adding it to your [dependencies]
section in your Nargo.toml
.
[dependencies]
aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v3.0.0-nightly.20250908", directory = "noir-projects/aztec-nr/aztec" }
easy_private_state = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v3.0.0-nightly.20250908", directory = "noir-projects/aztec-nr/easy-easy_private-state" }
Then import EasyPrivateUint
at the top of your contract:
use aztec::macros::aztec;
pub contract StarterToken {
...
use easy_private_state::EasyPrivateUint;
...
}
EasyPrivateUint is a wrapper of PrivateSet
that enables the storing of collections of u128 values in private state. It handles the complexity of using notes and PrivateSet
directly. It allows anyone to add a note to a user's accumulated balance, while only letting the recipient view or spend them.
Add private balances alongside public ones:
#[storage]
struct Storage<Context> {
// Public state
balances: Map<AztecAddress, PublicMutable<u128, Context>, Context>,
owner: PublicMutable<AztecAddress, Context>,
// Private state
private_balances: Map<AztecAddress, EasyPrivateUint<Context>, Context>
}
Use PrivateSet
(or its wrappers) instead of PrivateMutable
for private balances. Since private state works by accumulating notes rather than updating a single value, PrivateSet
allows multiple parties to add notes to someone's balance without needing to know their current total.
Mint private tokens
Create a function to mint private tokens:
#[private]
fn mint_private(to: AztecAddress, amount: u128) {
storage.private_balances.at(to).add(value, to);
}
Like the #[public]
macro, the #[private]
macro exposes the storage variable, but instead of providing a PublicContext
, it provides a PrivateContext
.
This function:
- Creates a new note with the specified value
- Inserts it into the recipient's note set
- Emits an encrypted log so the recipient can discover the note
Important: At this point, the contract allows anyone to mint private tokens. Part 4 shows you how to add access control.
Transfer private tokens
Transferring private tokens requires three steps:
Step 1: Spend sender's notes
fn transfer_private(to: AztecAddress, amount: u128) {
let sender = context.msg_sender();
storage.private_balances.at(sender).sub(amount, sender);
This code fetches all notes of the sender and verifies they sum to at least the transfer amount. It then spends those notes to remove them from the sender's balance.
Step 2: Create recipient's note
storage.private_balances.at(to).add(amount, to);
This mirrors the mint
example above.
Your full function should look like this:
#[private]
fn transfer_private(to: AztecAddress, amount: u128) {
let sender = context.msg_sender();
storage.private_balances.at(sender).sub(amount, sender);
storage.private_balances.at(to).add(amount, to);
}
View private balances
Add a utility function to check private balances locally:
#[utility]
unconstrained fn view_private_balance(owner: AztecAddress) -> BoundedVec<UintNote, MAX_NOTES_PER_PAGE> {
storage.user_private_state.at(key: owner).view_notes(NoteViewerOptions::new())
}
The #[utility]
macro indicates local-only execution without generating proofs nor altering any network / global state. This function fetches unspent notes from your local state.
Part 3: Execution environments
Aztec has three execution environments:
- Private: Executes locally on user devices with historical state. All transactions start here.
- Public: Executes on sequencers with current state (similar to Ethereum).
- Utility: Local queries that don't affect network state or require proofs.
Key concept: Separation of concerns
Private and public execution happen separately:
- Private functions can't read current public state (but they can read historical public state)
- Public functions can't read private state
- Private execution uses historical data and happens first
The main side effect is a decoupling between private and public state, and any transaction that accesses private and public state must take this into account. This separation is fundamental to maintaining privacy while enabling composability.
Part 4: Cross-domain interactions
The challenge
The private mint function lacks access control (anyone can mint). But the owner is stored in public state, which private functions can't access directly.
The solution
You cannot use PrivateMutable
for ownership that others need to verify. While it might seem logical to declare a PrivateMutable private_owner
, this won't work because private notes can only be read by their owner. When someone else tries to check who owns the contract, they would be unable to fetch the private note containing this information, causing the check to fail. For ownership information that needs to be publicly verifiable, you must use public state.
To enforce ownership checks in private functions, you can enqueue a public function call that executes after the private portion completes. This public function can access the public owner state variable to validate ownership. If the validation fails, the entire transaction reverts, including the private operations. Here's how to implement this pattern:
#[public]
#[internal]
fn assert_is_owner(maybe_owner: AztecAddress) {
assert_eq(maybe_owner, storage.owner.read());
}
The #[internal]
macro restricts this function to internal calls only.
Now update the private mint to enqueue this check:
#[private]
fn mint_private(to: AztecAddress, amount: u128) {
// Enqueue public validation
GettingStarted::at(context.this_address())._assert_is_owner(context.msg_sender()).enqueue(&mut context);
// Proceed with minting
storage.private_balances.at(to)
.insert(UintNote::new(value, to));
.emit(encode_and_encrypt_note(&mut context, to));
}
If the public validation fails, the entire transaction reverts, even though the private proof was valid. This common pattern enables access control across execution domains.