Aleo for Agents is offline at present

skill·2026-08-03·33 min read

Leo Smart Contract Development

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:

  • fn inside program { } declares an entry point. It runs off-chain and generates a proof.
  • fn outside program { } 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.
  • Final is the return type of an entry point that has a final { } block.
  • final fn outside program { } declares reusable finalization logic, inlined into each caller's final block 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 as my_token.aleo.
  • Record: an encrypted, UTXO-like private state object. Records must have an owner: address field. 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 program block. Structs are transparent, not encrypted.
  • Mapping: a public on-chain key-value store, declared inside the program block. Readable and writable only inside final code. 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_*, and snark.verify live 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 declarationshelper fn definitions
record definitionsfinal fn definitions
mapping declarationsstruct definitions
storage variables and vectorsinterface definitions
program-scope constglobal const

Putting a struct inside the program block is a parse error in 4.4 (EPAR0370047), and so is inline or function anywhere.

leo
// 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.

AnnotationMeaning
@noupgradeCode 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.
@customThe 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

TypeDescriptionLiteralRange
boolBooleantrue, falsetrue/false
u8Unsigned 8-bit42u80 to 255
u16Unsigned 16-bit1000u160 to 65,535
u32Unsigned 32-bit100000u320 to 4,294,967,295
u64Unsigned 64-bit1000000u640 to 2^64 - 1
u128Unsigned 128-bit340u1280 to 2^128 - 1
i8Signed 8-bit-42i8-128 to 127
i16Signed 16-bit-1000i16-32,768 to 32,767
i32Signed 32-bit-100000i32-2^31 to 2^31 - 1
i64Signed 64-bit-50i64-2^63 to 2^63 - 1
i128Signed 128-bit-340i128-2^127 to 2^127 - 1
fieldField element, the native ZK type1field0 to p-1
groupElliptic curve point0groupGroup elements
scalarScalar for group operations1scalarScalar field
addressAleo addressaleo1...Valid addresses
signatureSchnorr signatureValid 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:

leo
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:

leo
let t: (u32, bool) = (42u32, true);
let first: u32 = t.0;
let second: bool = t.1;

Optionals (T?):

leo
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.

leo
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.

leo
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.

leo
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

leo
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

leo
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);
        };
    }
}
OperationMethod syntaxStatic syntaxBehaviour
Getm.get(key)Mapping::get(m, key)Returns the value or fails the transaction
Get with defaultm.get_or_use(key, d)Mapping::get_or_use(m, key, d)Returns the value or d
Setm.set(key, value)Mapping::set(m, key, value)Creates or updates
Removem.remove(key)Mapping::remove(m, key)Deletes the entry
Containsm.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:

leo
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.

leo
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.

leo
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

leo
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.

leo
// 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.

leo
assert(condition);
assert_eq(a, b);
assert_neq(a, b);

11. Function kinds and call rules

KindDeclarationWhereCallable byProof?
Entry pointfn foo()inside programusers and other programsyes
Entry point with state changefn foo() -> Finalinside programusers and other programsyes, plus on-chain finalization
Helperfn foo()outside programentry points and other helpersinlined into caller
Finalization helperfinal fn foo()outside programfinal blocks and other final fninlined into caller's finalization
Constructorconstructor()inside programthe network, at deploy and upgradeno

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.

leo
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.

AccessorAvailable inReturns
std::ctx::caller()proof halfaddress of the immediate caller, which may be a program
std::ctx::signer()proof halfaddress of the original transaction signer, always a user
std::ctx::addr()proof halfaddress of the current program
std::ctx::block_height()final onlycurrent block height, u32
std::ctx::block_timestamp()final onlycurrent block timestamp, i64 Unix epoch
std::ctx::network_id()anywherenetwork identifier, u16
std::ctx::edition()anywherecurrent program edition
std::ctx::program_owner()anywheredeploying address
group::GENanywheregenerator 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.

leo
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

FunctionOutputCostUse when
Poseidon2::hash_to_field(v)fieldlowestdefault for everything
Poseidon4::hash_to_field(v)fieldlowalternative Poseidon width
Poseidon8::hash_to_field(v)fieldlowalternative Poseidon width
BHP256::hash_to_field(v)fieldlow to mediumgeneral purpose
BHP512/768/1024::hash_to_field(v)fieldmediumlarger inputs
Pedersen64::hash_to_field(v)fieldlowinputs up to 64 bits
Pedersen128::hash_to_field(v)fieldlowinputs up to 128 bits
Keccak256/384/512::hash_to_field(v)fieldvery highEthereum compatibility only
SHA3_256/384/512::hash_to_field(v)fieldvery highEthereum 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.

leo
let h: field = Poseidon2::hash_to_field(my_struct);
let a: address = BHP256::hash_to_address(my_data);

Commitments take a scalar blinding factor:

leo
// 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:

leo
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:

leo
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

bash
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.

leo
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.

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.

leo
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:

leo
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

ConstraintLimitError
Max compiled program size2000 KBbuild failure
Max mappings per program31compiler error
Max inputs per function16compiler error
Max outputs per function16compiler error
Loop boundscompile-time constants onlycompiler error
Recursionforbiddencyclic_function_dependency
Dynamic in-circuit arraysnot supportedcompiler error
Stringsnot supportedstrings_are_not_supported
Records inside recordsnot supportedstruct_or_record_cannot_contain_record
ConstructormandatoryETYC0372084
Function named after an AVM opcoderejected at bytecode parseECLI0377044

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.

leo
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.

leo
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

