Aleo for Agents is offline at present

skill·2026-08-03·44 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: the per-program mapping ceiling is versioned consensus configuration, not a fixed constant.

Consensus versionMax mappings per program
V1 through V2031
V21 and later128

Leo storage declarations spend the same budget after lowering. A storage singleton consumes one slot, and a storage vector consumes two, one mapping for the elements and one for the length. Eighteen storage vectors therefore occupy 36 slots before a single element is pushed.

Latest-version parsing and program construction accept up to 128 mappings, so a 40-mapping program can compile and load locally while the target network still enforces 31. Deployment verification applies the consensus version active at the candidate block height, so that deployment is rejected on-chain. V21 defines the 128 ceiling; a network enforces it only once that network configures an activation height for V21. Query the target network for its active consensus version and count the lowered mapping total, storage declarations included, before you deploy.

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 program31 through V20, 128 from V21compiler error, or deployment rejection when the active version allows fewer
Max inputs per function16compiler error
Max outputs per function16compiler error
Max identifier length31 bytesEPAR0370044
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. The mapping ceiling is the one limit that moves with the consensus version, and storage declarations count toward it; see section 8.

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
EPAR0370047 "expected {"a body without braces: if, else, for, final, a function, or a constructorWrap the body in { }; see "Reading EPAR0370047"
EPAR0370056 on self.calleraccessor removedstd::ctx::caller()
ETYC0372005 "unknown function std::context::signer"wrong module path; the module is ctx, not contextstd::ctx::signer()
ETYC0372005 "unknown variable std::ctx::signer"accessor written without parenthesesstd::ctx::signer()
ETYC0372005 "unknown function <program>.aleo::ctx::signer"std:: prefix dropped, so the name resolves inside the programWrite the full std::ctx:: path
ETYC0372005 "unknown function <program>.aleo::<lib>::<item>"library <lib> is not a dependency in program.json, so the path resolves inside the programleo add --local ../<lib> <lib>
ETYC0372193 "is not accessible from this module"the library item has no export modifierWrite export fn in the library's src/lib.leo
EPAR0370044 "identifier ... is too long"an identifier longer than 31 bytesShorten the name to 31 bytes or fewer
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)
ETYC0372117 "expected anything but a mapping, tuple, or unit"a tuple, a mapping name, or a call that returns nothing passed to a hash or a commitPass one value: a struct, an array, or a single typed value
ETYC0372050, ETYC0372051, ETYC0372031, ETYC0372078, ETYC0372166a tuple used as a struct or record field, a function input, a mapping key or value, an array element, or a storage typeReplace the tuple with a struct, or split it into separate items
ETYC0372004 "could not determine the type"integer literal with no type suffixWrite 1u64, not 1
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 EPAR0370047

