Leo smart contract development: complete reference
1. Overview
This skill covers writing, compiling, and reasoning about Leo programs for the Aleo blockchain. Leo is a statically typed, Rust-inspired language that compiles to zero-knowledge circuits (R1CS constraints). A Leo program executes off-chain on the caller's machine to produce a cryptographic proof; validators verify that proof on-chain without re-running the computation.
2. Version and canonical syntax
Target: Leo compiler >= 4.4.0. Everything below compiles against Leo 4.4.0.
Leo 4.0 replaced the entire function keyword vocabulary. If you learned Leo before that release, most of what you remember is now a parse error. The current keywords are:
fninsideprogram { }declares an entry point. It runs off-chain and generates a proof.fnoutsideprogram { }declares a helper. Helpers are inlined; they are not part of the on-chain interface.final { }is a block inside an entry point that runs on-chain after the proof verifies. It is where public state changes happen.Finalis the return type of an entry point that has afinal { }block.final fnoutsideprogram { }declares reusable finalization logic, inlined into each caller'sfinalblock at compile time.constructor()declares the program's upgrade policy and is mandatory.
If a source you are reading uses transition, async transition, async function, inline, Future, self.caller, or foo.aleo/bar, it predates Leo 4.0. Section 22 has the full translation table.
When the published documentation disagrees with the compiler, the compiler wins. The docs at docs.leo-lang.org currently describe 4.0 and have not caught up with every 4.1 to 4.4 change.
3. Key concepts
- Program: the deployment unit on Aleo, analogous to a smart contract. Declared as
program name.aleo { }. Each has a unique program ID such asmy_token.aleo. - Record: an encrypted, UTXO-like private state object. Records must have an
owner: addressfield. They are consumed when used as inputs and created as outputs, never mutated in place. Only the owner can decrypt one with their view key. Records cannot contain other records. - Struct: a composite type, declared outside the
programblock. Structs are transparent, not encrypted. - Mapping: a public on-chain key-value store, declared inside the
programblock. Readable and writable only insidefinalcode. Every value is globally visible. - Storage variable: a public on-chain singleton,
storage counter: u64;. Behaves like an option: it may be unset. - Storage vector: a public on-chain dynamic list,
storage members: [address];. - Proof context: the off-chain half of execution. Private inputs, record operations, and all circuit computation live here.
- Finalization context: the on-chain half. Mappings, storage,
std::ctx::block_height(),ChaCha::rand_*, andsnark.verifylive here, and everything in it is public. - Serial number (nullifier): published when a record is consumed, preventing double-spending without revealing which record was spent.
- Microcredit: the smallest denomination of Aleo credits. One credit is 1,000,000 microcredits.
4. The dual execution model
This is the concept that prevents the largest class of Leo errors.
┌──────────────────────────────────────────────────────────────┐
│ CALLER'S MACHINE (off-chain) │
│ │
│ 1. Caller invokes an entry point with private/public inputs │
│ 2. The fn body executes locally │
│ 3. Records are consumed (inputs) and created (outputs) │
│ 4. A zero-knowledge proof is generated │
│ 5. Proof plus encrypted outputs go to the network │
│ │
│ Available: std::ctx::caller(), std::ctx::signer(), │
│ record operations, all circuit computation │
│ Not available: mappings, storage, block height, ChaCha │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ VALIDATORS (on-chain) │
│ │
│ 6. Validators verify the proof without re-executing it │
│ 7. If the entry point returns Final, validators run the │
│ lifted final block to update public state │
│ 8. If the final block fails, the whole transaction is │
│ rejected and the fee is still charged │
│ │
│ Available: mapping get/set/remove/contains, storage vars │
│ and vectors, std::ctx::block_height(), │
│ std::ctx::block_timestamp(), ChaCha::rand_*, │
│ snark.verify, snark.verify_batch │
│ Not available: private inputs, records │
└──────────────────────────────────────────────────────────────┘
Three rules follow from this diagram.
Mapping and storage operations are only legal inside final { } or final fn. Using them in the proof half is a compile error.
A final { } block closes over values from the surrounding entry point, so you no longer pass them explicitly the way Leo 3.5 required. Capture context accessors in the proof half and read the captured variable inside final, because std::ctx::caller() is not available on-chain.
When a final block fails (an assertion, an overflow, a get on a missing key), the entire transaction is rejected and the base fee is still consumed. Prefer get_or_use over get.
5. Program structure
Leo 4.x splits a file into two regions. The program { } block is the on-chain interface. Everything supporting it lives outside.
Inside program { } | Outside program { } |
|---|---|
constructor() (mandatory) | import statements |
entry point fn declarations | helper fn definitions |
record definitions | final fn definitions |
mapping declarations | struct definitions |
storage variables and vectors | interface definitions |
program-scope const | global const |
Putting a struct inside the program block is a parse error in 4.4 (EPAR0370047), and so is inline or function anywhere.
// 1. Imports
import credits.aleo;
// 2. Global constants
const MAX_SUPPLY: u64 = 1000000u64;
// 3. Structs live outside the program block
struct TokenInfo {
name: field,
symbol: field,
decimals: u8,
}
// 4. Helper functions live outside; they are inlined into callers
fn double(x: u64) -> u64 {
return x * 2u64;
}
// 5. Reusable finalization logic lives outside
final fn credit_account(receiver: address, amount: u64) {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
}
program my_program.aleo {
// 6. The constructor is mandatory and carries the upgrade policy
@noupgrade
constructor() {}
// 7. Records are part of the public interface, so they stay inside
record Token {
owner: address,
amount: u64,
info: TokenInfo,
}
// 8. Public key-value state
mapping balances: address => u64;
// 9. Public singleton state
storage supply: u64;
// 10. Public dynamic list state
storage holders: [address];
// 11. Entry point with no on-chain effects
fn mint_private(receiver: address, amount: u64) -> Token {
return Token {
owner: receiver,
amount: double(amount),
info: TokenInfo { name: 0field, symbol: 0field, decimals: 6u8 },
};
}
// 12. Entry point with on-chain effects
fn mint(public receiver: address, public amount: u64) -> Final {
assert(amount <= MAX_SUPPLY);
return final {
credit_account(receiver, amount);
supply = supply.unwrap_or(0u64) + amount;
};
}
}
The constructor is mandatory
Every deployable program needs a constructor carrying exactly one policy annotation. Omitting it fails with ETYC0372084.
| Annotation | Meaning |
|---|---|
@noupgrade | Code is frozen at deployment. Callers can rely on it never changing. |
@admin(address="aleo1...") | The named address may deploy upgrades. |
@checksum(mapping="...", key="...") | Upgrades are gated on an on-chain checksum value. |
@custom | The constructor body defines the policy itself. |
Pick @noupgrade for anything other programs will depend on. Pick @admin when you expect to fix bugs. The choice is visible on-chain, so it is part of your program's security story rather than a deployment detail.
6. Type system
Primitive types
| Type | Description | Literal | Range |
|---|---|---|---|
bool | Boolean | true, false | true/false |
u8 | Unsigned 8-bit | 42u8 | 0 to 255 |
u16 | Unsigned 16-bit | 1000u16 | 0 to 65,535 |
u32 | Unsigned 32-bit | 100000u32 | 0 to 4,294,967,295 |
u64 | Unsigned 64-bit | 1000000u64 | 0 to 2^64 - 1 |
u128 | Unsigned 128-bit | 340u128 | 0 to 2^128 - 1 |
i8 | Signed 8-bit | -42i8 | -128 to 127 |
i16 | Signed 16-bit | -1000i16 | -32,768 to 32,767 |
i32 | Signed 32-bit | -100000i32 | -2^31 to 2^31 - 1 |
i64 | Signed 64-bit | -50i64 | -2^63 to 2^63 - 1 |
i128 | Signed 128-bit | -340i128 | -2^127 to 2^127 - 1 |
field | Field element, the native ZK type | 1field | 0 to p-1 |
group | Elliptic curve point | 0group | Group elements |
scalar | Scalar for group operations | 1scalar | Scalar field |
address | Aleo address | aleo1... | Valid addresses |
signature | Schnorr signature | Valid signatures |
Strings do not exist in Leo. The compiler rejects string literals. Encode text as field elements or [field; N] arrays.
Dynamic in-circuit arrays do not exist either. Circuit arrays need compile-time sizes: [u32; 8] is fine, there is no Vec<u32>. Storage vectors (storage items: [T];) are on-chain state, not circuit values, and are a separate feature.
Composite types
Fixed-size arrays:
let arr: [u32; 4] = [1u32, 2u32, 3u32, 4u32];
let element: u32 = arr[0u32];
let nested: [[u32; 2]; 3] = [[1u32, 2u32], [3u32, 4u32], [5u32, 6u32]];
Tuples:
let t: (u32, bool) = (42u32, true);
let first: u32 = t.0;
let second: bool = t.1;
Optionals (T?):
let some_val: u64? = 42u64;
let no_val: u64? = none;
let val: u64 = some_val.unwrap(); // fails if none
let safe: u64 = no_val.unwrap_or(0u64); // never fails
Casting
Use as. There is no implicit promotion.
let x: u32 = 42u32;
let y: u64 = x as u64; // widening, safe
let z: field = x as field; // integer to field, safe
let w: u8 = x as u8; // narrowing, may truncate
All integer types cast to each other and to field. field casts to integer types. group and scalar have limited casting.
Checked arithmetic
Leo checks arithmetic by default. Overflow and underflow fail the transaction rather than wrapping silently.
let a: u8 = 255u8;
// let b: u8 = a + 1u8; // fails: overflow
let b: u8 = a.add_wrapped(1u8); // wraps to 0u8
// Wrapping variants: add_wrapped, sub_wrapped, mul_wrapped,
// div_wrapped, pow_wrapped, shl_wrapped, shr_wrapped
Checked arithmetic is a security feature. Reach for _wrapped only when wrapping is the behaviour you want, such as inside a hash computation.
7. Records: the privacy primitive
Records are Aleo's encrypted UTXO-style state objects.
CREATE → ENCRYPT → STORE ON-CHAIN → DECRYPT (owner only) → CONSUME → NULLIFY
An entry point creates a record as an output. The record is encrypted to its owner's address and the ciphertext is stored on-chain. Only the owner can decrypt it with their view key. Passing it into another entry point consumes it, publishing a serial number that prevents double-spending, and new records come out the other side.
program token.aleo {
@noupgrade
constructor() {}
record Token {
owner: address, // mandatory
amount: u64,
}
fn mint(receiver: address, amount: u64) -> Token {
return Token { owner: receiver, amount: amount };
}
// Consumes one record, creates two
fn send(sender_token: Token, receiver: address, amount: u64) -> (Token, Token) {
let remaining: u64 = sender_token.amount - amount;
let receiver_token: Token = Token { owner: receiver, amount: amount };
let change_token: Token = Token { owner: sender_token.owner, amount: remaining };
return (receiver_token, change_token);
}
}
Field visibility
record Token {
owner: address, // always encrypted
private amount: u64, // encrypted, and the default
public token_type: u8, // visible on-chain in the transaction
}
Restrictions
Records must have an owner: address field. They cannot contain other records, though they can contain structs. They cannot be mapping keys or values. They exist only in the proof half of execution, never inside final code.
8. Mappings: public key-value state
program counter.aleo {
@noupgrade
constructor() {}
mapping counts: address => u64;
fn increment(public amount: u64) -> Final {
let caller: address = std::ctx::caller();
return final {
let current: u64 = counts.get_or_use(caller, 0u64);
counts.set(caller, current + amount);
};
}
}
| Operation | Method syntax | Static syntax | Behaviour |
|---|---|---|---|
| Get | m.get(key) | Mapping::get(m, key) | Returns the value or fails the transaction |
| Get with default | m.get_or_use(key, d) | Mapping::get_or_use(m, key, d) | Returns the value or d |
| Set | m.set(key, value) | Mapping::set(m, key, value) | Creates or updates |
| Remove | m.remove(key) | Mapping::remove(m, key) | Deletes the entry |
| Contains | m.contains(key) | Mapping::contains(m, key) | Returns bool |
Prefer get_or_use over get. A get on a missing key rejects the whole transaction and still charges the fee. There is no has_key; use contains.
A program can read another program's mappings but cannot write them:
let balance: u64 = credits.aleo::account.get_or_use(addr, 0u64);
Limits: 31 mappings per program. Keys and values must be primitives, structs of primitives, or supported containers, never records.
9. Storage variables and vectors
Storage variables behave like optionals: a declared variable is unset until something assigns to it.
program config.aleo {
@noupgrade
constructor() {}
storage admin: address;
storage paused: bool;
storage counter: u64;
fn initialize(public addr: address) -> Final {
return final {
admin = addr;
paused = false;
counter = 0u64;
};
}
fn bump() -> Final {
return final {
let current: u64 = counter.unwrap_or(0u64);
counter = current + 1u64;
// counter = none; // clears the value
};
}
}
Storage vectors are on-chain dynamic lists. Under the hood the compiler lowers storage members: [address]; into two mappings, members__ holding the elements and members__len__ holding the length.
program registry.aleo {
@noupgrade
constructor() {}
storage members: [address];
fn register(public member: address) -> Final {
return final {
members.push(member);
};
}
fn drop_last() -> Final {
return final {
members.pop();
};
}
fn inspect(public index: u32) -> Final {
return final {
let count: u32 = members.len();
assert(index < count);
// get() returns an optional, so unwrap it
let member: address = members.get(index).unwrap();
members.set(index, member);
};
}
}
Storage vectors do not support members[index] syntax; that fails with ETYC0372117, because the vector is not a circuit array. Use .get(index) and unwrap the result.
10. Control flow
if amount > 100u64 {
// ...
} else if amount > 50u64 {
// ...
} else {
// ...
}
let fee: u64 = condition ? 10u64 : 5u64;
In a ZK circuit both branches of an if/else are always evaluated and a multiplexer picks the result. Nested branching multiplies the constraint count, so a ternary usually costs less than an equivalent if/else chain.
// Loop bounds must be compile-time constants
for i: u8 in 0u8..10u8 {
// ...
}
// Leo 4.0 added inclusive bounds
for i: u32 in 0u32..=10u32 {
// i runs 0 through 10
}
// Variable bounds are rejected:
// for i: u8 in 0u8..n { } // compiler error
// Use a fixed bound with a guard instead
for i: u64 in 0u64..100u64 {
if i < actual_count {
// ...
}
}
Loops unroll completely at compile time, so for i in 0..100 costs 100 times a single iteration. Recursion is forbidden outright; the call graph must be acyclic, and a cycle fails with cyclic_function_dependency.
assert(condition);
assert_eq(a, b);
assert_neq(a, b);
11. Function kinds and call rules
| Kind | Declaration | Where | Callable by | Proof? |
|---|---|---|---|---|
| Entry point | fn foo() | inside program | users and other programs | yes |
| Entry point with state change | fn foo() -> Final | inside program | users and other programs | yes, plus on-chain finalization |
| Helper | fn foo() | outside program | entry points and other helpers | inlined into caller |
| Finalization helper | final fn foo() | outside program | final blocks and other final fn | inlined into caller's finalization |
| Constructor | constructor() | inside program | the network, at deploy and upgrade | no |
A final fn is a compile-time deduplication tool, not a standalone on-chain function. Its body is pasted into each caller's final block before the compiler lifts those blocks into on-chain finalizations.
fn double(x: u64) -> u64 {
return x * 2u64;
}
program example.aleo {
@noupgrade
constructor() {}
fn compute(public input: u64) -> u64 {
return double(input);
}
}
Do not name a function after an AVM opcode
This one costs people an afternoon. A program with fn add(...) compiles to Aleo instructions without complaint, then fails when snarkVM parses the result:
'add' is a reserved opcode.
Error [ECLI0377044]: failed to parse Aleo program `my_app`
The error mentions bytecode and says nothing about your function name, so it reads like a compiler bug. It is not. Avoid add, sub, mul, div, hash, get, set, cast, and the other AVM opcodes as function names. append_item, register, and deposit are all fine.
12. Context accessors
Leo 4.x moved context accessors into the standard library. self.caller, self.signer, block.height, and block.timestamp were removed and now fail with EPAR0370056.
| Accessor | Available in | Returns |
|---|---|---|
std::ctx::caller() | proof half | address of the immediate caller, which may be a program |
std::ctx::signer() | proof half | address of the original transaction signer, always a user |
std::ctx::addr() | proof half | address of the current program |
std::ctx::block_height() | final only | current block height, u32 |
std::ctx::block_timestamp() | final only | current block timestamp, i64 Unix epoch |
std::ctx::network_id() | anywhere | network identifier, u16 |
std::ctx::edition() | anywhere | current program edition |
std::ctx::program_owner() | anywhere | deploying address |
group::GEN | anywhere | generator of the elliptic curve group |
In a cross-program call std::ctx::caller() returns the calling program's address, not the user's. Use std::ctx::signer() for user-level access control and std::ctx::caller() for program-level access control. The compiler warns about this directly: std::ctx::caller() may return a program address, which cannot spend records.
const ADMIN: address = aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px;
program guarded.aleo {
@noupgrade
constructor() {}
fn admin_only() {
// Correct: checks the human who signed the transaction
assert_eq(std::ctx::signer(), ADMIN);
// Wrong for user auth: in a cross-program call this is the
// intermediate program's address
// assert_eq(std::ctx::caller(), ADMIN);
}
}
Context accessors are unavailable inside final. Capture them in the proof half and let the final block close over the variable.
13. Cryptographic operations
| Function | Output | Cost | Use when |
|---|---|---|---|
Poseidon2::hash_to_field(v) | field | lowest | default for everything |
Poseidon4::hash_to_field(v) | field | low | alternative Poseidon width |
Poseidon8::hash_to_field(v) | field | low | alternative Poseidon width |
BHP256::hash_to_field(v) | field | low to medium | general purpose |
BHP512/768/1024::hash_to_field(v) | field | medium | larger inputs |
Pedersen64::hash_to_field(v) | field | low | inputs up to 64 bits |
Pedersen128::hash_to_field(v) | field | low | inputs up to 128 bits |
Keccak256/384/512::hash_to_field(v) | field | very high | Ethereum compatibility only |
SHA3_256/384/512::hash_to_field(v) | field | very high | Ethereum compatibility only |
Poseidon2 is native to the field and costs the fewest constraints, so make it the default. Keccak and SHA3 cost one to two orders of magnitude more; use them only when you need to match an Ethereum hash exactly.
Each hash family also offers hash_to_address, hash_to_group, hash_to_scalar, and hash_to_u8 through hash_to_u128 and hash_to_i8 through hash_to_i128.
let h: field = Poseidon2::hash_to_field(my_struct);
let a: address = BHP256::hash_to_address(my_data);
Commitments take a scalar blinding factor:
// Pedersen64 accepts inputs up to 64 bits
let small: field = Pedersen64::commit_to_field(value_u32, salt);
// BHP256 accepts larger inputs including fields and structs
let large: group = BHP256::commit_to_group(value_field, salt);
Signature verification works in either form:
let ok: bool = signature::verify(sig, addr, message);
let also_ok: bool = sig.verify(addr, message);
Randomness is finalization-only, because a value sampled off-chain would be chosen by the caller:
program lottery.aleo {
@noupgrade
constructor() {}
storage roll: u32;
fn draw() -> Final {
return final {
roll = ChaCha::rand_u32();
// Also: rand_bool, rand_field, rand_u8/16/64/128,
// rand_i8/16/32/64/128, rand_scalar, rand_address
};
}
}
14. Cross-program calls and imports
leo add credits.aleo --network # deployed program
leo add my_lib.aleo --local ../my_lib # local dependency
leo remove my_lib.aleo
Cross-program references use ::. The / separator was removed in Leo 4.0.
import credits.aleo;
program my_app.aleo {
@noupgrade
constructor() {}
mapping payments: field => u64;
fn pay(public receiver: address, public amount: u64) -> Final {
let payer: address = std::ctx::caller();
let payment_id: field = BHP256::hash_to_field(payer);
// A cross-program call to a stateful entry point returns a Final
let transfer: Final = credits.aleo::transfer_public(receiver, amount);
return final {
// Run the callee's finalization before your own
transfer.run();
let total: u64 = payments.get_or_use(payment_id, 0u64);
payments.set(payment_id, total + amount);
};
}
}
The manifest is program.json. Leo 4.x renamed it from project.json.
{
"program": "my_app.aleo",
"version": "0.1.0",
"description": "",
"license": "MIT",
"leo": "4.4.0",
"dependencies": [
{ "name": "credits.aleo", "location": "network", "network": "testnet" },
{ "name": "my_lib.aleo", "location": "local", "path": "../my_lib" }
]
}
Leo 4.4 warns when the manifest's leo field does not match the installed compiler, which catches version drift before it becomes a deployment surprise.
15. Interfaces and dynamic dispatch
Leo 4.0 introduced interfaces, and dynamic dispatch reached mainnet with the V17 consensus upgrade. An interface specifies a contract a program promises to satisfy, which lets a caller work with any program implementing it.
interface Counter {
fn increment(amount: u64) -> u64;
}
program my_counter.aleo : Counter {
@noupgrade
constructor() {}
fn increment(amount: u64) -> u64 {
return amount + 1u64;
}
}
Interfaces can declare function signatures, records, mappings, and storage variables, and they support inheritance:
interface Base {
fn get_value() -> u64;
}
interface Extended : Base {
fn set_value(v: u64) -> u64;
}
Leo 4.4 rejects cross-program writes to another program's finalization state, so an interface gets you a callable surface, not shared mutable state.
16. Limits
| Constraint | Limit | Error |
|---|---|---|
| Max compiled program size | 2000 KB | build failure |
| Max mappings per program | 31 | compiler error |
| Max inputs per function | 16 | compiler error |
| Max outputs per function | 16 | compiler error |
| Loop bounds | compile-time constants only | compiler error |
| Recursion | forbidden | cyclic_function_dependency |
| Dynamic in-circuit arrays | not supported | compiler error |
| Strings | not supported | strings_are_not_supported |
| Records inside records | not supported | struct_or_record_cannot_contain_record |
| Constructor | mandatory | ETYC0372084 |
| Function named after an AVM opcode | rejected at bytecode parse | ECLI0377044 |
leo build prints the compiled size against the 2000 KB ceiling on every run.
17. End-to-end example: private token
The central pattern in Aleo development is a token with both public and private balances plus conversion between them.
program token.aleo {
@noupgrade
constructor() {}
// Private state
record Token {
owner: address,
amount: u64,
}
// Public state
mapping account: address => u64;
// === Public operations ===
fn mint_public(public receiver: address, public amount: u64) -> Final {
return final {
let current: u64 = account.get_or_use(receiver, 0u64);
account.set(receiver, current + amount);
};
}
fn transfer_public(public receiver: address, public amount: u64) -> Final {
let sender: address = std::ctx::caller();
return final {
let sender_amount: u64 = account.get_or_use(sender, 0u64);
assert(sender_amount >= amount);
account.set(sender, sender_amount - amount);
let receiver_amount: u64 = account.get_or_use(receiver, 0u64);
account.set(receiver, receiver_amount + amount);
};
}
// === Private operations ===
fn mint_private(receiver: address, amount: u64) -> Token {
return Token { owner: receiver, amount: amount };
}
fn transfer_private(
sender_token: Token,
receiver: address,
amount: u64,
) -> (Token, Token) {
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);
}
// === Bridging public and private ===
fn transfer_public_to_private(
public receiver: address,
public amount: u64,
) -> (Token, Final) {
let new_record: Token = Token { owner: receiver, amount: amount };
let sender: address = std::ctx::caller();
return (new_record, final {
let current: u64 = account.get_or_use(sender, 0u64);
assert(current >= amount);
account.set(sender, current - amount);
});
}
fn transfer_private_to_public(
sender_token: Token,
public receiver: address,
public amount: u64,
) -> (Token, Final) {
let change: u64 = sender_token.amount - amount;
let change_record: Token = Token { owner: sender_token.owner, amount: change };
return (change_record, final {
let current: u64 = account.get_or_use(receiver, 0u64);
account.set(receiver, current + amount);
});
}
}
Note the shape of the two bridging entry points: they return a tuple of a record and a Final, so one call produces both a private output and a public state change.
18. End-to-end example: tic-tac-toe
Pure circuit logic with no on-chain state, showing structs outside the program block and ternary-heavy control flow.
struct Row {
c1: u8,
c2: u8,
c3: u8,
}
struct Board {
r1: Row,
r2: Row,
r3: Row,
}
program tictactoe.aleo {
@noupgrade
constructor() {}
fn start() -> 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 },
};
}
fn 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);
let current_row: Row = row == 1u8 ? board.r1 : (row == 2u8 ? board.r2 : board.r3);
let current_val: u8 = col == 1u8 ? current_row.c1
: (col == 2u8 ? current_row.c2 : current_row.c3);
assert_eq(current_val, 0u8);
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,
};
}
}
19. Common compiler errors and troubleshooting
| Error | Cause | Fix |
|---|---|---|
EPAR0370005 on transition, async, function, inline, import | item is not legal directly inside program { } | Use fn; move import above the block; see section 22 |
EPAR0370047 inside the block | struct or interface inside program { } | Move the definition outside the block |
EPAR0370047 at top level | mapping or storage outside program { } | Move the declaration inside the block |
EPAR0370056 on self.caller | accessor removed | std::ctx::caller() |
ETYC0372084 | no constructor, or a constructor outside program { } | Add @noupgrade constructor() {} inside the block |
ETYC0372156 "must have exactly one of the following annotations" | constructor with no policy annotation, or with two | Carry exactly one of @noupgrade, @admin, @checksum, @custom |
ETYC0372156 "must be empty" | @noupgrade, @admin, or @checksum constructor with a body | Empty the body; the compiler writes the policy code |
ETYC0372156 "cannot be empty" | @custom constructor with no body | Write the policy check, or switch to @noupgrade |
ETYC0372042 "reads off-chain-only values from on-chain code" | proof-half accessor used in a constructor body or a final block | Read a mapping or std::ctx::block_height() instead |
ETYC0372003 "expected type ()" | final { } block with no -> Final return type | Declare -> Final |
ETYC0372043 "cannot call a local entry point fn" | helper fn defined inside program { } | Move the helper outside the block |
ETYC0372019 | record has no owner: address field | Add the field |
ETYC0372067 and ETYC0372034 | mapping or storage touched in the proof half | Move the operation into final { } or a final fn |
ETYC0372117 "expected an array" | vec[i] on a storage vector | Use vec.get(i).unwrap() |
ETYC0372117 optional mismatch (u64? vs u64) | unwrapped get() or storage read | Add .unwrap() or .unwrap_or(d) |
ETYC0372119 | operands of an operator have different types | Cast explicitly with as |
ECLI0377044 after a clean compile | function named after an AVM opcode | Rename the function; see section 11 |
ECMP0376006 | import not declared in the manifest | Add the dependency to program.json |
struct_or_record_cannot_contain_record | nested record | Store a commitment or the owner address instead |
| Loop bound not constant | variable bound | Use a literal bound with a guard |
cyclic_function_dependency | recursion | Flatten into bounded loops |
strings_are_not_supported | string literal | Encode as field or [field; N] |
| Overflow in checked arithmetic | result out of range | Use a wider type or a _wrapped variant |
Reading EPAR0370005
This is the error agents hit most often, and its shape is always the same:
[EPAR0370005] Error: expected '}', '@', 'record', 'struct', 'fn', 'final',
'const', 'mapping', 'storage', 'script', 'interface', found `transition`
The list of expected tokens is not advice about your file. It is the complete set of items the parser accepts directly inside program { }. Read the found token instead: it names the construct to remove. transition, async, function, and inline mean Leo 3.5 source, and import means a statement that belongs above the block.
Reading ETYC0372156
One error code covers every constructor policy failure, so read the message rather than the number:
| Message | Meaning |
|---|---|
A constructor must have exactly one of the following annotations | the constructor carries no annotation, or two |
A 'noupgrade', 'admin', or 'checksum' constructor must be empty | you wrote a body that the compiler intends to generate |
A 'custom' constructor cannot be empty | @custom hands you the policy, so you must write it |
@noupgrade, @admin, and @checksum are declarative. The annotation states the whole policy and the compiler emits the check, so the body stays {}. @custom is the opposite: the body is the policy and must contain a real check.
A constructor body runs on chain, like final. Proof-half accessors are unavailable there, so std::ctx::caller() in a @custom body fails with ETYC0372042. Gate on a mapping or on std::ctx::block_height() instead.
Before and after
Entry point declared with transition. EPAR0370005, found transition
// Before: `transition` was removed in Leo 4.0
transition mint(public receiver: address, public amount: u64) -> u64 {
return amount;
}
// After
fn mint(public receiver: address, public amount: u64) -> u64 {
return amount;
}
Split finalization. EPAR0370005, found async
// Before: two functions, an explicit Future, and hand-threaded arguments
async transition mint(public receiver: address, public amount: u64) -> Future {
return finalize_mint(receiver, amount);
}
async function finalize_mint(receiver: address, amount: u64) {
balances.set(receiver, amount);
}
// After: one entry point; the `final` block closes over the arguments
fn mint(public receiver: address, public amount: u64) -> Final {
return final {
balances.set(receiver, amount);
};
}
Missing Final return type. ETYC0372003: expected type '()', found type 'Final<Fn(address,u64)>'
// Before: the body returns a Final, the signature promises nothing
fn mint(public receiver: address, public amount: u64) {
return final { balances.set(receiver, amount); };
}
// After
fn mint(public receiver: address, public amount: u64) -> Final {
return final { balances.set(receiver, amount); };
}
Helper placed inside the block. ETYC0372043: cannot call a local entry point fn from an entry point fn
// Before: both are entry points, so the call is illegal
fn double(x: u64) -> u64 {
return x * 2u64;
}
fn mint(public amount: u64) -> u64 {
return double(amount);
}
// After: the helper moves above the block and is inlined into the caller
fn double(x: u64) -> u64 {
return x * 2u64;
}
Constructor with no policy annotation. ETYC0372156: A constructor must have exactly one of the following annotations
// Before
constructor() {}
// After
@noupgrade
constructor() {}
Policy annotation with a body. ETYC0372156: A 'noupgrade', 'admin', or 'checksum' constructor must be empty
// Before: @admin already states the policy, and the body repeats it
@admin(address="aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px")
constructor() {
assert_eq(std::ctx::caller(), ADMIN);
}
// After: the compiler inserts the check
@admin(address="aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px")
constructor() {}
@custom with an empty body. ETYC0372156: A 'custom' constructor cannot be empty
// Before: @custom promises a policy that is not there
@custom
constructor() {}
// After: the body is the policy, and it reads on-chain state
@custom
constructor() {
assert(upgrade_allowed.get_or_use(0u8, false));
}
As a complete program that compiles:
program upgradable.aleo {
mapping upgrade_allowed: u8 => bool;
@custom
constructor() {
assert(upgrade_allowed.get_or_use(0u8, false));
}
fn set_flag(public value: bool) -> Final {
return final {
upgrade_allowed.set(0u8, value);
};
}
}
Unwrapped public state. ETYC0372117: expected type 'u64', but type 'u64?' was found
// Before: a storage read is an option, not a value
supply = supply + amount;
// After
supply = supply.unwrap_or(0u64) + amount;
Every fix above applied at once, as one program that compiles:
// Helpers and structs stay above the block
fn double(x: u64) -> u64 {
return x * 2u64;
}
program token_fixed.aleo {
@noupgrade
constructor() {}
mapping balances: address => u64;
storage supply: u64;
record Token {
owner: address,
amount: u64,
}
fn mint_private(receiver: address, amount: u64) -> Token {
return Token { owner: receiver, amount: double(amount) };
}
fn mint_public(public receiver: address, public amount: u64) -> Final {
return final {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
supply = supply.unwrap_or(0u64) + amount;
};
}
}
Gotchas checklist
Run through this before leo build. It catches most failures without a compile cycle.
Invalid syntax patterns:
- No
transition,async transition,async function,function, orinlineanywhere. Onlyfn,final fn, andfinal { }. - No
Future,async { }, or.await(). UseFinal,final { }, and.run(). - No
self.caller,self.signer,self.address,block.height, orblock.timestamp. Usestd::ctx::. - Cross-program calls use
foo.aleo::bar, neverfoo.aleo/bar. - No string literals, dynamic arrays, or recursion.
Missing qualifiers:
- The constructor carries exactly one of
@noupgrade,@admin,@checksum,@custom. Two annotations fail the same way as none. - A
@noupgrade,@admin, or@checksumconstructor has an empty body. A@customconstructor has a body that checks something. - An entry point with a
final { }block declares-> Final. - Every record has
owner: address. - Reads from
storageand frommapping.get()are options. Unwrap them with.unwrap()or.unwrap_or(d), or read withget_or_use. - Every cast is explicit. Leo promotes nothing, so mixing
u32andu64isETYC0372119. - Test functions are annotated
@testand live undertests/.
Program structure:
- Inside
program { }:constructor, entry pointfn,record,mapping,storage, program-scopeconst. - Outside:
import, helperfn,final fn,struct,interface, globalconst. - Imports come first, before any other item in the file.
- A helper called by an entry point must be outside the block, otherwise it is an entry point too.
- Mappings and storage are readable and writable only from
final { }, afinal fn, or a script. - The constructor body runs on chain, next to
final, not in the proof half. std::ctx::caller()and the other proof-half accessors are unavailable insidefinalor a constructor body. Capture them before the block and let it close over the variable.- No function is named after an AVM opcode. That failure appears only after a successful compile, as
ECLI0377044.
20. Performance
Field operations are the cheapest, because field is the circuit's native type. Integer operations add range checks on top, so prefer field when your problem fits it.
Poseidon2 costs the least of any hash. Keccak and SHA3 cost 10 to 100 times more.
Both branches of an if/else always execute in a circuit, and loops unroll fully, so branching depth and loop bounds translate directly into constraint count and proving time.
Packing several small values into one struct field reduces the constraint count. So does preferring get_or_use over get, which avoids paying a fee for a transaction that was always going to fail.
21. Agent workflow
When writing or modifying a Leo program, work with the compiler in the loop rather than reasoning about syntax from memory.
- Write the source, following the inside/outside layout from section 5.
- Check the structure: imports and structs outside, entry points and state inside, a constructor present.
- Run
leo build. - On failure, read the exact error, look it up in section 19, and apply the documented fix. Do not guess; Leo's error codes are specific and the message usually names the construct.
- Run
leo testfor the test files undertests/. - Run
leo run <fn> <inputs>for entry points with nofinalblock, andleo execute <fn> <inputs>when finalization needs to run.
leo fmt is a plugin in Leo 4.x and is not installed by default. Run leo plugins to see what is available before assuming a formatting step exists.
Things that will not work, in rough order of how often they come up: mapping or storage operations outside final, Leo 3.5 keywords, self.caller, structs inside the program block, a missing constructor, dynamic arrays, strings, recursion, implicit casts, Mapping::get on a possibly-missing key, records without an owner, nested records, and functions named after AVM opcodes.
22. Migrating from Leo 3.5
| Leo 3.5 | Leo 4.4 |
|---|---|
transition foo() | fn foo() inside program {} |
async transition foo() -> Future | fn foo() -> Final |
function foo() | fn foo() outside program {} |
inline foo() | fn foo() outside program {} |
async function finalize_foo() | final { ... } block inside the entry point |
Future | Final |
async { ... } | final { ... } |
f.await() | f.run() |
foo.aleo/bar | foo.aleo::bar |
self.caller | std::ctx::caller() |
self.signer | std::ctx::signer() |
self.address | std::ctx::addr() |
block.height | std::ctx::block_height() |
block.timestamp | std::ctx::block_timestamp() |
network.id | std::ctx::network_id() |
@test script foo() | @test fn foo() in tests/ |
async constructor() | constructor() |
project.json | program.json |
structs inside program {} | structs outside program {} |
helpers inside program {} | helpers outside program {} |
The mechanical part of a migration is the keyword substitution. The part worth attention is that async function took its arguments explicitly while a final { } block closes over the entry point's scope, so parameter plumbing that existed only to reach finalization can be deleted.
Deploy with aleo_deployment, and test with aleo_testing. For privacy architecture see aleo_privacy_patterns, for TypeScript integration see aleo_frontend and aleo_backend, for credits operations see aleo_staking_delegation, and for copy-pasteable programs see aleo_cookbook.