ErrorCauseFix
EPAR0370005 on transition, async, function, inline, importitem is not legal directly inside program { }Use fn; move import above the block; see section 22
EPAR0370047 inside the blockstruct or interface inside program { }Move the definition outside the block
EPAR0370047 at top levelmapping or storage outside program { }Move the declaration inside the block
EPAR0370056 on self.calleraccessor removedstd::ctx::caller()
ETYC0372084no 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 twoCarry exactly one of @noupgrade, @admin, @checksum, @custom
ETYC0372156 "must be empty"@noupgrade, @admin, or @checksum constructor with a bodyEmpty the body; the compiler writes the policy code
ETYC0372156 "cannot be empty"@custom constructor with no bodyWrite 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 blockRead a mapping or std::ctx::block_height() instead
ETYC0372003 "expected type ()"final { } block with no -> Final return typeDeclare -> Final
ETYC0372043 "cannot call a local entry point fn"helper fn defined inside program { }Move the helper outside the block
ETYC0372019record has no owner: address fieldAdd the field
ETYC0372067 and ETYC0372034mapping or storage touched in the proof halfMove the operation into final { } or a final fn
ETYC0372117 "expected an array"vec[i] on a storage vectorUse vec.get(i).unwrap()
ETYC0372117 optional mismatch (u64? vs u64)unwrapped get() or storage readAdd .unwrap() or .unwrap_or(d)
ETYC0372119operands of an operator have different typesCast explicitly with as
ECLI0377044 after a clean compilefunction named after an AVM opcodeRename the function; see section 11
ECMP0376006import not declared in the manifestAdd the dependency to program.json
struct_or_record_cannot_contain_recordnested recordStore a commitment or the owner address instead
Loop bound not constantvariable boundUse a literal bound with a guard
cyclic_function_dependencyrecursionFlatten into bounded loops
strings_are_not_supportedstring literalEncode as field or [field; N]
Overflow in checked arithmeticresult out of rangeUse 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:

MessageMeaning
A constructor must have exactly one of the following annotationsthe constructor carries no annotation, or two
A 'noupgrade', 'admin', or 'checksum' constructor must be emptyyou 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

leo
// Before: `transition` was removed in Leo 4.0
transition mint(public receiver: address, public amount: u64) -> u64 {
    return amount;
}
leo
// After
fn mint(public receiver: address, public amount: u64) -> u64 {
    return amount;
}

Split finalization. EPAR0370005, found async

leo
// 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);
}
leo
// 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)>'

leo
// Before: the body returns a Final, the signature promises nothing
fn mint(public receiver: address, public amount: u64) {
    return final { balances.set(receiver, amount); };
}
leo
// 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

leo
// 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);
    }
leo
// 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

leo
// Before
    constructor() {}
leo
// After
    @noupgrade
    constructor() {}

Policy annotation with a body. ETYC0372156: A 'noupgrade', 'admin', or 'checksum' constructor must be empty

leo
// Before: @admin already states the policy, and the body repeats it
    @admin(address="aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px")
    constructor() {
        assert_eq(std::ctx::caller(), ADMIN);
    }
leo
// After: the compiler inserts the check
    @admin(address="aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px")
    constructor() {}

@custom with an empty body. ETYC0372156: A 'custom' constructor cannot be empty

leo
// Before: @custom promises a policy that is not there
    @custom
    constructor() {}
leo
// 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:

leo
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

leo
// Before: a storage read is an option, not a value
        supply = supply + amount;
leo
// After
        supply = supply.unwrap_or(0u64) + amount;

Every fix above applied at once, as one program that compiles:

leo
// 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, or inline anywhere. Only fn, final fn, and final { }.
  • No Future, async { }, or .await(). Use Final, final { }, and .run().
  • No self.caller, self.signer, self.address, block.height, or block.timestamp. Use std::ctx::.
  • Cross-program calls use foo.aleo::bar, never foo.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 @checksum constructor has an empty body. A @custom constructor has a body that checks something.
  • An entry point with a final { } block declares -> Final.
  • Every record has owner: address.
  • Reads from storage and from mapping.get() are options. Unwrap them with .unwrap() or .unwrap_or(d), or read with get_or_use.
  • Every cast is explicit. Leo promotes nothing, so mixing u32 and u64 is ETYC0372119.
  • Test functions are annotated @test and live under tests/.

Program structure:

  • Inside program { }: constructor, entry point fn, record, mapping, storage, program-scope const.
  • Outside: import, helper fn, final fn, struct, interface, global const.
  • 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 { }, a final 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 inside final or 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.

  1. Write the source, following the inside/outside layout from section 5.
  2. Check the structure: imports and structs outside, entry points and state inside, a constructor present.
  3. Run leo build.
  4. 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.
  5. Run leo test for the test files under tests/.
  6. Run leo run <fn> <inputs> for entry points with no final block, and leo 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.5Leo 4.4
transition foo()fn foo() inside program {}
async transition foo() -> Futurefn foo() -> Final
function foo()fn foo() outside program {}
inline foo()fn foo() outside program {}
async function finalize_foo()final { ... } block inside the entry point
FutureFinal
async { ... }final { ... }
f.await()f.run()
foo.aleo/barfoo.aleo::bar
self.callerstd::ctx::caller()
self.signerstd::ctx::signer()
self.addressstd::ctx::addr()
block.heightstd::ctx::block_height()
block.timestampstd::ctx::block_timestamp()
network.idstd::ctx::network_id()
@test script foo()@test fn foo() in tests/
async constructor()constructor()
project.jsonprogram.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.

Sources