← BACK
feature image
About Jibril Saffi, Senior Compiler Engineer - Provable

Senior Compiler Engineer

Interfaces and Dynamic Dispatch in Leo

Every serious smart contract platform eventually needs two things: a way to define shared contracts between programs, and a way to call programs you don't know about at compile time. Ethereum got there early with the ABI and CALL. Solana has CPIs. CosmWasm passes messages. Move sidesteps the question with generics and witness patterns but ultimately arrives at the same place.

Aleo, until now, had neither. Every cross-program call in Leo was static, the target hardcoded in source, baked into the circuit. This was a known limitation and a deliberate one: in a zero-knowledge VM, dynamism has real cost, and we weren't going to ship a half-considered version of it. With Leo v4, we're shipping interfaces, dynamic dispatch and dynamic records.

This post explains what we built and why we made the choices we did.

Interfaces

An interface in Leo declares a contract that a program must satisfy: which functions it exposes, which records it defines, which mappings it maintains.

interface TokenStandard {     record Token;     fn transfer(input: Token, to: address, amount: u64) -> Token; } program my_token.aleo : TokenStandard {     record Token {         owner: address,         balance: u64,     }     fn transfer(input: Token, to: address, amount: u64) -> Token { /* ... */ } }

The : TokenStandard annotation is a compile-time obligation. The compiler rejects the program if any required member is missing or has the wrong signature, similar to how Solidity enforces is IERC20, but extended to records and mappings, not just function signatures.

Interfaces compose: interface Transfer {     record Token;     fn transfer(input: Token, to: address, amount: u64) -> Token; } interface Balances {     mapping balances: address => u64; } interface Token : Transfer + Balances {} program my_token.aleo : Token { /* ... */ }

They can also constrain record shapes without fully specifying them. The .. syntax means "these fields are required, the implementor may add more":

interface TokenStandard {     record Token {         owner: address,         balance: u64,         ..     } }

This is structural subtyping for on-chain records. Records in Aleo are not structs, they are units of private state, closer to UTXOs than account storage. They are consumed and produced by transitions, encrypted by default, owned by a specific address. The .. syntax lets interfaces constrain their shape without fully specifying it. Solidity has no equivalent, structs are nominally typed. Go has structural subtyping for method sets. TypeScript has it for object shapes. Leo has it for records.

The reason to build this is straightforward. Ethereum's ERC ecosystem works because of shared interface definitions, IERC20, IERC721, and so on. Solidity enforces these at compile time: if your contract declares is IERC20, every function must be implemented, and callers get type-checked call sites through interface imports. Leo interfaces serve the same purpose, but their scope extends to records and mappings because those are part of the public surface of an Aleo program. Records are ownable, encrypted state objects that cross program boundaries. If your interface says a program produces a Token record, the compiler should verify that it does. The motivating use case: a token standard where any compliant token, public, private, or both, can be used interchangeably by any DEX, lending protocol, or bridge.

Dynamic Dispatch

Interfaces define contracts. Dynamic dispatch lets you use them at runtime.

Previously, every Leo cross-program call looked like this:

fn swap(to: address, amount: u64) {     return token_a.aleo/transfer(to, amount); }

The target is a literal. To support a second token, you write a second function. To support an arbitrary token, you redeploy. This is exactly the problem a universal swap contract faces, it needs to route transfers to whatever token the user brings, without knowing at deploy time which tokens will exist.

Dynamic dispatch changes this. We are also introducing a new syntax for dynamic calls:

// Static, target known at compile time token_a.aleo/transfer(to, amount); // Dynamic, target resolved at runtime, constrained by interface TokenStandard@(token_program)::transfer(to, amount);

The dynamic call target can be specified in two ways. As a raw field element, when the program identifier is already known as a field:

TokenStandard@(42field)::transfer(to, amount);

Or using identifier, a new type we've added to Leo based on a corresponding addition to the Aleo VM. Identifiers are human-readable names that compile down to field elements, written with single-quote syntax:

TokenStandard@('foo', 'aleo')::transfer(to, amount);

Here 'foo' and 'aleo' are identifier values that correspond to program foo.aleo. The two forms are equivalent, identifier pairs are always convertible to field elements, but the second is what most code will use in practice.

We are also considering a simplified form that omits the network identifier and would look like this:

A design note. On the EVM, dynamic dispatch is the default. Every external call goes through the ABI, resolved at runtime. This is convenient but has costs, proxy bugs, storage slot collisions, the entire class of problems that EIP-1967 exists to mitigate. Solidity developers have spent years building guardrails around unconstrained dynamism.

We went the other way. Static dispatch is the default. Dynamic dispatch is opt-in and interface-constrained. This is the same choice Rust makes: monomorphization by default, dyn Trait when you need it. The reasoning is the same, in a system where abstraction has concrete cost (binary size for Rust, circuit complexity for ZK), you want the programmer to choose dynamism deliberately. And when they do choose it, the interface bounds the blast radius.

Dynamic Records

Dynamic dispatch solves the problem for logic. Data has the same problem. If your function accepts a Token record, it must know the record's exact layout at compile time. But if you're dispatching dynamically to an unknown program, you don't know its record layout.

dyn record is that mechanism. It is a fixed-size representation of any record, the owner, a Merkle root of the record's entries, the nonce, and a version tag. Field access goes through Merkle proof verification:

fn try_get_memo(rec: dyn record) -> u64 {     if rec.has_field('memo') {         let memo: u64 = rec.memo as u64;         return memo;     } else {         // ...     } }

Field access is verified by Merkle proof inside the circuit. If the field doesn't exist or has the wrong type, the proof fails.

No other smart contract platform does this, because no other platform has this problem. Ethereum stores structs in flat storage slots, there's nothing to Merkle-ize. Move's resource model is statically typed end to end. CosmWasm serializes everything to JSON and trusts the caller to parse it correctly. We have private records with encrypted fields inside zero-knowledge proofs, and we need to pass them between programs that don't share type definitions. That's what dyn record does.

The combination of all three features, interfaces, dynamic dispatch, dynamic records, means you can write this:

fn route_private_transfer(     token_program: field,     input: dyn record,     to: address,     amount: u64 ) -> dyn record { TokenStandard@(token_program)::transfer_private(input, to, amount); }

One function that routes a **private** token transfer to any compliant program, with any record shape, resolved entirely at runtime.

Where This Leaves Us

The DeFi ecosystem on Ethereum exists because of three primitives: a shared token standard, runtime dispatch, and permissionless composability. Every major protocol, Uniswap, Aave, Compound, Curve, depends on all three. Chains that lack any one of them struggle to build equivalent ecosystems.

Leo now has the language-level machinery for all three, with privacy the EVM cannot provide and interface enforcement that extends beyond function signatures to records and mappings. We are not done, the dynamic dispatch implementation is ongoing and the exact syntax is still being refined. But the architecture is set and the hard problems are solved.

Interfaces, dynamic dispatch and dynamic records ship with Leo v4. And the first major consumer of all three is already in progress: ARC-20, Aleo's interoperable token standard. More on that soon.

About Jibril Saffi, Senior Compiler Engineer - Provable

Senior Compiler Engineer