This code covers two different failures, so read the message rather than the number. When the message names an item, such as `struct` definitions are not allowed inside a `program { ... }` block, the item is on the wrong side of the block boundary, and the help line names the fix. When the message is only expected {, the parser wanted a block and found something else:

[EPAR0370047] Error: expected {
   ╭─[ src/main.leo:5:21 ]
 5 │         if x > 1u64 return x;

The column points at the first token after the place where { belongs. Leo has no single-statement bodies, so every one of these needs braces:

Pattern at the columnFix
if cond return x; or else return x;if cond { return x; } else { return y; }
for i: u8 in 0u8..4u8 total += 1u64;for i: u8 in 0u8..4u8 { total += 1u64; }
return final balances.set(k, v);return final { balances.set(k, v); };
fn f(x: u64) -> u64 with the body on the next line and no {Open the body with { on the signature line
final fn g(x: u64);Leo has no forward declarations; write the body
constructor()constructor() {}
fn f(x: u64) -> u64 where x > 0u64 {Leo has no where clause; move the check into the body as assert(...)

else if is legal and needs no extra braces. A for over an array, for v in arr { }, fails with the same code but a different message, expected '..' or '..=': loops take only integer ranges, so index the array inside the loop.

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.

Reading ETYC0372005

ETYC0372005 means the type checker found no item with that name. With context accessors it almost always means a wrong path, not a missing feature:

MessageMeaning
unknown function 'std::context::signer'the module is std::ctx; std::context does not exist
unknown variable 'std::ctx::signer'accessors are functions, so they need ()
unknown function '<program>.aleo::ctx::signer'without std::, the compiler looks for ctx inside your program
unknown function '<program>.aleo::<lib>::<item>'no dependency named <lib> is in program.json, so the compiler looks for <lib> inside your program
unknown function '<lib>.aleo::<item>'a library has no program ID; call it as <lib>::<item>, with no .aleo

The same rule covers every accessor in section 12: std::ctx::caller(), std::ctx::addr(), std::ctx::block_height(), and the rest. The compiler reports the error at the call site, so the line and column in the message point to the bad path.

When the message carries your own program ID in front of a path you did not write that way, the first segment of the path did not resolve. The compiler then treats the path as a module of the current program. For a library call such as retail_rules::discount, the source is usually right and the manifest is wrong: the library is missing from dependencies. The help text suggests an import, but a library takes no import statement, and import retail_rules.aleo; fails with ECMP0376006.

Reading ETYC0372117

Three unrelated messages share this code, so read the message rather than the number:

MessageMeaning
expected an arrayyou indexed a storage vector with vec[i]; it is not a circuit array
expected type 'u64', but type 'u64?' was foundan option from storage or mapping.get() reached code that wants a value
expected anything but a mapping, tuple, or unita hash or a commit received a tuple, a mapping name, or a call that returns nothing

Hash and commit functions take one value, and that value must be an integer, field, group, scalar, bool, address, a struct, an array, or a record. To hash several values, pack them into a struct, or into an array when the types match.

Tuples are narrower than they look. A tuple is legal as a local let binding and as a return type, and nowhere else. As a function input it is ETYC0372051, as a struct or record field ETYC0372050, as a mapping key or value ETYC0372031, as an array element ETYC0372078, and as a storage type ETYC0372166. A struct replaces the tuple in every one of those positions.

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;
}

Braceless if body. EPAR0370047: expected {

leo
// Before: a single-statement body without braces
fn clamp(x: u64) -> u64 {
    if x > 100u64 return 100u64;
    return x;
}
leo
// After: every branch is a braced block
fn clamp(x: u64) -> u64 {
    if x > 100u64 {
        return 100u64;
    }
    return x;
}

Braceless final block. EPAR0370047: expected {

leo
// Before: `final` is always followed by a block
fn mint(public receiver: address, public amount: u64) -> Final {
    return final balances.set(receiver, amount);
}
leo
// After: the block ends with `};` because it is the returned expression
fn mint(public receiver: address, public amount: u64) -> Final {
    return final {
        balances.set(receiver, amount);
    };
}

As a complete program that compiles:

leo
fn clamp(x: u64) -> u64 {
    if x > 100u64 {
        return 100u64;
    }
    return x;
}

program braces.aleo {
    @noupgrade
    constructor() {}

    mapping balances: address => u64;

    fn mint(public receiver: address, public amount: u64) -> Final {
        let capped: u64 = clamp(amount);
        let total: u64 = 0u64;
        for i: u8 in 0u8..4u8 {
            total += capped;
        }
        return final {
            balances.set(receiver, total);
        };
    }
}

Wrong context module. ETYC0372005: unknown function 'std::context::signer'

leo
// Before: `std::context` is not a module
fn admin_only(public admin: address) {
    assert_eq(std::context::signer(), admin);
}
leo
// After: the module is `std::ctx`, and the accessor is a call
fn admin_only(public admin: address) {
    assert_eq(std::ctx::signer(), admin);
}

Library call with no manifest dependency. ETYC0372005: unknown function `branch_lab.aleo::retail_rules::discount`

The call is correct in both states. The fix is in the manifest, not in the source.

leo
// Before and after: the same line in `branch_lab/src/main.leo`
        return retail_rules::discount(amount);
bash
# Before: only the consumer exists, so `retail_rules` resolves to nothing
leo new branch_lab

# After: create the library beside the consumer, then declare it
leo new retail_rules --library
cd branch_lab
leo add --local ../retail_rules retail_rules

leo add writes this entry into branch_lab/program.json. A build that copies only src/main.leo into a fresh project loses it, and the error returns.

json
"dependencies": [
  {
    "name": "retail_rules",
    "location": "local",
    "path": "../retail_rules",
    "edition": null
  }
]

The dependency name has no .aleo suffix, and the source has no import line for it.

Library item without export. ETYC0372193: function `retail_rules::discount` is not accessible from this module

leo
// Before: `retail_rules/src/lib.leo`, private by default
fn discount(amount: u64) -> u64 {
    return amount / 20u64;
}
leo
// After
export fn discount(amount: u64) -> u64 {
    return amount / 20u64;
}

Identifier longer than 31 bytes. EPAR0370044: identifier `test_quotes_select_resolved_library_functions` is too long (45 bytes; maximum is 31)

leo
// Before: 45 bytes
fn test_quotes_select_resolved_library_functions() {
    assert_eq(retail_rules::discount(100u64), 5u64);
}
leo
// After: 24 bytes
fn test_quotes_use_library() {
    assert_eq(retail_rules::discount(100u64), 5u64);
}

The limit covers every identifier: functions, struct fields, and local variables fail the same way. Descriptive test names reach it first, so count the bytes of a test_ name before you write the body.

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;
        };
    }
}

A tuple passed to a hash. ETYC0372117: expected anything but a mapping, tuple, or unit, but type '(field, field)' was found

leo
// Before: a hash takes one value, and a tuple is not one
        let tag: field = Poseidon2::hash_to_field((sender, amount));
leo
// After: a struct carries both parts as a single value
        let tag: field = Poseidon2::hash_to_field(Preimage { sender: sender, amount: amount });

The same message names () when the argument is a call that returns nothing, and (address => u64) when the argument is a mapping name. Both fail for the same reason: neither is a value.

As a complete program that compiles:

leo
struct Preimage {
    sender: address,
    amount: u64,
}

program tagging.aleo {
    @noupgrade
    constructor() {}

    fn mint_tag(public sender: address, public amount: u64) -> field {
        return Poseidon2::hash_to_field(Preimage { sender: sender, amount: 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::.
  • The accessor module is std::ctx. std::context::signer() fails with ETYC0372005.
  • Cross-program calls use foo.aleo::bar, never foo.aleo/bar.
  • Library calls use my_lib::item, with no .aleo and no import line.
  • No identifier is longer than 31 bytes. Long test_ function names fail with EPAR0370044.
  • No string literals, dynamic arrays, or recursion.
  • Every if, else, for, final, function, and constructor body is in braces. A braceless body fails with EPAR0370047: expected {.
  • No where clauses, forward declarations (fn f();), while loops, or match. Loops are for i: T in a..b { } over integer ranges only.
  • A tuple is a local let binding or a return type only. It is illegal as a function input, a struct or record field, an array element, a mapping key or value, and a storage type.
  • Every hash and commit call takes one value. Pack several values into a struct or an array first, never a tuple.

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.
  • Context accessors carry the full std::ctx:: path and parentheses: std::ctx::signer(), not ctx::signer() or std::ctx::signer.
  • Every library item that another package calls carries export. Without it the call fails with ETYC0372193.
  • 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.
  • Every integer literal carries a type suffix. Bare 1 fails with ETYC0372004, so write 1u64.
  • 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.
  • Every library that the source calls is in dependencies in program.json. A missing entry fails with ETYC0372005, and the message puts your own program ID in front of the library path.
  • A library is its own package, made with leo new <name> --library. Its code is in src/lib.leo, with no program { } block and no constructor.
  • A helper called by an entry point must be outside the block, otherwise it is an entry point too.
  • EPAR0370047 with a named item means a block-boundary error. EPAR0370047: expected { means a missing brace at the reported column.
  • 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.
  • A struct that exists only to pack hash inputs is still a struct, so it lives outside program { }.
  • 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