Skip to main content
Version: v3.0.0-nightly.20250919

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:

  1. Public Layer: Giggle mints tokens publicly - transparent and auditable
  2. Private Layer: Employees transfer and spend tokens privately - completely confidential
  3. 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.

Privacy Note

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)
Why Public Balances?

While employees want privacy when spending, having public balances during minting allows:

  1. Employees to verify they received their mental health benefits
  2. Auditors to confirm fair distribution
  3. 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:

  1. Verifies that only Giggle (the owner) is calling
  2. Transparently adds tokens to the employee's public balance
  3. Creates an auditable record of the allocation
Real-World Scenario

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
tip

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:

  1. She consumes Note1 (30 BOB) and Note2 (50 BOB) = 80 BOB total
  2. She creates a new note for Bob's clinic (40 BOB)
  3. She creates a "change" note for herself (40 BOB)
  4. 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.

Real-World Impact

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:

  1. Public Domain: Where Giggle mints tokens transparently
  2. 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?

Privacy Trade-off

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:

  1. The private minting executes first (creating the proof)
  2. The public ownership check executes after
  3. If the check fails, the entire transaction (including the private part) reverts
  4. 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