skill·2026-03-22·26 min read

Leo Smart Contract Development

Leo Smart Contract Development — Complete Reference

1. Overview

This skill teaches you everything needed to write, compile, and reason about Leo programs for the Aleo blockchain. Leo is a statically-typed, Rust-inspired language that compiles to zero-knowledge circuits (R1CS constraints). Unlike Solidity or traditional smart contracts, Leo programs execute off-chain on the user's machine to generate a cryptographic proof, which is then verified on-chain by validators.

2. Version & Canonical Syntax

  • Target: Leo compiler >= 3.5.0
  • Canonical syntax: This skill uses the Leo 3.5.0 keywords:
    • transition — entry-point functions (off-chain, generates ZK proof)
    • async transition — entry-point functions that also have on-chain finalize logic; returns Future
    • async function — on-chain finalize functions (named finalize_<transition_name> by convention)
    • inline — helper functions defined inside the program block, automatically inlined by the compiler
  • Deployable programs require constructors: define async constructor() {} with exactly one annotation policy (@noupgrade, @admin, @checksum, or @custom)
  • There are NO free functions outside the program block — use inline functions inside the program block instead
  • When official docs disagree: Follow the Leo GitHub repository and this skill as canonical

3. Key Concepts & Glossary

  • Program: The deployment unit on Aleo (analogous to a smart contract). Declared as program name.aleo { }. Every program has a unique program ID (e.g., my_token.aleo).
  • Record: An encrypted, UTXO-like private state object. Records must have an owner: address field. Records are consumed (destroyed) when used as inputs and created as new outputs — they are never mutated in place. Only the owner can decrypt a record using their view key. Records cannot contain other records.
  • Struct: A composite data type similar to a Rust struct. Structs are transparent (not encrypted) and can be used in both transitions and finalize blocks. Structs cannot contain records.
  • Mapping: A public, on-chain key-value store. Mappings can ONLY be read/written inside async function (finalize) blocks. Mapping values are globally visible to everyone.
  • Storage Variable: A public singleton value stored on-chain (storage counter: u64;). Only accessible in async function (finalize) blocks.
  • Storage Vector: A public dynamic-length array stored on-chain (storage items: [u64];). Only accessible in async function (finalize) blocks.
  • Finalize / async function: A separate async function finalize_NAME(...) block defines on-chain logic that runs AFTER the zero-knowledge proof is verified. This is the ONLY place where mappings, storage variables, storage vectors, block.height, block.timestamp, ChaCha::rand_*, snark.verify, and snark.verify_batch are available. Values needed in finalize must be passed explicitly as arguments from the async transition via return finalize_NAME(arg1, arg2, ...);.
  • Transition: An entry-point function inside a program scope. Declared with transition. When the transition needs on-chain finalize logic, use async transition ... -> Future and a paired async function finalize_NAME(...).
  • Future (return type): When an async transition has a paired async function, its return type includes Future. This signals that the transition has on-chain side effects.
  • Inline function: A helper function declared with inline inside the program block. Automatically inlined by the compiler, cannot be called externally.
  • Serial Number / Nullifier: When a record is consumed, a unique serial number (nullifier) is published on-chain to prevent double-spending, without revealing which record was consumed.
  • self.caller: The address of the immediate caller. If Program A calls Program B, then inside Program B, self.caller is Program A's address.
  • self.signer: The address of the original transaction signer (always a user's address, never a program). In cross-program calls, self.signer is the human who initiated the top-level transaction.
  • Microcredits: The smallest denomination of Aleo credits. 1 credit = 1,000,000 microcredits.

4. The Dual Execution Model

This is the single most important concept in Leo development. Understanding it prevents the most common class of errors.

┌─────────────────────────────────────────────────────────────┐
│                    USER'S MACHINE (Off-Chain)                │
│                                                              │
│  1. User calls a function with private/public inputs         │
│  2. Function body executes locally                           │
│  3. Records are consumed (inputs) and created (outputs)      │
│  4. A zero-knowledge proof is generated                      │
│  5. Proof + encrypted outputs are sent to the network        │
│                                                              │
│  Available here: self.caller, self.signer, record ops,       │
│                  all computation, struct manipulation          │
│  NOT available: block.height, ChaCha::rand_*, mappings       │
└─────────────────────────────┬───────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    VALIDATORS (On-Chain)                      │
│                                                              │
│  6. Validators verify the proof (fast, no re-execution)      │
│  7. If the transition has a paired `async function`           │
│     (finalize), validators execute it on-chain to update      │
│     public state                                              │
│  8. If finalize fails, the ENTIRE transaction is rejected     │
│     (but the fee is still consumed)                          │
│                                                              │
│  Available here: Mapping::get/set/remove/contains,           │
│                  storage vars/vecs, block.height,             │
│                  block.timestamp, ChaCha::rand_*,            │
│                  snark.verify, snark.verify_batch             │
│  NOT available: private inputs, records, direct self.caller  │
│                 (must be passed as args from transition)       │
└─────────────────────────────────────────────────────────────┘

Critical rule: Mapping operations (Mapping::get, Mapping::set, Mapping::remove, Mapping::contains) are ONLY allowed inside async function (finalize) blocks. Attempting to use them in the transition body will cause a compiler error.

Critical rule: Values from the transition body that you need in the finalize function must be passed explicitly as arguments via return finalize_NAME(arg1, arg2, ...);. The async function cannot access transition parameters directly. self.caller is only available in the transition body — capture it in a variable and pass it to finalize.

Critical rule: If an async function (finalize) fails (assertion failure, overflow, missing mapping key with get instead of get_or_use), the ENTIRE transaction is rejected. The base fee is still consumed. Use get_or_use instead of get to avoid unexpected failures.

5. Program Structure

Every Leo program follows this structure:

leo
// 1. Imports (optional)
import credits.aleo;

// 2. Program declaration (required)
program my_program.aleo {
    @noupgrade
    async constructor() {}

    // 3. Struct definitions
    struct TokenInfo {
        name: field,
        symbol: field,
        decimals: u8,
    }

    // 4. Record definitions
    record Token {
        owner: address,        // mandatory field
        amount: u64,
        info: TokenInfo,       // records can contain structs
    }

    // 5. Mapping definitions (public on-chain key-value stores)
    mapping balances: address => u64;
    mapping total_supply: bool => u64;

    // 6. Storage variables (public on-chain singletons)
    storage supply: u64;

    // 7. Storage vectors (public on-chain dynamic arrays)
    storage holders: [address];

    // 8. Inline helper functions
    inline double(x: u64) -> u64 {
        return x * 2u64;
    }

    // 9. Entry-point transitions (off-chain only)
    transition mint_private(receiver: address, amount: u64) -> Token {
        return Token {
            owner: receiver,
            amount: double(amount),
            info: TokenInfo {
                name: 0field,
                symbol: 0field,
                decimals: 6u8,
            },
        };
    }

    // 10. Async transitions (off-chain + on-chain finalize)
    async transition mint(public receiver: address, public amount: u64) -> Future {
        // Off-chain logic here
        let caller: address = self.caller;
        return finalize_mint(caller, receiver, amount);
    }

    // 11. Finalize functions (on-chain logic)
    async function finalize_mint(caller: address, receiver: address, amount: u64) {
        // On-chain logic here
        let current: u64 = Mapping::get_or_use(balances, receiver, 0u64);
        Mapping::set(balances, receiver, current + amount);
    }
}

Constructor Requirement (Leo 3.5.0)

Deployable and test programs on current networks require a constructor with exactly one policy annotation. Use one of @noupgrade, @admin, @checksum, or @custom immediately above async constructor() {}.

6. Complete Type System

Primitive Types

TypeDescriptionLiteral ExampleRange
boolBooleantrue, falsetrue/false
u8Unsigned 8-bit integer42u80 to 255
u16Unsigned 16-bit integer1000u160 to 65,535
u32Unsigned 32-bit integer100000u320 to 4,294,967,295
u64Unsigned 64-bit integer1000000u640 to 18,446,744,073,709,551,615
u128Unsigned 128-bit integer340u1280 to 2^128 - 1
i8Signed 8-bit integer-42i8-128 to 127
i16Signed 16-bit integer-1000i16-32,768 to 32,767
i32Signed 32-bit integer-100000i32-2^31 to 2^31 - 1
i64Signed 64-bit integer-50i64-2^63 to 2^63 - 1
i128Signed 128-bit integer-340i128-2^127 to 2^127 - 1
fieldField element (native ZK type)1field0 to p-1 (prime)
groupElliptic curve point0groupGroup elements
scalarScalar for group ops1scalarScalar field
addressAleo addressaleo1...Valid addresses
signatureTransaction signatureValid signatures

CRITICAL: Strings do NOT exist in Leo. The compiler will reject any attempt to use string literals. If you need text data, encode it as field elements or [field; N] arrays.

CRITICAL: Dynamic arrays in transition/function values do NOT exist. All in-circuit arrays must have compile-time known sizes: [u32; 8] is valid, Vec<u32> does not exist. (storage items: [T]; vectors are a separate on-chain storage feature.)

Composite Types

Arrays (fixed-size):

leo
let arr: [u32; 4] = [1u32, 2u32, 3u32, 4u32];
let element: u32 = arr[0u32];  // Access by index
let nested: [[u32; 2]; 3] = [[1u32, 2u32], [3u32, 4u32], [5u32, 6u32]];

Maximum array size: 32 elements (but check current compiler limits).

Tuples:

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

Optional Types (T?):

leo
let some_val: u64? = 42u64;        // Has a value
let no_val: u64? = none;            // No value

// Unwrap (panics if none)
let val: u64 = some_val.unwrap();

// Unwrap with default (safe)
let val: u64 = no_val.unwrap_or(0u64);

Type Casting

Use the as keyword for explicit type casting. Implicit type promotion does NOT exist.

leo
let x: u32 = 42u32;
let y: u64 = x as u64;       // Widening cast (safe)
let z: field = x as field;    // Integer to field (safe)
let w: u8 = x as u8;         // Narrowing cast (may truncate)

Castable types: All integer types can cast to each other and to field. field can cast to integer types. group and scalar have limited casting.

Arithmetic — Checked vs. Wrapping

Leo uses checked arithmetic by default. Overflow or underflow causes the transaction to fail (not silent wrapping like in C).

leo
let a: u8 = 255u8;
// let b: u8 = a + 1u8;           // FAILS: overflow
let b: u8 = a.add_wrapped(1u8);   // OK: wraps to 0u8

// Available wrapping variants:
// add_wrapped, sub_wrapped, mul_wrapped, div_wrapped
// pow_wrapped, shl_wrapped, shr_wrapped

Security note: Checked arithmetic is a security feature. Use it by default. Only use _wrapped variants when you explicitly need wrapping behavior (e.g., hash computations).

7. Records — The Core Privacy Primitive

Records are Aleo's UTXO-like encrypted state objects. They are the fundamental mechanism for private data.

Record Lifecycle

CREATE → ENCRYPT → STORE ON-CHAIN → DECRYPT (owner only) → CONSUME → NULLIFY
  1. A function creates a new record as output
  2. The record is encrypted to the owner's address
  3. The encrypted record (ciphertext) is stored on-chain
  4. Only the owner can decrypt it using their view key
  5. When used as input to another function, the record is consumed (destroyed)
  6. A serial number (nullifier) is published to prevent double-spending
  7. New records are created as outputs

Record Definition and Usage

leo
program token.aleo {
    @noupgrade
    async constructor() {}

    // Record definition — owner field is MANDATORY
    record Token {
        owner: address,     // REQUIRED: who can spend this record
        amount: u64,
    }

    // Mint: creates a new record
    transition mint(receiver: address, amount: u64) -> Token {
        return Token {
            owner: receiver,
            amount: amount,
        };
    }

    // Transfer: consumes one record, creates two new records
    transition transfer(
        sender_token: Token,       // This record is CONSUMED (destroyed)
        receiver: address,
        amount: u64
    ) -> (Token, Token) {          // Two new records are CREATED
        let remaining: u64 = sender_token.amount - amount;

        // New record for receiver
        let receiver_token: Token = Token {
            owner: receiver,
            amount: amount,
        };

        // Change back to sender
        let change_token: Token = Token {
            owner: sender_token.owner,
            amount: remaining,
        };

        return (receiver_token, change_token);
    }
}

Record Visibility Annotations

Record fields can have visibility modifiers:

leo
record Token {
    owner: address,            // Always private (encrypted)
    private amount: u64,       // Private (encrypted) — default
    public token_type: u8,     // Public (visible on-chain in transaction)
}

Record Restrictions

  • Records MUST have an owner: address field
  • Records CANNOT contain other records (but CAN contain structs)
  • Records CANNOT be used as mapping keys or values
  • Records are only available in transition bodies, NOT in async function (finalize) blocks

8. Mappings — Public On-Chain State

Mappings are public key-value stores that persist on-chain. All mapping values are globally visible.

Mapping Operations

All mapping operations MUST be inside async function (finalize) blocks.

leo
program counter.aleo {
    @noupgrade
    async constructor() {}

    mapping counts: address => u64;

    async transition increment(public amount: u64) -> Future {
        let caller: address = self.caller;
        return finalize_increment(caller, amount);
    }

    async function finalize_increment(caller: address, amount: u64) {
        // Static syntax (preferred)
        let current: u64 = Mapping::get_or_use(counts, caller, 0u64);
        Mapping::set(counts, caller, current + amount);

        // Method syntax (also valid)
        // let current: u64 = counts.get_or_use(caller, 0u64);
        // counts.set(caller, current + amount);
    }
}

Complete Mapping API:

OperationStatic SyntaxMethod SyntaxDescription
Get (fails if missing)Mapping::get(map, key)map.get(key)Returns value or fails transaction
Get with defaultMapping::get_or_use(map, key, default)map.get_or_use(key, default)Returns value or default
SetMapping::set(map, key, value)map.set(key, value)Creates or updates entry
RemoveMapping::remove(map, key)map.remove(key)Deletes entry
ContainsMapping::contains(map, key)map.contains(key)Returns bool

CRITICAL: Use get_or_use instead of get whenever possible. If get is called on a missing key, the ENTIRE transaction fails and the fee is still consumed.

CRITICAL: There is no has_key function. Use Mapping::contains.

Mapping Limits

  • Maximum 31 mappings per program
  • Keys and values must be primitive types, structs, or arrays (NOT records)

9. Storage Variables and Vectors

Storage variables and vectors provide additional public on-chain state options.

Storage Variables (Singletons)

leo
program config.aleo {
    @noupgrade
    async constructor() {}

    storage admin: address;
    storage paused: bool;
    storage counter: u64;

    async transition initialize(public addr: address) -> Future {
        return finalize_initialize(addr);
    }

    async function finalize_initialize(addr: address) {
        admin = addr;              // Set value
        paused = false;            // Set value
        counter = 0u64;            // Set value
    }

    async transition increment() -> Future {
        return finalize_increment();
    }

    async function finalize_increment() {
        let current: u64 = counter.unwrap_or(0u64);
        counter = current + 1u64;
        // counter = none;         // Clear value
    }
}

Storage Vectors (Dynamic On-Chain Arrays)

leo
program registry.aleo {
    @noupgrade
    async constructor() {}

    storage members: [address];

    async transition add_member(public member: address) -> Future {
        return finalize_add_member(member);
    }

    async function finalize_add_member(member: address) {
        members.push(member);
    }

    async transition remove_last() -> Future {
        return finalize_remove_last();
    }

    async function finalize_remove_last() {
        members.pop();
    }

    async transition get_member(public index: u32) -> Future {
        return finalize_get_member(index);
    }

    async function finalize_get_member(index: u32) {
        let member: address? = members.get(index);
        // Also available:
        // members.set(index, new_value);
        // members.len();
        // members.clear();
        // members.swap_remove(index);
    }
}

10. Control Flow

Conditionals

leo
// If/else
if amount > 100u64 {
    // ...
} else if amount > 50u64 {
    // ...
} else {
    // ...
}

// Ternary
let fee: u64 = condition ? 10u64 : 5u64;

PERFORMANCE WARNING: In ZK circuits, BOTH branches of an if/else are ALWAYS evaluated. The result is selected by a multiplexer. This means deep/complex branching doubles, quadruples, etc. the circuit cost. Prefer ternary expressions over deep if/else chains when possible.

Loops

leo
// For loops — bounds MUST be compile-time constants
for i: u8 in 0u8..10u8 {
    // Loop body
}

// This is INVALID — variable bounds are forbidden:
// let n: u8 = get_count();
// for i: u8 in 0u8..n { }  // COMPILER ERROR

// Workaround: use a fixed upper bound with early termination
for i: u64 in 0u64..100u64 {
    if i < actual_count {
        // Do work
    }
}

PERFORMANCE WARNING: All for loops are fully unrolled at compile time. A for i in 0..100 generates 100x the constraints of a single iteration. Minimize loop bounds.

CRITICAL: Recursion is strictly forbidden. The call graph must be a DAG (directed acyclic graph). The compiler will reject cyclic_function_dependency.

Assertions

leo
assert(condition);                    // Fails transaction if false
assert_eq(a, b);                      // Fails if a != b
assert_neq(a, b);                     // Fails if a == b

11. Function Variants and Call Rules

Function Types

TypeKeywordWhereCan Be Called ByGenerates Proof
Entry point (off-chain only)transitionInside programExternal (users/programs)Yes
Entry point (off-chain + on-chain)async transitionInside programExternal (users/programs)Yes + finalize
On-chain finalize logicasync functionInside programPaired async transition onlyNo (runs on-chain)
Helper (inlined)inlineInside programTransitions, other inlinesNo (inlined into caller)

inline functions are defined INSIDE the program { } block. They are automatically inlined by the compiler and cannot be called externally. Use them for reusable helper logic.

Call Rules

  • transition / async transition can call: other transitions (cross-program) and inline functions
  • inline functions can call: other inline functions
  • async function (finalize) is called implicitly by its paired async transition — you do not call it directly
  • Recursive calls are FORBIDDEN (cycle detection at compile time)
leo
program example.aleo {
    @noupgrade
    async constructor() {}

    // Inline helper — defined INSIDE the program block
    // Automatically inlined, cannot be called externally
    inline double(x: u64) -> u64 {
        return x * 2u64;
    }

    // Entry point (callable externally)
    transition compute(public input: u64) -> u64 {
        return double(input); // Calls inline function
    }
}

12. Context Accessors

AccessorAvailable InDescription
self.callerTransition bodyAddress of immediate caller (could be a program in cross-program calls)
self.signerTransition bodyAddress of original transaction signer (always a user)
self.addressTransition bodyAddress of the current program
block.heightasync function ONLYCurrent block height (u32)
block.timestampasync function ONLYCurrent block timestamp (i64, Unix epoch)
network.idAnywhereNetwork identifier
group::GENAnywhereGenerator of the elliptic curve group

SECURITY: In cross-program calls, self.caller returns the calling PROGRAM's address, not the user's. Use self.signer for user-level access control. Use self.caller for program-level access control.

leo
transition admin_only() {
    // CORRECT: checks the original user
    assert_eq(self.signer, ADMIN_ADDRESS);

    // DANGEROUS: in cross-program calls, self.caller is the
    // intermediate program, not the user
    // assert_eq(self.caller, ADMIN_ADDRESS); // Don't do this for user auth
}

13. Cryptographic Operations

Hash Functions

FunctionOutputConstraint CostWhen to Use
Poseidon2::hash_to_field(val)fieldLowestDefault choice for all hashing
Poseidon4::hash_to_field(val)fieldLowAlternative Poseidon variant
Poseidon8::hash_to_field(val)fieldLowAlternative Poseidon variant
BHP256::hash_to_field(val)fieldLow-MediumGeneral-purpose hashing
BHP512::hash_to_field(val)fieldMediumLarger input hashing
BHP768::hash_to_field(val)fieldMediumLarger input hashing
BHP1024::hash_to_field(val)fieldMediumLarger input hashing
Pedersen64::hash_to_field(val)fieldLowSmall input hashing
Pedersen128::hash_to_field(val)fieldLowSmall input hashing
Keccak256::hash_to_field(val)fieldVery HighEVM compatibility ONLY
Keccak384::hash_to_field(val)fieldVery HighEVM compatibility ONLY
Keccak512::hash_to_field(val)fieldVery HighEVM compatibility ONLY
SHA3_256::hash_to_field(val)fieldVery HighEVM compatibility ONLY
SHA3_384::hash_to_field(val)fieldVery HighEVM compatibility ONLY
SHA3_512::hash_to_field(val)fieldVery HighEVM compatibility ONLY

PERFORMANCE: Always prefer Poseidon2::hash_to_field as the default hash function. It is native to the field and consumes the fewest constraints. Only use Keccak/SHA3 if you need strict Ethereum compatibility — they are enormously expensive in ZK circuits.

Hash functions also have variants: hash_to_address, hash_to_group, hash_to_scalar, hash_to_u8, hash_to_u16, ..., hash_to_u128, hash_to_i8, ..., hash_to_i128.

leo
// Hash a struct to a field element
let hash: field = Poseidon2::hash_to_field(my_struct);

// Hash to an address
let addr: address = BHP256::hash_to_address(my_data);

Commitment Functions

Commitments use a random scalar as a blinding factor for hiding:

leo
// Pedersen64 accepts inputs up to 64 bits (u32, u16, u8, bool, etc.)
let small_commit: field = Pedersen64::commit_to_field(value_u32, salt);
// BHP256 accepts larger inputs including field, structs, etc.
let large_commit: group = BHP256::commit_to_group(value_field, salt);
// salt must be type `scalar`

Signature Verification

leo
let is_valid: bool = signature::verify(sig, addr, message);

Randomness (Finalize-Only)

Random number generation is ONLY available inside async function (finalize) blocks:

leo
async transition lottery() -> Future {
    return finalize_lottery();
}

async function finalize_lottery() {
    let random_bool: bool = ChaCha::rand_bool();
    let random_field: field = ChaCha::rand_field();
    let random_u32: u32 = ChaCha::rand_u32();
    let random_u64: u64 = ChaCha::rand_u64();
    // Also: rand_u8, rand_u16, rand_u128,
    //        rand_i8, rand_i16, rand_i32, rand_i64, rand_i128,
    //        rand_scalar, rand_address
}

14. Cross-Program Calls and Imports

Adding Dependencies

bash
# Add a network dependency (deployed program)
leo add credits.aleo --network

# Add a local dependency
leo add my_lib.aleo --local ../my_lib

# Remove a dependency
leo remove my_lib.aleo

Import and Call Syntax

leo
import credits.aleo;

program my_app.aleo {
    @noupgrade
    async constructor() {}

    mapping payments: field => u64;

    async transition pay(public receiver: address, public amount: u64) -> Future {
        // Cross-program calls return a Future that must be awaited in the finalize function.
        let payer: address = self.caller;
        let transfer: Future = credits.aleo/transfer_public(receiver, amount);
        let payment_id: field = BHP256::hash_to_field(payer);
        return finalize_pay(transfer, payment_id, amount);
    }

    async function finalize_pay(transfer: Future, payment_id: field, amount: u64) {
        transfer.await();
        let total: u64 = Mapping::get_or_use(payments, payment_id, 0u64);
        Mapping::set(payments, payment_id, total + amount);
    }
}

program.json Dependencies

json
{
    "program": "my_app.aleo",
    "version": "0.1.0",
    "description": "",
    "license": "MIT",
    "dependencies": [
        {
            "name": "credits.aleo",
            "location": "network",
            "network": "testnet"
        },
        {
            "name": "my_lib.aleo",
            "location": "local",
            "path": "../my_lib"
        }
    ]
}

15. Hard Limits and Constraints

ConstraintLimitError if Exceeded
Max program size~100 KB compiledBuild failure
Max mappings per program31Compiler error
Max inputs per function16Compiler error
Max outputs per function16Compiler error
Max array elements32 (check current)Compiler error
Loop boundsMust be compile-time constantsCompiler error
RecursionStrictly forbiddencyclic_function_dependency
Dynamic in-circuit arrays (Vec<T>)Not supportedCompiler error
StringsNot supportedstrings_are_not_supported
Records containing recordsNot supportedstruct_or_record_cannot_contain_record

16. Complete End-to-End Example: Private Token

This is the most important pattern in Aleo development — a token with both public and private balances and conversion between them.

leo
program token.aleo {
    @noupgrade
    async constructor() {}

    // Private state: encrypted records
    record Token {
        owner: address,
        amount: u64,
    }

    // Public state: on-chain mappings
    mapping account: address => u64;

    // === PUBLIC OPERATIONS ===

    // Mint tokens to a public balance
    async transition mint_public(
        public receiver: address,
        public amount: u64,
    ) -> Future {
        return finalize_mint_public(receiver, amount);
    }

    async function finalize_mint_public(receiver: address, amount: u64) {
        let current_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
        Mapping::set(account, receiver, current_amount + amount);
    }

    // Transfer tokens between public balances
    async transition transfer_public(
        public receiver: address,
        public amount: u64,
    ) -> Future {
        let sender: address = self.caller;
        return finalize_transfer_public(sender, receiver, amount);
    }

    async function finalize_transfer_public(sender: address, receiver: address, amount: u64) {
        let sender_amount: u64 = Mapping::get_or_use(account, sender, 0u64);
        assert(sender_amount >= amount);
        Mapping::set(account, sender, sender_amount - amount);

        let receiver_amount: u64 = Mapping::get_or_use(account, receiver, 0u64);
        Mapping::set(account, receiver, receiver_amount + amount);
    }

    // === PRIVATE OPERATIONS ===

    // Mint tokens as a private record
    transition mint_private(
        receiver: address,
        amount: u64,
    ) -> Token {
        return Token {
            owner: receiver,
            amount: amount,
        };
    }

    // Transfer tokens between private records (UTXO model)
    transition transfer_private(
        sender_token: Token,       // consumed
        receiver: address,
        amount: u64,
    ) -> (Token, Token) {          // two new records created
        let change: u64 = sender_token.amount - amount;

        let to_receiver: Token = Token {
            owner: receiver,
            amount: amount,
        };

        let to_sender: Token = Token {
            owner: sender_token.owner,
            amount: change,
        };

        return (to_receiver, to_sender);
    }

    // === BRIDGE OPERATIONS (public <-> private) ===

    // Convert public balance to private record (shielding)
    async transition transfer_public_to_private(
        public receiver: address,
        public amount: u64,
    ) -> (Token, Future) {
        let new_record: Token = Token {
            owner: receiver,
            amount: amount,
        };

        let sender: address = self.caller;
        return (new_record, finalize_pub_to_priv(sender, amount));
    }

    async function finalize_pub_to_priv(sender: address, amount: u64) {
        let current: u64 = Mapping::get_or_use(account, sender, 0u64);
        assert(current >= amount);
        Mapping::set(account, sender, current - amount);
    }

    // Convert private record to public balance (unshielding)
    async transition transfer_private_to_public(
        sender_token: Token,        // consumed
        public receiver: address,
        public amount: u64,
    ) -> (Token, Future) {
        let change: u64 = sender_token.amount - amount;

        let change_record: Token = Token {
            owner: sender_token.owner,
            amount: change,
        };

        return (change_record, finalize_priv_to_pub(receiver, amount));
    }

    async function finalize_priv_to_pub(receiver: address, amount: u64) {
        let current: u64 = Mapping::get_or_use(account, receiver, 0u64);
        Mapping::set(account, receiver, current + amount);
    }
}

17. Complete End-to-End Example: Tic-Tac-Toe

This demonstrates pure struct-based state, control flow, and deterministic logic without mappings:

leo
program tictactoe.aleo {
    @noupgrade
    async constructor() {}

    struct Board {
        r1: Row,
        r2: Row,
        r3: Row,
    }

    struct Row {
        c1: u8,
        c2: u8,
        c3: u8,
    }

    // Create a new empty board
    transition new() -> Board {
        return Board {
            r1: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
            r2: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
            r3: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
        };
    }

    // Make a move: player 1 or 2, row 1-3, col 1-3
    transition make_move(
        board: Board,
        player: u8,
        row: u8,
        col: u8,
    ) -> Board {
        assert(player == 1u8 || player == 2u8);
        assert(row >= 1u8 && row <= 3u8);
        assert(col >= 1u8 && col <= 3u8);

        // Get current row
        let current_row: Row = row == 1u8 ? board.r1
            : (row == 2u8 ? board.r2 : board.r3);

        // Check cell is empty
        let current_val: u8 = col == 1u8 ? current_row.c1
            : (col == 2u8 ? current_row.c2 : current_row.c3);
        assert_eq(current_val, 0u8);

        // Set the cell
        let new_row: Row = Row {
            c1: col == 1u8 ? player : current_row.c1,
            c2: col == 2u8 ? player : current_row.c2,
            c3: col == 3u8 ? player : current_row.c3,
        };

        return Board {
            r1: row == 1u8 ? new_row : board.r1,
            r2: row == 2u8 ? new_row : board.r2,
            r3: row == 3u8 ? new_row : board.r3,
        };
    }
}

18. Common Compiler Errors and Fixes

ErrorCauseFix
"Mapping operations are only allowed in async functions"Mapping::get/set used outside async functionMove mapping operations into an async function (finalize) block
"Record must have an owner field of type address"Record missing owner: addressAdd owner: address as first field
"struct_or_record_cannot_contain_record"A struct/record contains a recordUse a struct for the inner type, or store the record's commitment/owner address instead
"Loop bound must be constant"Variable used as loop boundUse a compile-time constant literal
"cyclic_function_dependency"Recursive function call detectedFlatten the logic or use bounded for loops
"strings_are_not_supported"String literal usedEncode text as field elements or [field; N]
"too_many_finalize_arguments"Too many arguments passed to async functionCombine parameters into a struct
Type mismatch (e.g., u32 vs u64)Implicit type promotion attemptedUse explicit as cast: x as u64
Overflow/underflow in checked arithmeticResult exceeds type rangeUse _wrapped variant or larger type
"array_too_large"Array exceeds max sizeReduce array size or use multiple smaller arrays
"too_many_mappings"More than 31 mappingsConsolidate mappings using struct values

19. Performance Considerations

  1. Field operations are cheapest: field is the native ZK type. Use field when your problem fits.
  2. Integer operations add range checks: Every integer operation requires additional constraints for range verification. Prefer field over integers when possible.
  3. Poseidon is cheapest for hashing: Poseidon2::hash_to_field is the most constraint-efficient. Keccak/SHA3 are 10-100x more expensive.
  4. Both branches evaluate: In ZK, if/else evaluates both branches. Minimize branching depth.
  5. Loops unroll completely: A for i in 0..N generates Nx the constraints. Minimize N.
  6. Struct packing: Combine small values into fewer struct fields to reduce constraint count.
  7. Prefer get_or_use over get: get on missing keys fails the transaction (wastes fee). get_or_use is always safe.

20. Agent SOP: Compiler-in-the-Loop Workflow

When asked to write or modify a Leo program, follow this exact loop:

  1. Write/Edit: Generate the .leo source code following all rules in this skill.
  2. Check Structure: Verify the program has: proper imports, all types defined before use, correct transition/async transition/async function pairing.
  3. Format: Run leo fmt --check. If it fails, run leo fmt.
  4. Compile: Run leo build.
  5. Error Handling: If compilation fails:
    • Read the EXACT error message. Do NOT guess the fix.
    • Look up the error in the "Common Compiler Errors" table above.
    • Apply the documented fix.
    • Re-run leo build.
  6. Test: Run leo test to verify correctness.
  7. Local Execute: Use leo run <function_name> <inputs> only for pure transition paths. For async transitions that rely on finalize logic, use leo execute <function_name> <inputs>.
  8. Proof Generate: Run leo execute <function_name> <inputs> to generate proof and verify.

NEVER:

  • Use dynamic arrays, strings, or recursion
  • Put mapping operations outside async function (finalize)
  • Assume implicit type casting
  • Use Mapping::get without handling the missing-key case (prefer get_or_use)
  • Create records without an owner: address field
  • Nest records inside other records or structs
  • Define functions outside the program block (use inline inside the program block for helpers)
  • Omit constructor policy annotation on deployable/test programs
  • Use self.caller inside async function — capture it in the transition and pass it as an argument
  • After writing your program, deploy it using aleo_deployment
  • Test thoroughly using aleo_testing
  • For privacy architecture, see aleo_privacy_patterns
  • For TypeScript SDK integration, see aleo_frontend and aleo_backend
  • For staking/credits operations, see aleo_staking_delegation
  • For complete copy-pasteable programs, see aleo_cookbook

Sources