Leo Smart Contract Development — Complete Reference
1. Overview
This skill teaches you everything needed to write, compile, and reason about Leo programs for the Aleo blockchain. Leo is a statically-typed, Rust-inspired language that compiles to zero-knowledge circuits (R1CS constraints). Unlike Solidity or traditional smart contracts, Leo programs execute off-chain on the user's machine to generate a cryptographic proof, which is then verified on-chain by validators.
2. Version & Canonical Syntax
- Target: Leo compiler >= 3.5.0
- Canonical syntax: This skill uses the Leo 3.5.0 keywords:
transition— entry-point functions (off-chain, generates ZK proof)async transition— entry-point functions that also have on-chain finalize logic; returnsFutureasync function— on-chain finalize functions (namedfinalize_<transition_name>by convention)inline— helper functions defined inside theprogramblock, automatically inlined by the compiler
- Deployable programs require constructors: define
async constructor() {}with exactly one annotation policy (@noupgrade,@admin,@checksum, or@custom) - There are NO free functions outside the
programblock — useinlinefunctions inside the program block instead - When official docs disagree: Follow the Leo GitHub repository and this skill as canonical
3. Key Concepts & Glossary
- Program: The deployment unit on Aleo (analogous to a smart contract). Declared as
program name.aleo { }. Every program has a unique program ID (e.g.,my_token.aleo). - Record: An encrypted, UTXO-like private state object. Records must have an
owner: addressfield. Records are consumed (destroyed) when used as inputs and created as new outputs — they are never mutated in place. Only the owner can decrypt a record using their view key. Records cannot contain other records. - Struct: A composite data type similar to a Rust struct. Structs are transparent (not encrypted) and can be used in both transitions and finalize blocks. Structs cannot contain records.
- Mapping: A public, on-chain key-value store. Mappings can ONLY be read/written inside
async function(finalize) blocks. Mapping values are globally visible to everyone. - Storage Variable: A public singleton value stored on-chain (
storage counter: u64;). Only accessible inasync function(finalize) blocks. - Storage Vector: A public dynamic-length array stored on-chain (
storage items: [u64];). Only accessible inasync function(finalize) blocks. - Finalize / async function: A separate
async function finalize_NAME(...)block defines on-chain logic that runs AFTER the zero-knowledge proof is verified. This is the ONLY place where mappings, storage variables, storage vectors,block.height,block.timestamp,ChaCha::rand_*,snark.verify, andsnark.verify_batchare available. Values needed in finalize must be passed explicitly as arguments from theasync transitionviareturn finalize_NAME(arg1, arg2, ...);. - Transition: An entry-point function inside a
programscope. Declared withtransition. When the transition needs on-chain finalize logic, useasync transition ... -> Futureand a pairedasync function finalize_NAME(...). - Future (return type): When an
async transitionhas a pairedasync function, its return type includesFuture. This signals that the transition has on-chain side effects. - Inline function: A helper function declared with
inlineinside theprogramblock. Automatically inlined by the compiler, cannot be called externally. - Serial Number / Nullifier: When a record is consumed, a unique serial number (nullifier) is published on-chain to prevent double-spending, without revealing which record was consumed.
- self.caller: The address of the immediate caller. If Program A calls Program B, then inside Program B,
self.calleris Program A's address. - self.signer: The address of the original transaction signer (always a user's address, never a program). In cross-program calls,
self.signeris the human who initiated the top-level transaction. - Microcredits: The smallest denomination of Aleo credits. 1 credit = 1,000,000 microcredits.
4. The Dual Execution Model
This is the single most important concept in Leo development. Understanding it prevents the most common class of errors.
┌─────────────────────────────────────────────────────────────┐
│ USER'S MACHINE (Off-Chain) │
│ │
│ 1. User calls a function with private/public inputs │
│ 2. Function body executes locally │
│ 3. Records are consumed (inputs) and created (outputs) │
│ 4. A zero-knowledge proof is generated │
│ 5. Proof + encrypted outputs are sent to the network │
│ │
│ Available here: self.caller, self.signer, record ops, │
│ all computation, struct manipulation │
│ NOT available: block.height, ChaCha::rand_*, mappings │
└─────────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ VALIDATORS (On-Chain) │
│ │
│ 6. Validators verify the proof (fast, no re-execution) │
│ 7. If the transition has a paired `async function` │
│ (finalize), validators execute it on-chain to update │
│ public state │
│ 8. If finalize fails, the ENTIRE transaction is rejected │
│ (but the fee is still consumed) │
│ │
│ Available here: Mapping::get/set/remove/contains, │
│ storage vars/vecs, block.height, │
│ block.timestamp, ChaCha::rand_*, │
│ snark.verify, snark.verify_batch │
│ NOT available: private inputs, records, direct self.caller │
│ (must be passed as args from transition) │
└─────────────────────────────────────────────────────────────┘
Critical rule: Mapping operations (Mapping::get, Mapping::set, Mapping::remove, Mapping::contains) are ONLY allowed inside async function (finalize) blocks. Attempting to use them in the transition body will cause a compiler error.
Critical rule: Values from the transition body that you need in the finalize function must be passed explicitly as arguments via return finalize_NAME(arg1, arg2, ...);. The async function cannot access transition parameters directly. self.caller is only available in the transition body — capture it in a variable and pass it to finalize.
Critical rule: If an async function (finalize) fails (assertion failure, overflow, missing mapping key with get instead of get_or_use), the ENTIRE transaction is rejected. The base fee is still consumed. Use get_or_use instead of get to avoid unexpected failures.
5. Program Structure
Every Leo program follows this structure:
// 1. Imports (optional)
import credits.aleo;
// 2. Program declaration (required)
program my_program.aleo {
@noupgrade
async constructor() {}
// 3. Struct definitions
struct TokenInfo {
name: field,
symbol: field,
decimals: u8,
}
// 4. Record definitions
record Token {
owner: address, // mandatory field
amount: u64,
info: TokenInfo, // records can contain structs
}
// 5. Mapping definitions (public on-chain key-value stores)
mapping balances: address => u64;
mapping total_supply: bool => u64;
// 6. Storage variables (public on-chain singletons)
storage supply: u64;
// 7. Storage vectors (public on-chain dynamic arrays)
storage holders: [address];
// 8. Inline helper functions
inline double(x: u64) -> u64 {
return x * 2u64;
}
// 9. Entry-point transitions (off-chain only)
transition mint_private(receiver: address, amount: u64) -> Token {
return Token {
owner: receiver,
amount: double(amount),
info: TokenInfo {
name: 0field,
symbol: 0field,
decimals: 6u8,
},
};
}
// 10. Async transitions (off-chain + on-chain finalize)
async transition mint(public receiver: address, public amount: u64) -> Future {
// Off-chain logic here
let caller: address = self.caller;
return finalize_mint(caller, receiver, amount);
}
// 11. Finalize functions (on-chain logic)
async function finalize_mint(caller: address, receiver: address, amount: u64) {
// On-chain logic here
let current: u64 = Mapping::get_or_use(balances, receiver, 0u64);
Mapping::set(balances, receiver, current + amount);
}
}
Constructor Requirement (Leo 3.5.0)
Deployable and test programs on current networks require a constructor with exactly one policy annotation. Use one of @noupgrade, @admin, @checksum, or @custom immediately above async constructor() {}.
6. Complete Type System
Primitive Types
| Type | Description | Literal Example | Range |
|---|---|---|---|
bool | Boolean | true, false | true/false |
u8 | Unsigned 8-bit integer | 42u8 | 0 to 255 |
u16 | Unsigned 16-bit integer | 1000u16 | 0 to 65,535 |
u32 | Unsigned 32-bit integer | 100000u32 | 0 to 4,294,967,295 |
u64 | Unsigned 64-bit integer | 1000000u64 | 0 to 18,446,744,073,709,551,615 |
u128 | Unsigned 128-bit integer | 340u128 | 0 to 2^128 - 1 |
i8 | Signed 8-bit integer | -42i8 | -128 to 127 |
i16 | Signed 16-bit integer | -1000i16 | -32,768 to 32,767 |
i32 | Signed 32-bit integer | -100000i32 | -2^31 to 2^31 - 1 |
i64 | Signed 64-bit integer | -50i64 | -2^63 to 2^63 - 1 |
i128 | Signed 128-bit integer | -340i128 | -2^127 to 2^127 - 1 |
field | Field element (native ZK type) | 1field | 0 to p-1 (prime) |
group | Elliptic curve point | 0group | Group elements |
scalar | Scalar for group ops | 1scalar | Scalar field |
address | Aleo address | aleo1... | Valid addresses |
signature | Transaction signature | — | Valid signatures |
CRITICAL: Strings do NOT exist in Leo. The compiler will reject any attempt to use string literals. If you need text data, encode it as field elements or [field; N] arrays.
CRITICAL: Dynamic arrays in transition/function values do NOT exist. All in-circuit arrays must have compile-time known sizes: [u32; 8] is valid, Vec<u32> does not exist. (storage items: [T]; vectors are a separate on-chain storage feature.)
Composite Types
Arrays (fixed-size):
let arr: [u32; 4] = [1u32, 2u32, 3u32, 4u32];
let element: u32 = arr[0u32]; // Access by index
let nested: [[u32; 2]; 3] = [[1u32, 2u32], [3u32, 4u32], [5u32, 6u32]];
Maximum array size: 32 elements (but check current compiler limits).
Tuples:
let t: (u32, bool) = (42u32, true);
let first: u32 = t.0;
let second: bool = t.1;
Optional Types (T?):
let some_val: u64? = 42u64; // Has a value
let no_val: u64? = none; // No value
// Unwrap (panics if none)
let val: u64 = some_val.unwrap();
// Unwrap with default (safe)
let val: u64 = no_val.unwrap_or(0u64);
Type Casting
Use the as keyword for explicit type casting. Implicit type promotion does NOT exist.
let x: u32 = 42u32;
let y: u64 = x as u64; // Widening cast (safe)
let z: field = x as field; // Integer to field (safe)
let w: u8 = x as u8; // Narrowing cast (may truncate)
Castable types: All integer types can cast to each other and to field. field can cast to integer types. group and scalar have limited casting.
Arithmetic — Checked vs. Wrapping
Leo uses checked arithmetic by default. Overflow or underflow causes the transaction to fail (not silent wrapping like in C).
let a: u8 = 255u8;
// let b: u8 = a + 1u8; // FAILS: overflow
let b: u8 = a.add_wrapped(1u8); // OK: wraps to 0u8
// Available wrapping variants:
// add_wrapped, sub_wrapped, mul_wrapped, div_wrapped
// pow_wrapped, shl_wrapped, shr_wrapped
Security note: Checked arithmetic is a security feature. Use it by default. Only use _wrapped variants when you explicitly need wrapping behavior (e.g., hash computations).
7. Records — The Core Privacy Primitive
Records are Aleo's UTXO-like encrypted state objects. They are the fundamental mechanism for private data.
Record Lifecycle
CREATE → ENCRYPT → STORE ON-CHAIN → DECRYPT (owner only) → CONSUME → NULLIFY
- A function creates a new record as output
- The record is encrypted to the owner's address
- The encrypted record (ciphertext) is stored on-chain
- Only the owner can decrypt it using their view key
- When used as input to another function, the record is consumed (destroyed)
- A serial number (nullifier) is published to prevent double-spending
- New records are created as outputs
Record Definition and Usage
program token.aleo {
@noupgrade
async constructor() {}
// Record definition — owner field is MANDATORY
record Token {
owner: address, // REQUIRED: who can spend this record
amount: u64,
}
// Mint: creates a new record
transition mint(receiver: address, amount: u64) -> Token {
return Token {
owner: receiver,
amount: amount,
};
}
// Transfer: consumes one record, creates two new records
transition transfer(
sender_token: Token, // This record is CONSUMED (destroyed)
receiver: address,
amount: u64
) -> (Token, Token) { // Two new records are CREATED
let remaining: u64 = sender_token.amount - amount;
// New record for receiver
let receiver_token: Token = Token {
owner: receiver,
amount: amount,
};
// Change back to sender
let change_token: Token = Token {
owner: sender_token.owner,
amount: remaining,
};
return (receiver_token, change_token);
}
}
Record Visibility Annotations
Record fields can have visibility modifiers:
record Token {
owner: address, // Always private (encrypted)
private amount: u64, // Private (encrypted) — default
public token_type: u8, // Public (visible on-chain in transaction)
}
Record Restrictions
- Records MUST have an
owner: addressfield - Records CANNOT contain other records (but CAN contain structs)
- Records CANNOT be used as mapping keys or values
- Records are only available in transition bodies, NOT in
async function(finalize) blocks
8. Mappings — Public On-Chain State
Mappings are public key-value stores that persist on-chain. All mapping values are globally visible.
Mapping Operations
All mapping operations MUST be inside async function (finalize) blocks.
program counter.aleo {
@noupgrade
async constructor() {}
mapping counts: address => u64;
async transition increment(public amount: u64) -> Future {
let caller: address = self.caller;
return finalize_increment(caller, amount);
}
async function finalize_increment(caller: address, amount: u64) {
// Static syntax (preferred)
let current: u64 = Mapping::get_or_use(counts, caller, 0u64);
Mapping::set(counts, caller, current + amount);
// Method syntax (also valid)
// let current: u64 = counts.get_or_use(caller, 0u64);
// counts.set(caller, current + amount);
}
}
Complete Mapping API:
| Operation | Static Syntax | Method Syntax | Description |
|---|---|---|---|
| Get (fails if missing) | Mapping::get(map, key) | map.get(key) | Returns value or fails transaction |
| Get with default | Mapping::get_or_use(map, key, default) | map.get_or_use(key, default) | Returns value or default |
| Set | Mapping::set(map, key, value) | map.set(key, value) | Creates or updates entry |
| Remove | Mapping::remove(map, key) | map.remove(key) | Deletes entry |
| Contains | Mapping::contains(map, key) | map.contains(key) | Returns bool |
CRITICAL: Use get_or_use instead of get whenever possible. If get is called on a missing key, the ENTIRE transaction fails and the fee is still consumed.
CRITICAL: There is no has_key function. Use Mapping::contains.
Mapping Limits
- Maximum 31 mappings per program
- Keys and values must be primitive types, structs, or arrays (NOT records)
9. Storage Variables and Vectors
Storage variables and vectors provide additional public on-chain state options.
Storage Variables (Singletons)
program config.aleo {
@noupgrade
async constructor() {}
storage admin: address;
storage paused: bool;
storage counter: u64;
async transition initialize(public addr: address) -> Future {
return finalize_initialize(addr);
}
async function finalize_initialize(addr: address) {
admin = addr; // Set value
paused = false; // Set value
counter = 0u64; // Set value
}
async transition increment() -> Future {
return finalize_increment();
}
async function finalize_increment() {
let current: u64 = counter.unwrap_or(0u64);
counter = current + 1u64;
// counter = none; // Clear value
}
}
Storage Vectors (Dynamic On-Chain Arrays)
program registry.aleo {
@noupgrade
async constructor() {}
storage members: [address];
async transition add_member(public member: address) -> Future {
return finalize_add_member(member);
}
async function finalize_add_member(member: address) {
members.push(member);
}
async transition remove_last() -> Future {
return finalize_remove_last();
}
async function finalize_remove_last() {
members.pop();
}
async transition get_member(public index: u32) -> Future {
return finalize_get_member(index);
}
async function finalize_get_member(index: u32) {
let member: address? = members.get(index);
// Also available:
// members.set(index, new_value);
// members.len();
// members.clear();
// members.swap_remove(index);
}
}
10. Control Flow
Conditionals
// If/else
if amount > 100u64 {
// ...
} else if amount > 50u64 {
// ...
} else {
// ...
}
// Ternary
let fee: u64 = condition ? 10u64 : 5u64;
PERFORMANCE WARNING: In ZK circuits, BOTH branches of an if/else are ALWAYS evaluated. The result is selected by a multiplexer. This means deep/complex branching doubles, quadruples, etc. the circuit cost. Prefer ternary expressions over deep if/else chains when possible.
Loops
// For loops — bounds MUST be compile-time constants
for i: u8 in 0u8..10u8 {
// Loop body
}
// This is INVALID — variable bounds are forbidden:
// let n: u8 = get_count();
// for i: u8 in 0u8..n { } // COMPILER ERROR
// Workaround: use a fixed upper bound with early termination
for i: u64 in 0u64..100u64 {
if i < actual_count {
// Do work
}
}
PERFORMANCE WARNING: All for loops are fully unrolled at compile time. A for i in 0..100 generates 100x the constraints of a single iteration. Minimize loop bounds.
CRITICAL: Recursion is strictly forbidden. The call graph must be a DAG (directed acyclic graph). The compiler will reject cyclic_function_dependency.
Assertions
assert(condition); // Fails transaction if false
assert_eq(a, b); // Fails if a != b
assert_neq(a, b); // Fails if a == b
11. Function Variants and Call Rules
Function Types
| Type | Keyword | Where | Can Be Called By | Generates Proof |
|---|---|---|---|---|
| Entry point (off-chain only) | transition | Inside program | External (users/programs) | Yes |
| Entry point (off-chain + on-chain) | async transition | Inside program | External (users/programs) | Yes + finalize |
| On-chain finalize logic | async function | Inside program | Paired async transition only | No (runs on-chain) |
| Helper (inlined) | inline | Inside program | Transitions, other inlines | No (inlined into caller) |
inline functions are defined INSIDE the program { } block. They are automatically inlined by the compiler and cannot be called externally. Use them for reusable helper logic.
Call Rules
transition/async transitioncan call: other transitions (cross-program) andinlinefunctionsinlinefunctions can call: otherinlinefunctionsasync function(finalize) is called implicitly by its pairedasync transition— you do not call it directly- Recursive calls are FORBIDDEN (cycle detection at compile time)
program example.aleo {
@noupgrade
async constructor() {}
// Inline helper — defined INSIDE the program block
// Automatically inlined, cannot be called externally
inline double(x: u64) -> u64 {
return x * 2u64;
}
// Entry point (callable externally)
transition compute(public input: u64) -> u64 {
return double(input); // Calls inline function
}
}
12. Context Accessors
| Accessor | Available In | Description |
|---|---|---|
self.caller | Transition body | Address of immediate caller (could be a program in cross-program calls) |
self.signer | Transition body | Address of original transaction signer (always a user) |
self.address | Transition body | Address of the current program |
block.height | async function ONLY | Current block height (u32) |
block.timestamp | async function ONLY | Current block timestamp (i64, Unix epoch) |
network.id | Anywhere | Network identifier |
group::GEN | Anywhere | Generator of the elliptic curve group |
SECURITY: In cross-program calls, self.caller returns the calling PROGRAM's address, not the user's. Use self.signer for user-level access control. Use self.caller for program-level access control.
transition admin_only() {
// CORRECT: checks the original user
assert_eq(self.signer, ADMIN_ADDRESS);
// DANGEROUS: in cross-program calls, self.caller is the
// intermediate program, not the user
// assert_eq(self.caller, ADMIN_ADDRESS); // Don't do this for user auth
}
13. Cryptographic Operations
Hash Functions
| Function | Output | Constraint Cost | When to Use |
|---|---|---|---|
Poseidon2::hash_to_field(val) | field | Lowest | Default choice for all hashing |
Poseidon4::hash_to_field(val) | field | Low | Alternative Poseidon variant |
Poseidon8::hash_to_field(val) | field | Low | Alternative Poseidon variant |
BHP256::hash_to_field(val) | field | Low-Medium | General-purpose hashing |
BHP512::hash_to_field(val) | field | Medium | Larger input hashing |
BHP768::hash_to_field(val) | field | Medium | Larger input hashing |
BHP1024::hash_to_field(val) | field | Medium | Larger input hashing |
Pedersen64::hash_to_field(val) | field | Low | Small input hashing |
Pedersen128::hash_to_field(val) | field | Low | Small input hashing |
Keccak256::hash_to_field(val) | field | Very High | EVM compatibility ONLY |
Keccak384::hash_to_field(val) | field | Very High | EVM compatibility ONLY |
Keccak512::hash_to_field(val) | field | Very High | EVM compatibility ONLY |
SHA3_256::hash_to_field(val) | field | Very High | EVM compatibility ONLY |
SHA3_384::hash_to_field(val) | field | Very High | EVM compatibility ONLY |
SHA3_512::hash_to_field(val) | field | Very High | EVM compatibility ONLY |
PERFORMANCE: Always prefer Poseidon2::hash_to_field as the default hash function. It is native to the field and consumes the fewest constraints. Only use Keccak/SHA3 if you need strict Ethereum compatibility — they are enormously expensive in ZK circuits.
Hash functions also have variants: hash_to_address, hash_to_group, hash_to_scalar, hash_to_u8, hash_to_u16, ..., hash_to_u128, hash_to_i8, ..., hash_to_i128.
// Hash a struct to a field element
let hash: field = Poseidon2::hash_to_field(my_struct);
// Hash to an address
let addr: address = BHP256::hash_to_address(my_data);
Commitment Functions
Commitments use a random scalar as a blinding factor for hiding:
// Pedersen64 accepts inputs up to 64 bits (u32, u16, u8, bool, etc.)
let small_commit: field = Pedersen64::commit_to_field(value_u32, salt);
// BHP256 accepts larger inputs including field, structs, etc.
let large_commit: group = BHP256::commit_to_group(value_field, salt);
// salt must be type `scalar`
Signature Verification
let is_valid: bool = signature::verify(sig, addr, message);
Randomness (Finalize-Only)
Random number generation is ONLY available inside async function (finalize) blocks:
async transition lottery() -> Future {
return finalize_lottery();
}
async function finalize_lottery() {
let random_bool: bool = ChaCha::rand_bool();
let random_field: field = ChaCha::rand_field();
let random_u32: u32 = ChaCha::rand_u32();
let random_u64: u64 = ChaCha::rand_u64();
// Also: rand_u8, rand_u16, rand_u128,
// rand_i8, rand_i16, rand_i32, rand_i64, rand_i128,
// rand_scalar, rand_address
}
14. Cross-Program Calls and Imports
Adding Dependencies
# Add a network dependency (deployed program)
leo add credits.aleo --network
# Add a local dependency
leo add my_lib.aleo --local ../my_lib
# Remove a dependency
leo remove my_lib.aleo
Import and Call Syntax
import credits.aleo;
program my_app.aleo {
@noupgrade
async constructor() {}
mapping payments: field => u64;
async transition pay(public receiver: address, public amount: u64) -> Future {
// Cross-program calls return a Future that must be awaited in the finalize function.
let payer: address = self.caller;
let transfer: Future = credits.aleo/transfer_public(receiver, amount);
let payment_id: field = BHP256::hash_to_field(payer);
return finalize_pay(transfer, payment_id, amount);
}
async function finalize_pay(transfer: Future, payment_id: field, amount: u64) {
transfer.await();
let total: u64 = Mapping::get_or_use(payments, payment_id, 0u64);
Mapping::set(payments, payment_id, total + amount);
}
}
program.json Dependencies
{
"program": "my_app.aleo",
"version": "0.1.0",
"description": "",
"license": "MIT",
"dependencies": [
{
"name": "credits.aleo",
"location": "network",
"network": "testnet"
},
{
"name": "my_lib.aleo",
"location": "local",
"path": "../my_lib"
}
]
}
15. Hard Limits and Constraints
| Constraint | Limit | Error if Exceeded |
|---|---|---|
| Max program size | ~100 KB compiled | Build failure |
| Max mappings per program | 31 | Compiler error |
| Max inputs per function | 16 | Compiler error |
| Max outputs per function | 16 | Compiler error |
| Max array elements | 32 (check current) | Compiler error |
| Loop bounds | Must be compile-time constants | Compiler error |
| Recursion | Strictly forbidden | cyclic_function_dependency |
Dynamic in-circuit arrays (Vec<T>) | Not supported | Compiler error |
| Strings | Not supported | strings_are_not_supported |
| Records containing records | Not supported | struct_or_record_cannot_contain_record |
16. Complete End-to-End Example: Private Token
This is the most important pattern in Aleo development — a token with both public and private balances and conversion between them.
program token.aleo {
@noupgrade
async constructor() {}
// Private state: encrypted records
record Token {
owner: address,
amount: u64,
}
// Public state: on-chain mappings
mapping account: address => u64;
// === PUBLIC OPERATIONS ===
// Mint tokens to a public balance
async transition mint_public(
public receiver: address,
public amount: u64,
) -> Future {
return finalize_mint_public(receiver, amount);
}
async function finalize_mint_public(receiver: address, amount: u64) {
let current_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, current_amount + amount);
}
// Transfer tokens between public balances
async transition transfer_public(
public receiver: address,
public amount: u64,
) -> Future {
let sender: address = self.caller;
return finalize_transfer_public(sender, receiver, amount);
}
async function finalize_transfer_public(sender: address, receiver: address, amount: u64) {
let sender_amount: u64 = Mapping::get_or_use(account, sender, 0u64);
assert(sender_amount >= amount);
Mapping::set(account, sender, sender_amount - amount);
let receiver_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, receiver_amount + amount);
}
// === PRIVATE OPERATIONS ===
// Mint tokens as a private record
transition mint_private(
receiver: address,
amount: u64,
) -> Token {
return Token {
owner: receiver,
amount: amount,
};
}
// Transfer tokens between private records (UTXO model)
transition transfer_private(
sender_token: Token, // consumed
receiver: address,
amount: u64,
) -> (Token, Token) { // two new records created
let change: u64 = sender_token.amount - amount;
let to_receiver: Token = Token {
owner: receiver,
amount: amount,
};
let to_sender: Token = Token {
owner: sender_token.owner,
amount: change,
};
return (to_receiver, to_sender);
}
// === BRIDGE OPERATIONS (public <-> private) ===
// Convert public balance to private record (shielding)
async transition transfer_public_to_private(
public receiver: address,
public amount: u64,
) -> (Token, Future) {
let new_record: Token = Token {
owner: receiver,
amount: amount,
};
let sender: address = self.caller;
return (new_record, finalize_pub_to_priv(sender, amount));
}
async function finalize_pub_to_priv(sender: address, amount: u64) {
let current: u64 = Mapping::get_or_use(account, sender, 0u64);
assert(current >= amount);
Mapping::set(account, sender, current - amount);
}
// Convert private record to public balance (unshielding)
async transition transfer_private_to_public(
sender_token: Token, // consumed
public receiver: address,
public amount: u64,
) -> (Token, Future) {
let change: u64 = sender_token.amount - amount;
let change_record: Token = Token {
owner: sender_token.owner,
amount: change,
};
return (change_record, finalize_priv_to_pub(receiver, amount));
}
async function finalize_priv_to_pub(receiver: address, amount: u64) {
let current: u64 = Mapping::get_or_use(account, receiver, 0u64);
Mapping::set(account, receiver, current + amount);
}
}
17. Complete End-to-End Example: Tic-Tac-Toe
This demonstrates pure struct-based state, control flow, and deterministic logic without mappings:
program tictactoe.aleo {
@noupgrade
async constructor() {}
struct Board {
r1: Row,
r2: Row,
r3: Row,
}
struct Row {
c1: u8,
c2: u8,
c3: u8,
}
// Create a new empty board
transition new() -> Board {
return Board {
r1: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r2: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r3: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
};
}
// Make a move: player 1 or 2, row 1-3, col 1-3
transition make_move(
board: Board,
player: u8,
row: u8,
col: u8,
) -> Board {
assert(player == 1u8 || player == 2u8);
assert(row >= 1u8 && row <= 3u8);
assert(col >= 1u8 && col <= 3u8);
// Get current row
let current_row: Row = row == 1u8 ? board.r1
: (row == 2u8 ? board.r2 : board.r3);
// Check cell is empty
let current_val: u8 = col == 1u8 ? current_row.c1
: (col == 2u8 ? current_row.c2 : current_row.c3);
assert_eq(current_val, 0u8);
// Set the cell
let new_row: Row = Row {
c1: col == 1u8 ? player : current_row.c1,
c2: col == 2u8 ? player : current_row.c2,
c3: col == 3u8 ? player : current_row.c3,
};
return Board {
r1: row == 1u8 ? new_row : board.r1,
r2: row == 2u8 ? new_row : board.r2,
r3: row == 3u8 ? new_row : board.r3,
};
}
}
18. Common Compiler Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
| "Mapping operations are only allowed in async functions" | Mapping::get/set used outside async function | Move mapping operations into an async function (finalize) block |
| "Record must have an owner field of type address" | Record missing owner: address | Add owner: address as first field |
| "struct_or_record_cannot_contain_record" | A struct/record contains a record | Use a struct for the inner type, or store the record's commitment/owner address instead |
| "Loop bound must be constant" | Variable used as loop bound | Use a compile-time constant literal |
| "cyclic_function_dependency" | Recursive function call detected | Flatten the logic or use bounded for loops |
| "strings_are_not_supported" | String literal used | Encode text as field elements or [field; N] |
| "too_many_finalize_arguments" | Too many arguments passed to async function | Combine parameters into a struct |
Type mismatch (e.g., u32 vs u64) | Implicit type promotion attempted | Use explicit as cast: x as u64 |
| Overflow/underflow in checked arithmetic | Result exceeds type range | Use _wrapped variant or larger type |
| "array_too_large" | Array exceeds max size | Reduce array size or use multiple smaller arrays |
| "too_many_mappings" | More than 31 mappings | Consolidate mappings using struct values |
19. Performance Considerations
- Field operations are cheapest:
fieldis the native ZK type. Usefieldwhen your problem fits. - Integer operations add range checks: Every integer operation requires additional constraints for range verification. Prefer
fieldover integers when possible. - Poseidon is cheapest for hashing:
Poseidon2::hash_to_fieldis the most constraint-efficient. Keccak/SHA3 are 10-100x more expensive. - Both branches evaluate: In ZK,
if/elseevaluates both branches. Minimize branching depth. - Loops unroll completely: A
for i in 0..Ngenerates Nx the constraints. Minimize N. - Struct packing: Combine small values into fewer struct fields to reduce constraint count.
- Prefer
get_or_useoverget:geton missing keys fails the transaction (wastes fee).get_or_useis always safe.
20. Agent SOP: Compiler-in-the-Loop Workflow
When asked to write or modify a Leo program, follow this exact loop:
- Write/Edit: Generate the
.leosource code following all rules in this skill. - Check Structure: Verify the program has: proper imports, all types defined before use, correct
transition/async transition/async functionpairing. - Format: Run
leo fmt --check. If it fails, runleo fmt. - Compile: Run
leo build. - Error Handling: If compilation fails:
- Read the EXACT error message. Do NOT guess the fix.
- Look up the error in the "Common Compiler Errors" table above.
- Apply the documented fix.
- Re-run
leo build.
- Test: Run
leo testto verify correctness. - Local Execute: Use
leo run <function_name> <inputs>only for pure transition paths. For async transitions that rely on finalize logic, useleo execute <function_name> <inputs>. - Proof Generate: Run
leo execute <function_name> <inputs>to generate proof and verify.
NEVER:
- Use dynamic arrays, strings, or recursion
- Put mapping operations outside
async function(finalize) - Assume implicit type casting
- Use
Mapping::getwithout handling the missing-key case (preferget_or_use) - Create records without an
owner: addressfield - Nest records inside other records or structs
- Define functions outside the
programblock (useinlineinside the program block for helpers) - Omit constructor policy annotation on deployable/test programs
- Use
self.callerinsideasync function— capture it in the transition and pass it as an argument
- After writing your program, deploy it using
aleo_deployment - Test thoroughly using
aleo_testing - For privacy architecture, see
aleo_privacy_patterns - For TypeScript SDK integration, see
aleo_frontendandaleo_backend - For staking/credits operations, see
aleo_staking_delegation - For complete copy-pasteable programs, see
aleo_cookbook