Skip to main content
Version: 4.4.0

Migrating from Leo 3.5 to 4.0

Leo 4.0 redesigns the language surface to make Leo's execution model transparent. Every Leo program runs in two distinct contexts: a proof context (private, off-chain, generating ZK proofs) and a finalization context (public, on-chain, modifying state). The old keywords - transition, function, async, Future - obscured this distinction. 4.0 replaces them with a minimal set (fn, final, Final) that makes each function's execution context immediately clear. The program {} block now explicitly defines a program's public interface.

This guide covers every breaking change and shows how to update your code.

Quick Reference​

3.5 Syntax4.0 Syntax
transition foo()fn foo() (inside program {})
async transition foo() -> Futurefn foo() -> Final
function foo()fn foo() (outside program {})
async function foo()final { ... } block (see below)
inline foo()fn foo() (outside program {})
Future (type)Final
async { ... }final { ... }
f.await()f.run()
@test script foo()@test fn foo() (inside program {})
async constructor()constructor()
foo.aleo/barfoo.aleo::bar

Function Declaration Keywords​

The entire function keyword vocabulary has been unified under fn. Where a function lives and what modifiers it carries determine its role.

Transitions become fn​

In 3.5, entry points were declared with transition:

// 3.5
program test.aleo {
transition mint(public amount: u64) -> Token {
return Token { owner: self.caller, amount: amount };
}
}

In 4.0, use fn inside a program {} block:

// 4.0
program test.aleo {
record Token {
owner: address,
amount: u64,
}

fn mint(public amount: u64) -> Token {
return Token { owner: std::ctx::caller(), amount: amount };
}

@noupgrade
constructor() {}
}

function becomes fn (outside program {})​

In 3.5, helper functions lived inside the program {} block with the function keyword:

// 3.5
program test.aleo {
function helper(a: u32, b: u32) -> u32 {
return a + b;
}

transition mint(public amount: u64) -> u64 {
return helper(amount, 1u32);
}
}

In 4.0, helper functions move outside the program {} block, since that block now defines the program's public interface (entry points, records, mappings). Helper functions are internal and not part of the interface:

// 4.0
fn helper(a: u32, b: u32) -> u32 {
return a + b;
}

program test.aleo {
fn mint(public amount: u32) -> u32 {
return helper(amount, 1u32);
}

@noupgrade
constructor() {}
}

inline becomes fn​

In 3.5, helper functions used the inline keyword (both inside and outside program {} blocks). In 4.0, inline is removed - all helpers become fn and those inside program blocks move outside:

// 3.5
program test.aleo {
inline helper() -> u32 {
return 42u32;
}

transition mint() -> u32 {
return helper();
}
}
// 4.0
fn helper() -> u32 {
return 42u32;
}

program test.aleo {
fn mint() -> u32 {
return helper();
}

@noupgrade
constructor() {}
}

Program Block as Interface Boundary​

In 3.5, all declarations - transitions, functions, structs, mappings - lived inside the program {} block. In 4.0, the program {} block defines the program's public interface: the entry points, records, mappings, and storage that are visible on-chain. Everything else moves outside:

Inside program {}Outside program {}
Entry point fn declarationsHelper fn definitions
record definitionsfinal fn definitions
mapping declarationsstruct definitions
interface definitions

This separation clearly shows what a program exposes on-chain. Helper functions and types at module level support the implementation but are not part of the on-chain interface.

Async/Finalize to Final​

The async/finalize pattern - the mechanism for updating public on-chain state - has been reworked around the final keyword.

Core concept​

Leo programs execute in two distinct contexts:

  • Proof context - private, off-chain execution that generates ZK proofs. Regular fn declarations run here. Inputs can be private, and the computation is not visible on-chain.
  • Finalization context - public, on-chain execution that modifies state (mappings, storage). final fn definitions and final { } blocks run here. All inputs and operations are publicly visible.

