Private Token Contract
The Privacy Challenge: Mental Health Benefits at Giggle
Giggle (a fictional tech company) wants to support their employees' mental health by providing BOB tokens that can be spent at Bob's Psychology Clinic. However, employees have a crucial requirement: complete privacy. They don't want Giggle to know:
- How many BOB tokens they've actually used
- When they're using mental health services
- Their therapy patterns or frequency
In this tutorial, we'll build a token contract that allows Giggle to mint BOB tokens for employees while ensuring complete privacy in how those tokens are spent.
Prerequisites
This is an intermediate tutorial that assumes you have:
- Completed the Counter Contract tutorial
- A running Aztec Sandbox (see the Counter tutorial for setup)
- Basic understanding of Aztec.nr syntax and structure
- Aztec toolchain installed (
aztec-up -v 3.0.0-nightly.20250919
)
If you haven't completed the Counter Contract tutorial, please do so first as we'll skip the basic setup steps covered there.
What We're Building
We'll create BOB tokens with:
- Public and Private minting: Giggle can mint tokens in private or public
- Public and Private transfers: Employees can spend tokens at Bob's clinic with full privacy
Project Setup
Let's create a simple yarn + aztec.nr project:
yarn init
yarn add @aztec/aztec.js@v3.0.0-nightly.20250919 @aztec/accounts@v3.0.0-nightly.20250919
aztec-nargo init --contract
Contract structure
We have a messy, but working structure. In src/main.nr
we even have a proto-contract. Let's replace it with a simple starting point:
use aztec::macros::aztec;
#[aztec]
pub contract BobToken {
// We'll build the mental health token here
}
The #[aztec]
macro transforms our contract code to work with Aztec's privacy protocol. We'll rename it from StarterToken
to BobToken
to reflect our use case.
Let's import the Aztec.nr library by adding it to our dependencies in Nargo.toml
:
[package]
name = "bob_token_contract"
type = "contract"
[dependencies]
aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v3.0.0-nightly.20250919", directory = "noir-projects/aztec-nr/aztec" }
Since we're here, let's import more specific stuff from this library:
pub contract BobToken {
use aztec::{
macros::{functions::{initializer, private, public, utility, internal}, storage::storage},
protocol_types::{address::AztecAddress, traits::ToField},
state_vars::Map,
state_vars::public_mutable::PublicMutable,
};
}
These are the different macros we need to define the visibility of functions, and some handy types and functions.
Building the Mental Health Token System
The Privacy Architecture
Before we start coding, let's understand how privacy works in our mental health token system:
- Public Layer: Giggle mints tokens publicly - transparent and auditable
- Private Layer: Employees transfer and spend tokens privately - completely confidential
- Cross-layer Transfer: Employees can move tokens between public and private domains as needed
This architecture ensures that while the initial allocation is transparent (important for corporate governance), the actual usage remains completely private.
In Aztec, private state uses a UTXO model with "notes" - think of them as encrypted receipts that only the owner can decrypt and spend. When an employee receives BOB tokens privately, they get encrypted notes that only they can see and use.
Let's start building! Remember to import types as needed - your IDE's Noir extension can help with auto-imports.
Part 1: Public Minting for Transparency
Let's start with the public components that Giggle will use to mint and track initial token allocations.
Setting Up Storage
First, define the storage for our BOB tokens:
#[storage]
struct Storage<Context> {
// Giggle's admin address
owner: PublicMutable<AztecAddress, Context>,
// Public balances - visible for transparency during minting
public_balances: Map<AztecAddress, PublicMutable<u64, Context>, Context>,
}
This storage structure allows:
owner
: Stores Giggle's admin address (who can mint tokens)public_balances
: Tracks public token balances (employees can verify their allocations)
While employees want privacy when spending, having public balances during minting allows:
- Employees to verify they received their mental health benefits
- Auditors to confirm fair distribution
- Transparency in the allocation process
Initializing Giggle as Owner
When deploying the contract, we need to set Giggle as the owner:
#[initializer]
#[public]
fn setup() {
// Giggle becomes the owner who can mint mental health tokens
storage.owner.write(context.msg_sender());
}
The #[initializer]
decorator ensures this runs once during deployment. Only Giggle's address will have the power to mint new BOB tokens for employees.
Minting BOB Tokens for Employees
Giggle needs a way to allocate mental health tokens to employees:
#[public]
fn mint_public(employee: AztecAddress, amount: u64) {
// Only Giggle can mint tokens
assert_eq(context.msg_sender(), storage.owner.read(), "Only Giggle can mint BOB tokens");
// Add tokens to employee's public balance
let current_balance = storage.public_balances.at(employee).read();
storage.public_balances.at(employee).write(current_balance + amount);
}
This public minting function:
- Verifies that only Giggle (the owner) is calling
- Transparently adds tokens to the employee's public balance
- Creates an auditable record of the allocation
Imagine Giggle allocating 100 BOB tokens to each employee at the start of the year. This public minting ensures employees can verify they received their benefits, while their actual usage remains private.
Public Transfers (Optional Transparency)
While most transfers will be private, we'll add public transfers for cases where transparency is desired:
#[public]
fn transfer_public(to: AztecAddress, amount: u64) {
let sender = context.msg_sender();
let sender_balance = storage.public_balances.at(sender).read();
assert(sender_balance >= amount, "Insufficient BOB tokens");
// Deduct from sender
storage.public_balances.at(sender).write(sender_balance - amount);
// Add to recipient
let recipient_balance = storage.public_balances.at(to).read();
storage.public_balances.at(to).write(recipient_balance + amount);
}
This might be used when:
- An employee transfers tokens to a colleague who's comfortable with transparency
- Bob's clinic makes a public refund
- Any scenario where privacy isn't required
Admin Transfer (Future-Proofing)
In case Giggle's mental health program administration changes:
#[public]
fn transfer_ownership(new_owner: AztecAddress) {
assert_eq(context.msg_sender(), storage.owner.read(), "Only current admin can transfer ownership");
storage.owner.write(new_owner);
}
Your First Deployment - Let's See It Work
Compile Your Contract
You've written enough code to have a working token! Let's compile and test it:
aztec-nargo compile
aztec-postprocess-contract
Generate TypeScript Interface
aztec codegen target --outdir artifacts
You should now have a nice typescript interface in a new artifacts
folder. Pretty useful!
Deploy and Test
Create index.ts
. We will connect to our running sandbox and its PXE, then deploy the test accounts and get three wallets out of it.
Then we will use the giggleWallet
to deploy our contract, mint 100 BOB to Alice, then transfer 10 of those to Bob's Clinic publicly... for now. Let's go:
import { BobTokenContract } from './artifacts/BobToken.js';
import { createPXEClient, waitForPXE, Wallet } from '@aztec/aztec.js';
import { getInitialTestAccountsWallets } from '@aztec/accounts/testing';
async function main() {
// Connect to sandbox
const pxe = createPXEClient('http://localhost:8080');
await waitForPXE(pxe);
// Get test accounts
const wallets = await getInitialTestAccountsWallets(pxe);
const giggleWallet = wallets[0]; // Giggle's admin wallet
const aliceWallet = wallets[1]; // Employee Alice
const bobClinicWallet = wallets[2]; // Bob's Psychology Clinic
const bobToken = await BobTokenContract
.deploy(giggleWallet)
.send({ from: giggleWallet.address })
.deployed();
await bobToken.withWallet(giggleWallet).methods
.mint_public(aliceWallet.address, 100n)
.send({ from: giggleWallet.address })
.wait();
await bobToken.withWallet(aliceWallet).methods
.transfer_public(bobClinicWallet.address, 10n)
.send({ from: aliceWallet.address })
.wait();
}
main();
Run your test:
npx tsx index.ts
What's this tsx
dark magic? Well, it just compiles and runs typescript using reasonable defaults. Pretty cool for small snippets like this!
🎉 Celebrate
Congratulations! You've just deployed a working token contract on Aztec! You can:
- ✅ Mint BOB tokens as Giggle
- ✅ Transfer tokens between employees
- ✅ Track balances publicly
But there's a problem... Giggle can see everything! They know:
- Who's transferring tokens
- How much is being spent
- When mental health services are being used
This defeats the whole purpose of our mental health privacy initiative. Let's fix this by adding private functionality!
Part 2: Adding Privacy - The Real Magic Begins
Now let's add the privacy features that make our mental health benefits truly confidential.
Understanding Private Notes
Here's where Aztec's privacy magic happens. Unlike public balances (a single number), private balances are collections of encrypted "notes". Think of it this way:
- Public balance: "Alice has 100 BOB tokens" (visible to everyone)
- Private balance: Alice has encrypted notes [Note1: 30 BOB, Note2: 50 BOB, Note3: 20 BOB] that only she can decrypt
When Alice spends 40 BOB tokens at Bob's clinic:
- She consumes Note1 (30 BOB) and Note2 (50 BOB) = 80 BOB total
- She creates a new note for Bob's clinic (40 BOB)
- She creates a "change" note for herself (40 BOB)
- The consumed notes are nullified (marked as spent)
In this case, all that the network sees (including Giggle) is just "something happening to some state in some contract". How cool is that?
Updating Storage for Privacy
For something like balances, you can use a simple library called easy_private_state
which abstracts away a custom private Note. A Note is at the core of how private state works in Aztec and you can read about it here. For now, let's just import the library in Nargo.toml
:
[dependencies]
easy_private_state = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v3.0.0-nightly.20250919", directory = "noir-projects/aztec-nr/easy-private-state" }
Then import EasyPrivateUint
in our contract:
use aztec::macros::aztec;
pub contract BobToken {
// ... other imports
use easy_private_state::EasyPrivateUint;
// ...
}
We need to update the contract storage to have private balances as well:
#[storage]
struct Storage<Context> {
// Giggle's admin address
owner: PublicMutable<AztecAddress, Context>,
// Public balances - visible for transparency
public_balances: Map<AztecAddress, PublicMutable<u64, Context>, Context>,
// Private balances - only the owner can see these
private_balances: Map<AztecAddress, EasyPrivateUint<Context>, Context>,
}
The private_balances
use EasyPrivateUint
which manages encrypted notes automatically.
Moving Tokens to Privateland
Great, now our contract knows about private balances. Let's implement a method to allow users to move their publicly minted tokens there:
#[private]
fn public_to_private(amount: u64) {
let sender = context.msg_sender();
// This will enqueue a public function to deduct from public balance
BobToken::at(context.this_address())._deduct_public_balance(sender, amount).enqueue(&mut context);
// Add to private balance
storage.private_balances.at(sender).add(amount, sender);
}
And the helper function:
#[public]
#[internal]
fn _deduct_public_balance(owner: AztecAddress, amount: u64) {
let balance = storage.public_balances.at(owner).read();
assert(balance >= amount, "Insufficient public BOB tokens");
storage.public_balances.at(owner).write(balance - amount);
}
By calling public_to_private
we're telling the network "deduct this amount from my balance" while simultaneously creating a Note with that balance in privateland.
Private Transfers
Now for the crucial privacy feature - transferring BOB tokens in privacy. This is actually pretty simple:
#[private]
fn transfer_private(to: AztecAddress, amount: u64) {
let sender = context.msg_sender();
// Spend sender's notes (consumes existing notes)
storage.private_balances.at(sender).sub(amount, sender);
// Create new notes for recipient
storage.private_balances.at(to).add(amount, to);
}
This function simply nullifies the sender's notes, while adding them to the recipient.
When an employee uses 50 BOB tokens at Bob's clinic, this private transfer ensures Giggle has no visibility into:
- The fact that the employee is seeking mental health services
- The frequency of visits
- The amount spent on treatment
Checking Balances
Employees can check their BOB token balances without hitting the network by using utility unconstrained functions:
#[utility]
unconstrained fn private_balance_of(owner: AztecAddress) -> Field {
storage.private_balances.at(owner).get_value()
}
#[utility]
unconstrained fn public_balance_of(owner: AztecAddress) -> pub u64 {
storage.public_balances.at(owner).read()
}
Part 3: Securing Private Minting
Let's make this a little bit harder, and more interesting. Let's say Giggle doesn't want to mint the tokens in public. Can we have private minting on Aztec?
Sure we can. Let's see.
Understanding Execution Domains
Our BOB token system operates in two domains:
- Public Domain: Where Giggle mints tokens transparently
- Private Domain: Where employees spend tokens confidentially
The key challenge: How do we ensure only Giggle can mint tokens when the minting happens in a private function?
Private functions can't directly read current public state (like who the owner is). They can only read historical public state or enqueue public function calls for validation.
The Access Control Challenge
We want Giggle to mint BOB tokens directly to employees' private balances (for maximum privacy), but we need to ensure only Giggle can do this. The challenge: ownership is stored publicly, but private functions can't read current public state.
Let's use a clever pattern where private functions enqueue public validation checks. First we make a little helper function in public. Remember, public functions always run after private functions, since private functions run client-side.
#[public]
#[internal]
fn _assert_is_owner(address: AztecAddress) {
assert_eq(address, storage.owner.read(), "Only Giggle can mint BOB tokens");
}
Now we can add a secure private minting function. It looks pretty easy, and it is, since the whole thing will revert if the public function fails:
#[private]
fn mint_private(employee: AztecAddress, amount: u64) {
// Enqueue ownership check (will revert if not Giggle)
BobToken::at(context.this_address())
._assert_is_owner(context.msg_sender())
.enqueue(&mut context);
// If check passes, mint tokens privately
storage.private_balances.at(employee).add(amount, employee);
}
This pattern ensures:
- The private minting executes first (creating the proof)
- The public ownership check executes after
- If the check fails, the entire transaction (including the private part) reverts
- Only Giggle can successfully mint BOB tokens
Part 4: Converting Back to Public
For the sake of completeness, let's also have a function that brings the tokens back to publicland:
#[private]
fn private_to_public(amount: u64) {
let sender = context.msg_sender();
// Remove from private balance
storage.private_balances.at(sender).sub(amount, sender);
// Enqueue public credit
BobToken::at(context.this_address())
._credit_public_balance(sender, amount)
.enqueue(&mut context);
}
#[public]
#[internal]
fn _credit_public_balance(owner: AztecAddress, amount: u64) {
let balance = storage.public_balances.at(owner).read();
storage.public_balances.at(owner).write(balance + amount);
}
Testing the Complete Privacy System
Now that you've implemented all the privacy features, let's update our test script to showcase the full privacy flow:
Update Your Test Script
Let's stop being lazy and add a nice little "log" function that just spits out everyone's balances to the console, for example:
// at the top of your file
async function getBalances(contract: BobTokenContract, wallets: Wallet[]) {
Promise.all([
contract.withWallet(wallets[1]).methods
.public_balance_of(wallets[1].address)
.simulate({ from: wallets[1].address }),
contract.withWallet(wallets[1]).methods
.private_balance_of(wallets[1].address)
.simulate({ from: wallets[1].address }),
contract.withWallet(wallets[2]).methods
.public_balance_of(wallets[2].address)
.simulate({ from: wallets[2].address }),
contract.withWallet(wallets[2]).methods
.private_balance_of(wallets[2].address)
.simulate({ from: wallets[2].address })
]).then(([alicePublicBalance, alicePrivateBalance, bobPublicBalance, bobPrivateBalance]) => {
console.log(`📊 Alice has ${alicePublicBalance} public BOB tokens and ${alicePrivateBalance} private BOB tokens`);
console.log(`📊 Bob's Clinic has ${bobPublicBalance} public BOB tokens and ${bobPrivateBalance} private BOB tokens`);
});
}
Looks ugly but it does what it says: prints Alice's and Bob's balances. This will make it easier to see our contract working.
Now let's add some more stuff to our index.ts
:
async function main() {
// ...etc
await bobToken.withWallet(giggleWallet).methods
.mint_public(aliceWallet.address, 100n)
.send({ from: giggleWallet.address })
.wait();
await getBalances(bobToken, wallets);
await bobToken.withWallet(aliceWallet).methods
.transfer_public(bobClinicWallet.address, 10n)
.send({ from: aliceWallet.address })
.wait();
await getBalances(bobToken, wallets);
await bobToken.withWallet(aliceWallet).methods
.public_to_private(90n)
.send({ from: aliceWallet.address })
.wait();
await getBalances(bobToken, wallets);
await bobToken.withWallet(aliceWallet).methods
.transfer_private(bobClinicWallet.address, 50n)
.send({ from: aliceWallet.address })
.wait();
await getBalances(bobToken, wallets);
await bobToken.withWallet(aliceWallet).methods
.private_to_public(10n)
.send({ from: aliceWallet.address })
.wait();
await getBalances(bobToken, wallets);
await bobToken.withWallet(giggleWallet).methods
.mint_private(aliceWallet.address, 100n)
.send({ from: giggleWallet.address })
.wait();
await getBalances(bobToken, wallets);
}
main().catch(console.error);
The flow is something like:
- Giggle mints Alice 100 BOB in public
- Alice transfers 10 BOB to Bob in public
- Alice makes the remaining 90 BOB private
- Alice transfers 50 of those to Bob, in private
- Of the remaining 40 BOB, she makes 10 public again
- Giggle mints 100 BOB tokens for Alice, in private
Let's give it a try:
npx tsx index.ts
You should see the complete privacy journey from transparent allocation to confidential usage!
Summary
You've built a privacy-preserving token system that solves a real-world problem: enabling corporate mental health benefits while protecting employee privacy. This demonstrates Aztec's unique ability to provide both transparency and privacy where each is most needed.
The BOB token shows how blockchain can enable new models of corporate benefits that weren't possible before - where verification and privacy coexist, empowering employees to seek help without fear of judgment or career impact.
What You Learned
- How to create tokens with both public and private states
- How to bridge between public and private domains
- How to implement access control across execution contexts
- How to build real-world privacy solutions on Aztec
Continue Your Journey
- Explore cross-chain communication to integrate with existing health systems
- Learn about account abstraction for recovery mechanisms