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
finalcode. - 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.
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.
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.
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);
});
}
}
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.
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.
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.
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.
# 1000 tokens at 5% (500 bps) for 10 periods
leo run compute_interest 1000u64 500u64 10u64
Recipe 6: cross-program credit transfer
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
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.
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
| Error | Cause | Fix |
|---|---|---|
| Mapping operation rejected | mapping touched in the proof half | Move it inside final { } |
EPAR0370005 on async/inline/function | Leo 3.5 keyword | Use fn, final, Final |
EPAR0370056 on self.caller | accessor removed in Leo 4.x | std::ctx::caller() |
EPAR0370047 | struct declared inside program { } | Move it outside |
ETYC0372084 | no constructor | Add @noupgrade constructor() {} |
ECLI0377044 with "reserved opcode" | entry point named after an AVM opcode | Rename it |
ETYC0372117 "expected an array" | vec[i] on a storage vector | vec.get(i).unwrap() |
| Input rejected by CLI or SDK | missing type suffix | Use typed literals such as 100u64 |
| Authorization passes for the wrong party | std::ctx::caller() used for user auth | Use std::ctx::signer() |
| Context accessor unavailable on-chain | accessor called inside final | Capture 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
- Pick the closest recipe before writing anything new.
- Keep the canonical structure: helpers and structs outside the program block, state and entry points inside, mapping work confined to
final, typed literals throughout. - Adapt in small steps and run
leo buildafter each structural change. - Add negative checks for access control, underflow, and missing state.
- Run
leo test, thenleo executefor entry points that finalize andleo runfor pure ones. - On failure, map the error to the table above and apply that fix before editing anything else.