In Leo 3.5, the "async" terminology suggested asynchronous execution. However, this code ran on-chain during finalization. Leo 4.0 uses final to communicate this behavior directly. A final block or final fn runs in the finalization context.

In practice: 3.5 split on-chain logic across an async transition and a separate async function. In 4.0, on-chain logic lives inside final { } blocks within entry points.

The compiler converts a final { } block into a standalone finalization function. This function is the on-chain equivalent of the 3.5 async function. Before this conversion, Leo puts each final fn definition in the caller finalization block. Thus, a final fn reuses code during compilation and is not a standalone on-chain function.

Inline finalize​

3.5 - separate async transition and async function:

// 3.5
program token.aleo {
mapping balances: address => u64;

async transition mint(public receiver: address, public amount: u64) -> Future {
return finalize_mint(receiver, amount);
}

async function finalize_mint(public receiver: address, public amount: u64) {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
}
}

4.0 - final { } block inline:

// 4.0
program token.aleo {
mapping balances: address => u64;

fn mint(public receiver: address, public amount: u64) -> Final {
return final {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
};
}

@noupgrade
constructor() {}
}

Async blocks​

If you were using the async { } shorthand in 3.5:

// 3.5
program token.aleo {
mapping balances: address => u64;

async transition mint(public receiver: address, public amount: u64) -> Future {
let f: Future = async {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
};
return f;
}
}

Replace async with final and Future with Final:

// 4.0
program token.aleo {
mapping balances: address => u64;

fn mint(public receiver: address, public amount: u64) -> Final {
let f: Final = final {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
};
return f;
}

@noupgrade
constructor() {}
}

Reusable finalization logic with final fn​

4.0 introduces final fn as a new mechanism for deduplicating finalization logic across multiple entry points. Unlike 3.5's async function - which compiled to a standalone on-chain finalization - final fn bodies are always inlined into the caller's final { } block at compile time. They are a code reuse tool, not a direct replacement for async function.

The direct replacement for async function is the final { } block shown in the sections above. Use final fn when multiple entry points share common finalization logic:

// 4.0
final fn update_balance(receiver: address, amount: u64) {
let current: u64 = balances.get_or_use(receiver, 0u64);
balances.set(receiver, current + amount);
}

program token.aleo {
mapping balances: address => u64;

fn mint(public receiver: address, public amount: u64) -> Final {
return final {
update_balance(receiver, amount);
};
}

fn airdrop(public r1: address, public r2: address, public amount: u64) -> Final {
return final {
update_balance(r1, amount);
update_balance(r2, amount);
};
}

@noupgrade
constructor() {}
}

Here, update_balance is inlined into each caller's finalization block before the compiler lifts those blocks into standalone on-chain functions. The result is two independent on-chain finalizations that each contain the inlined logic - no shared update_balance function exists in the compiled output.

final fn definitions live outside the program {} block, since they are not part of the program's public on-chain interface.

.await() becomes .run()​

When composing futures from external program calls:

// 3.5
program example.aleo {
async transition compose(value: u8) -> Future {
let f: Future = other_program.aleo/action();
return finalize_compose(value, f);
}

async function finalize_compose(value: u8, f: Future) {
f.await();
// ... on-chain logic
}
}
// 4.0
import other_program.aleo;

program example.aleo {
fn compose(value: u8) -> Final {
let f: Final = other_program.aleo::action();
return final {
f.run();
// ... on-chain logic
};
}

@noupgrade
constructor() {}
}

Summary of keyword changes​

3.54.0
async transition foo() -> Futurefn foo() -> Final
async function foo()final { ... } block
let f: Future = async { ... }let f: Final = final { ... }
f.await()f.run()
return finalize_foo(args)return final { finalize_foo(args); }

Module-Level Struct Declarations​

In versions 3.5 and 4.0, you can declare structs inside or outside program {} blocks. In version 4.0, put structs outside program {} when they are not part of the on-chain interface. Records remain in the program block because they are part of the public interface. Structs in program {} still compile.

