Aleo for Agents is offline at present

skill·2026-08-03·10 min read

Aleo Cookbook: Complete Recipes

Aleo cookbook: complete recipes

Overview

Complete, ready-to-use programs organized by task. Every recipe here compiles against Leo 4.4.0 as written. Use them as starting templates.

Version and canonical syntax

Target: Leo compiler >= 4.4.0.

Recipes use fn for entry points and a final { } block wherever public state changes. Mapping and storage operations only appear inside final code. Helper functions and structs live outside the program block. Every program declares a constructor.

If you are adapting an older example, aleo_smart_contracts section 22 has the complete Leo 3.5 translation table.

Quick glossary

  • Record: encrypted private state object with a mandatory owner: address.
  • Mapping: public on-chain key-value state, reachable only from final code.
  • Shielding: converting a public mapping balance into a private record.
  • Unshielding: converting a private record back into a public mapping balance.
  • Nullifier: the public spent marker emitted when a record is consumed.

Recipe 1: on-chain counter

A public counter per address. The smallest useful final block.

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

Note there is no get_count entry point. Reading public state does not need a transaction: query the mapping directly over the REST API, which costs nothing and returns immediately.

bash
leo build
leo execute increment 5u64 --broadcast

# Read the value back without a transaction
curl "https://api.explorer.provable.com/v1/testnet/program/counter.aleo/mapping/counts/$ADDRESS"

Recipe 2: private token with public and private bridges

A token with both public and private balances plus conversion between them. This is the pattern most Aleo applications build on.

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

    record Token {
        owner: address,
        amount: u64,
    }

    mapping account: address => u64;

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

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

    // Shielding: public balance becomes a private record
    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);
        });
    }

    // Unshielding: private record becomes a public balance
    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);
        });
    }
}
bash
leo build

# Mint 1000 tokens publicly
leo execute mint_public "aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9" 1000u64 --broadcast

# Move 200 between public balances
leo execute transfer_public "aleo19y2eyc2cycvdqmycqam60l6uexvfj468xcet65jnnzc5pn8g9ufqg2clp2" 200u64 --broadcast

# Shield 100 into a private record
leo execute transfer_public_to_private "aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9" 100u64 --broadcast

The two bridge entry points return a tuple of a record and a Final, so a single call produces a private output and a public state change together.


Recipe 3: access-controlled mint

Only one address may mint. Storing the admin in a const keeps the check inside the circuit and costs nothing on-chain.

leo
const ADMIN: address = aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9;

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

    record Token {
        owner: address,
        amount: u64,
    }

    storage total_supply: u64;

    fn mint(receiver: address, amount: u64) -> (Token, Final) {
        // signer, not caller: caller would be the intermediate program
        // in a composed call, which is not who you want to authorize
        assert_eq(std::ctx::signer(), ADMIN);

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

        return (token, final {
            let supply: u64 = total_supply.unwrap_or(0u64);
            total_supply = supply + amount;
        });
    }
}

A storage singleton fits a global counter better than the mapping total_supply: bool => u64 idiom that older Leo code uses, since there was never more than one key.

If you want to change the admin later without redeploying, hold it in a storage admin: address; instead and gate the mint on that value inside the final block. That trades a cheap circuit-time constant for mutable on-chain state, so make the choice deliberately.


Recipe 4: lottery with on-chain randomness

ChaCha::rand_* is finalization-only, because randomness sampled off-chain would be chosen by whoever generates the proof.

leo
program lottery.aleo {
    @noupgrade
    constructor() {}

    mapping winners: u8 => address;
    storage winner_count: u8;

    fn enter() -> Final {
        let player: address = std::ctx::caller();
        return final {
            let won: bool = ChaCha::rand_bool();

            if won {
                let count: u8 = winner_count.unwrap_or(0u8);
                assert(count < 5u8);
                winners.set(count, player);
                winner_count = count + 1u8;
            }
        };
    }
}

The winner count is a storage singleton rather than a one-key mapping, which is what the old bool => u8 idiom was standing in for.


Recipe 5: bounded interest calculation

Leo unrolls loops at compile time, so the bound is a constant and the guard skips the unwanted iterations.

leo
fn calculate_interest(principal: u64, rate_bps: u64, periods: u64) -> u64 {
    let result: u64 = principal;

    for i: u64 in 0u64..100u64 {
        if i < periods {
            let interest: u64 = result * rate_bps / 10000u64;
            result = result + interest;
        }
    }

    return result;
}

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

    fn compute_interest(
        public principal: u64,
        public rate_bps: u64,
        public periods: u64,
    ) -> u64 {
        assert(periods <= 100u64);
        return calculate_interest(principal, rate_bps, periods);
    }
}

