Overview
The Token example demonstrates how to build a custom token with both public (transparent) and private (shielded) functionality. This is one of the most comprehensive examples, showcasing records, mappings, and finalizers.
This example is located at .circleci/token/ in the Leo repository and is actively tested in CI.
Program Structure
program token.aleo {
mapping account: address => u64;
record token {
owner: address,
amount: u64,
}
// Mint functions
// Transfer functions
// Finalizers
}
Data Structures
Mapping: Public Balances
Public token balances are stored on-chain in a mapping:
mapping account: address => u64;
Properties:
Publicly visible on the blockchain
Accessed in finalizer functions
Key: address (account owner)
Value: u64 (token balance)
Record: Private Tokens
Private tokens are stored as records:
record token {
owner: address, // The token owner
amount: u64, // The token amount
}
Properties:
Private by default (encrypted on-chain)
Owner controls spending
Can be split and combined
Zero-knowledge proofs hide amounts
Minting Functions
Public Mint
Mint tokens directly to a public balance:
fn mint_public(public receiver: address, public amount: u64) -> Final {
return final { finalize_mint_public(receiver, amount); };
}
Finalizer:
final fn finalize_mint_public(public receiver: address, public amount: u64) {
let current_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, current_amount + amount);
}
How it works:
Function receives receiver address and amount (both public)
Returns a finalizer that executes on-chain
Finalizer reads current balance (or 0 if none exists)
Adds the minted amount to the balance
Stores the updated balance on-chain
Usage:
leo run mint_public aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 1000u64
Private Mint
Mint tokens as a private record:
fn mint_private(receiver: address, amount: u64) -> token {
return token {
owner: receiver,
amount: amount,
};
}
How it works:
Creates a new token record
Sets the owner to the receiver
Sets the amount
Returns the record (encrypted on-chain)
Key Difference:
No finalizer needed (no on-chain state update)
Record is returned to the receiver
Amount remains private
Usage:
leo run mint_private aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 1000u64
Transfer Functions
Public Transfer
Transfer tokens between public balances:
fn transfer_public(public receiver: address, public amount: u64) -> Final {
return final { finalize_transfer_public(self.caller, receiver, amount); };
}
Finalizer:
final fn finalize_transfer_public(
public sender: address,
public receiver: address,
public amount: u64
) {
// Deduct from sender
let sender_amount: u64 = Mapping::get_or_use(account, sender, 0u64);
Mapping::set(account, sender, sender_amount - amount);
// Add to receiver
let receiver_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, receiver_amount + amount);
}
How it works:
Uses self.caller to identify the sender
Finalizer decrements sender’s balance
Finalizer increments receiver’s balance
Transaction fails if sender has insufficient balance
Usage:
leo run transfer_public aleo1receiver... 100u64
Private Transfer
Transfer tokens using private records:
fn transfer_private(
sender: token,
receiver: address,
amount: u64
) -> (token, token) {
// Calculate change
let difference: u64 = sender.amount - amount;
// Create change record for sender
let remaining: token = token {
owner: sender.owner,
amount: difference,
};
// Create new record for receiver
let transferred: token = token {
owner: receiver,
amount: amount,
};
return (remaining, transferred);
}
How it works:
Consumes the sender’s token record
Calculates the change amount
Creates a new record for the sender with the remaining balance
Creates a new record for the receiver with the transferred amount
Returns both records
Key Features:
Completely private (amounts hidden)
No on-chain state updates
Proof verifies sender has sufficient balance
Fails if sender.amount < amount (underflow protection)
Usage:
leo run transfer_private "{ owner: aleo1..., amount: 1000u64 }" aleo1receiver... 100u64
Private to Public Transfer
Convert private tokens to public balance:
fn transfer_private_to_public(
sender: token,
public receiver: address,
public amount: u64
) -> (token, Final) {
let difference: u64 = sender.amount - amount;
let remaining: token = token {
owner: sender.owner,
amount: difference,
};
return (remaining, final { finalize_transfer_private_to_public(receiver, amount); });
}
Finalizer:
final fn finalize_transfer_private_to_public(
public receiver: address,
public amount: u64
) {
let current_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, current_amount + amount);
}
How it works:
Consumes private record
Creates change record for sender (private)
Finalizer adds amount to receiver’s public balance
Receiver address and amount become public
Privacy Trade-offs:
Sender’s identity remains private
Receiver address is public
Amount is public
Public to Private Transfer
Convert public balance to private record:
fn transfer_public_to_private(
public receiver: address,
public amount: u64
) -> (token, Final) {
let transferred: token = token {
owner: receiver,
amount: amount,
};
return (transferred, final { finalize_transfer_public_to_private(self.caller, amount); });
}
Finalizer:
final fn finalize_transfer_public_to_private(
public sender: address,
public amount: u64
) {
let current_amount: u64 = Mapping::get_or_use(account, sender, 0u64);
Mapping::set(account, sender, current_amount - amount);
}
How it works:
Creates private record for receiver
Finalizer deducts amount from sender’s public balance
Receiver gets private record
Privacy Trade-offs:
Sender address is public (self.caller)
Receiver address is public
Amount is public
Resulting record is private
Running the Example
Build the Program
cd .circleci/token
leo build
Run Functions
# Mint public tokens
leo run mint_public aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 1000u64
# Mint private tokens
leo run mint_private aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9 1000u64
# Transfer public tokens
leo run transfer_public aleo1receiver... 100u64
# Transfer private tokens
leo run transfer_private "{owner: aleo1..., amount: 1000u64}" aleo1receiver... 100u64
Use the Demo Script
Key Concepts
Records vs Mappings
Feature Records Mappings Privacy Private (encrypted) Public (transparent) Storage Off-chain (user holds) On-chain (global state) Access Owner only Anyone can read Cost Lower (no state updates) Higher (state updates)
When to Use Public vs Private
Use Public (Mappings) when:
Transparency is required
Regulatory compliance needs
Public audit trails
Simpler user experience
Use Private (Records) when:
Privacy is paramount
Hiding transaction amounts
Competitive advantages
User preference for privacy
Finalizers
Finalizers execute on-chain after the main function:
Main function generates a proof
Proof is verified
Finalizer executes on-chain
Finalizer can access and modify mappings
Finalizer can use block.height and other on-chain data
Security Considerations
Overflow Protection
let current_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, current_amount + amount);
Addition can overflow! If current_amount + amount > u64::MAX, the transaction fails.
Underflow Protection
let difference: u64 = sender.amount - amount;
Subtraction can underflow! If sender.amount < amount, the proof generation fails.
Authorization
The self.caller value identifies the transaction initiator:
finalize_transfer_public(self.caller, receiver, amount);
This ensures only the account owner can transfer their tokens.
Testing
Unit Tests
Create test cases in inputs/token.in:
[mint_public]
receiver: address = aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9;
amount: u64 = 1000u64;
[transfer_private]
sender: token = {owner: aleo1..., amount: 1000u64};
receiver: address = aleo1receiver...;
amount: u64 = 100u64;
Run Tests
Extensions and Improvements
Add Total Supply Tracking
mapping total_supply: u8 => u64;
final fn finalize_mint_public(public receiver: address, public amount: u64) {
// Update balance
let current_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, current_amount + amount);
// Update total supply
let supply: u64 = Mapping::get_or_use(total_supply, 0u8, 0u64);
Mapping::set(total_supply, 0u8, supply + amount);
}
Add Burn Functionality
fn burn_public(public amount: u64) -> Final {
return final { finalize_burn_public(self.caller, amount); };
}
final fn finalize_burn_public(public owner: address, public amount: u64) {
let current_amount: u64 = Mapping::get_or_use(account, owner, 0u64);
Mapping::set(account, owner, current_amount - amount);
}
Add Transfer Limits
final fn finalize_transfer_public(
public sender: address,
public receiver: address,
public amount: u64
) {
assert(amount <= 10000u64); // Max transfer limit
// ... rest of transfer logic
}
Lottery Simpler example with randomness
Tic-Tac-Toe Game logic with structs
Further Reading
Records Learn more about records
Mappings Deep dive into mappings
Finalize Understanding finalize blocks
Built-in Types Type reference