3.5:

program test.aleo {
struct Point {
x: i32,
y: i32,
}

transition foo(p: Point) -> Point {
return Point { x: p.y, y: p.x };
}
}

4.0 (recommended):

struct Point {
x: i32,
y: i32,
}

program test.aleo {
fn foo(p: Point) -> Point {
return Point { x: p.y, y: p.x };
}

@noupgrade
constructor() {}
}

Constructor​

The async keyword is removed from constructor declarations. In 3.5, constructors were declared with async constructor. In 4.0 the keyword is simply constructor:

// 3.5
program hello.aleo {
@noupgrade
async constructor() {}
}
// 4.0
program hello.aleo {
fn ping() {
return;
}

@noupgrade
constructor() {}
}

External Call Syntax: / becomes ::​

In 3.5, calling a function or referencing a type in another program used a / separator:

// 3.5
let result: u32 = other_program.aleo/some_fn(1u32);
let s: other_program.aleo/MyStruct = other_program.aleo/MyStruct { x: 1u32 };

In 4.0, this separator is ::, consistent with the path syntax used elsewhere in Leo:

// 4.0
let result: u32 = other_program.aleo::some_fn(1u32);
let s: other_program.aleo::MyStruct = other_program.aleo::MyStruct { x: 1u32 };

This applies to all cross-program references: function calls, type annotations, external mapping access, external storage access, and external storage vector access.

To migrate: replace program_name.aleo/ with program_name.aleo:: wherever it appears in your Leo source files.

Removed Features​

script functions, interpreter, and debugger​

The script keyword, the interpreter (leo test for script functions), and the interactive debugger (leo debug) have all been removed. The interpreter worked by traversing the AST directly - a custom evaluation model that did not reflect how code actually executes on-chain via the VM. Tests could pass in the interpreter but behave differently when compiled and run on the real VM.

In 3.5, tests used @test script inside a program block:

// 3.5
import some_program.aleo;

program test_some_program.aleo {
@test
script test_it() {
let result: u32 = some_program.aleo/main(1u32, 2u32);
assert_eq(result, 3u32);
}
}

In 4.0, script is removed. Use @test fn inside a program block in a test file (under tests/):

// 4.0
import test_program.aleo;

program test_test_program.aleo {
@test
fn test_addition() {
let result: u32 = test_program.aleo::sum(1u32, 2u32);
assert_eq(result, 3u32);
}

@test
@should_fail
fn test_overflow() {
let result: u8 = test_program.aleo::sum_u8(255u8, 1u8);
}

@noupgrade
constructor() {}
}

For end-to-end and integration testing, use the SDK directly or snarkVM as a library.

New Features​

The following are not breaking changes, but are worth knowing about when migrating.

Interfaces​

4.0 introduces interface definitions that specify contracts a program must fulfill:

interface Counter {
fn increment(amount: u64) -> u64;
}

program my_counter.aleo : Counter {
fn increment(amount: u64) -> u64 {
return amount + 1u64;
}

@noupgrade
constructor() {}
}

Interfaces can declare function signatures, record definitions, mappings, and storage variables. Programs implement an interface by listing it after : in the program declaration.

For full documentation including record requirements, interface composition, dynamic calls, and dynamic records, see Interfaces & Dynamic Dispatch.

Interfaces support inheritance:

interface Base {
fn get_value() -> u64;
}

interface Extended : Base {
fn set_value(v: u64) -> u64;
}

program test.aleo : Extended {
fn get_value() -> u64 { return 0u64; }
fn set_value(v: u64) -> u64 { return v; }

@noupgrade
constructor() {}
}

Inclusive ranges​

4.0 adds ..= for inclusive range bounds in for loops:

for i: u32 in 0u32..=10u32 {
// i takes values 0, 1, 2, ..., 10
total += i;
}