The helper lives outside the program block, which is where Leo 4.x requires helpers. The 100-iteration bound is paid for in constraints on every call regardless of periods, so pick the smallest bound the application can live with.

bash
# 1000 tokens at 5% (500 bps) for 10 periods
leo run compute_interest 1000u64 500u64 10u64

Recipe 6: cross-program credit transfer

leo
import credits.aleo;

program payment.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);

        // Calling a stateful entry point in another program returns a Final
        let transfer: Final = credits.aleo::transfer_public(receiver, amount);

        return final {
            // Run the callee's finalization first
            transfer.run();

            let total: u64 = payments.get_or_use(payment_id, 0u64);
            payments.set(payment_id, total + amount);
        };
    }
}

Two things changed here in Leo 4.x. The separator is :: rather than /, and the returned handle is a Final you run() instead of a Future you await().


Recipe 7: signature verification

leo
program verifier.aleo {
    @noupgrade
    constructor() {}

    fn verify_message(
        sig: signature,
        signer_addr: address,
        message: field,
    ) -> bool {
        let is_valid: bool = signature::verify(sig, signer_addr, message);
        assert(is_valid);
        return is_valid;
    }
}

The method form sig.verify(signer_addr, message) compiles to the same thing. Sign the message off-chain with leo account sign or the SDK's Account.sign.


Recipe 8: members registry with a storage vector

Storage vectors are on-chain dynamic lists. Under the hood Leo lowers storage members: [address]; into an element mapping and a length mapping.

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

    storage members: [address];

    fn register() -> Final {
        let who: address = std::ctx::signer();
        return final {
            members.push(who);
        };
    }

    fn replace_at(public index: u32, public replacement: address) -> Final {
        return final {
            let count: u32 = members.len();
            assert(index < count);

            // get() returns an optional, so unwrap it
            let existing: address = members.get(index).unwrap();
            assert_neq(existing, replacement);

            members.set(index, replacement);
        };
    }
}

Two constraints worth knowing. Storage vectors have no index syntax, so members[i] fails with ETYC0372117 and you use .get(i) plus an unwrap. And .get(i) returns T?, not T.

The reserved-opcode trap lives here too. Naming this recipe's first entry point add would compile to Aleo instructions cleanly, then fail with 'add' is a reserved opcode. and ECLI0377044 when snarkVM parses the output. The error says nothing about your function name. Avoid add, sub, mul, div, hash, get, set, and cast as entry-point names.


Common recipe errors

ErrorCauseFix
Mapping operation rejectedmapping touched in the proof halfMove it inside final { }
EPAR0370005 on async/inline/functionLeo 3.5 keywordUse fn, final, Final
EPAR0370056 on self.calleraccessor removed in Leo 4.xstd::ctx::caller()
EPAR0370047struct declared inside program { }Move it outside
ETYC0372084no constructorAdd @noupgrade constructor() {}
ECLI0377044 with "reserved opcode"entry point named after an AVM opcodeRename it
ETYC0372117 "expected an array"vec[i] on a storage vectorvec.get(i).unwrap()
Input rejected by CLI or SDKmissing type suffixUse typed literals such as 100u64
Authorization passes for the wrong partystd::ctx::caller() used for user authUse std::ctx::signer()
Context accessor unavailable on-chainaccessor called inside finalCapture it in the proof half and close over the variable

Security notes

Keep sensitive values in records rather than mappings; everything in a mapping is world-readable forever.

Use std::ctx::signer() for user authentication. std::ctx::caller() returns the calling program in a composed call, which makes it the wrong check for "is this the admin" and the right check for "was I called by the program I expect".

Never paste production private keys into examples or fixtures. Treat commit-reveal salts and record plaintext as sensitive off-chain data.

Choose the constructor policy deliberately. @noupgrade tells callers your code cannot change; @admin tells them one key can change it. Both are visible on-chain.

Performance notes

Start from the smallest recipe that matches the task. Prefer Poseidon2::hash_to_field unless you need Ethereum-compatible hashes. Keep loop bounds tight, because Leo unrolls them and you pay for every iteration whether or not the guard runs the body. Use get_or_use rather than get so a missing key does not cost a rejected transaction's fee.

Full language reference: aleo_smart_contracts. Privacy architecture: aleo_privacy_patterns. Deployment: aleo_deployment. Testing: aleo_testing.

Agent workflow for adapting a recipe

  1. Pick the closest recipe before writing anything new.
  2. Keep the canonical structure: helpers and structs outside the program block, state and entry points inside, mapping work confined to final, typed literals throughout.
  3. Adapt in small steps and run leo build after each structural change.
  4. Add negative checks for access control, underflow, and missing state.
  5. Run leo test, then leo execute for entry points that finalize and leo run for pure ones.
  6. On failure, map the error to the table above and apply that fix before editing anything else.

Sources