Interfaces, Dynamic Calls & Dynamic Records
Leo provides three related features for building composable, generic programs:
- Interfaces — declare a named contract that programs must fulfill.
- Dynamic Calls — call into a program determined at runtime.
- Dynamic Records — pass and inspect records whose structure is unknown at compile time.
Interfaces
An interface declaration specifies the functions, records, mappings, and storage variables that a program must provide. Interfaces are compile-time contracts and do not change the generated bytecode. The compiler verifies that an implementing program provides all required members. Interfaces also enable dynamic calls. The caller knows the available operations but can select the program at runtime. You can declare an interface outside the program {} block, in a submodule, or in a library package.
interface Transfer {
record Token;
fn transfer(input: Token, to: address, amount: u64) -> Token;
}
Implementing an Interface
A program implements an interface by listing it after : in the program declaration. The compiler checks that the program provides everything the interface requires.
program my_token.aleo : Transfer {
mapping balances: address => u64;
record Token {
owner: address,
balance: u64,
}
fn transfer(input: Token, to: address, amount: u64) -> Token {
return Token { owner: to, balance: input.balance - amount };
}
@noupgrade
constructor() {}
}
Implementing Multiple Interfaces
A program can implement multiple interfaces at once using +:
interface Transfer {
record Token;
fn transfer(input: Token, to: address, amount: u64) -> Token;
}
interface Pausable {
mapping paused: address => bool;
fn pause() -> (bool, Final);
}
// my_token.aleo must satisfy both Transfer and Pausable
program my_token.aleo : Transfer + Pausable {
mapping paused: address => bool;
record Token {
owner: address,
balance: u64,
}
fn transfer(input: Token, to: address, amount: u64) -> Token {
return Token { owner: to, balance: input.balance - amount };
}
fn pause() -> (bool, Final) {
let caller: address = std::ctx::caller();
return (true, final {
Mapping::set(paused, caller, true);
});
}
@noupgrade
constructor() {}
}
Record Requirements
An interface can require the existence of a record by name. The shortest form is the marker form, which only requires that the implementor declare a record with that name and imposes no field constraints:
interface Foo {
record Bar; // programs implementing Foo must declare a record called Bar
}
To additionally require specific fields, list them inside braces. The list must end with .., which marks the prototype as a partial specification — implementors may declare any additional fields beyond those listed:
interface Foo {
record Bar {
owner: address,
baz: u64,
..
}
}
A field list without a trailing .. is currently a parser error (expected `..`; fully constraining record fields in interfaces is not yet supported). Use the marker form (record Bar;) for "any record by this name", or the field list with .. for "at least these fields plus optionally more". Strictly-constraining record prototypes are reserved for a future release.
If owner is listed in a prototype, it must have type address. Listing owner is optional — every record carries an owner: address regardless.
Inheritance and Composition
Interfaces can inherit from other interfaces using ::
interface Base {
fn get_value() -> u64;
}
interface Extended : Base {
fn set_value(v: u64) -> u64;
}
Multiple interfaces can be composed together using +:
interface Transfer {
record Token;
fn transfer(input: Token, to: address, amount: u64) -> Token;
}
interface Balances {
mapping balances: address => u64;
}
// MyToken requires everything from both Transfer and Balances
interface MyToken : Transfer + Balances {}
program my_token.aleo : MyToken {
mapping balances: address => u64;
record Token {
owner: address,
amount: u64,
}
fn transfer(input: Token, to: address, amount: u64) -> Token {
return Token { owner: to, amount: input.amount - amount };
}
@noupgrade
constructor() {}
}
Composition Rules
The compiler flattens all members when a program implements multiple interfaces with +. It also flattens members when an interface inherits from another interface with :. Then, the compiler checks each member for conflicts. Two interfaces with the same member name must define the same shape. Otherwise, compilation reports a conflicting interface member error.
The matching rules per member kind:
- Functions with the same name must have identical parameter types and identical return types. Parameter names do not need to match.
- Records with the same name require the child prototype to list each field in the parent prototype. Each field must have the same type and visibility mode (
public,private, orconstant). A missing parent field causes aconflicting_record_fielderror. The trailing..lets an implementing program add fields. It does not let a child interface omit required fields. - Mappings with the same name must have identical key types and identical value types.
- Storage variables with the same name must have identical types.
If the contributions agree, the merged interface contains a single copy of the member. If they disagree, the compiler emits a conflicting_interface_member (or conflicting_record_field) error pointing at the inheriting interface or implementing program.
Cycles in interface inheritance are detected separately and rejected before flattening begins.
Interfaces and dyn record
Interface declarations are a compile-time contract: the compiler checks that an implementing program's record declarations satisfy the interface's record prototypes. A dyn record value passed at runtime is not revalidated against any interface — its actual on-chain layout could carry any fields. When you read a field from a dyn record, the read halts if the field does not exist with the requested type. See Reading Fields for the rules.
Dynamic Calls
Static calls require the callee program to be known at compile time:
// Static: the callee is fixed at compile time
fn route_transfer_static(to: address, amount: u64) {
return token_a.aleo::transfer(to, amount);
}
Dynamic calls allow the callee to be determined at runtime. The caller still knows what it can call — expressed as an interface — but not which program it is calling:
// Dynamic: any program that implements TokenStandard can be called
fn route_transfer_dynamic(token_program: identifier, to: address, amount: u64) {
return TokenStandard@(token_program)::transfer_public(to, amount);
}
The syntax is:
Interface@(target)::method(args)
where:
Interfaceis the interface name.targetis anidentifiervalue (orfield) resolved at runtime — the name of the program to call into.methodis the function to invoke.
The identifier Type
The identifier type represents a program name resolved at runtime. An identifier literal uses single-quote syntax:
let target: identifier = 'my_program';
return TokenStandard@(target)::transfer_public(to, amount);
By default the target is looked up on the aleo network. To specify a different network explicitly, pass a second identifier as a second argument:
let target: identifier = 'my_program';
let net: identifier = 'aleo';
return TokenStandard@(target, net)::transfer_public(to, amount);
The only valid network identifier currently is aleo.
Dynamic Mapping Reads
An interface that declares a mapping can also be used to read that mapping on a runtime-determined program. The syntax mirrors dynamic calls, but with a mapping name in place of a method name and a trailing read operation:
Interface@(target[, network])::mapping.get(key)
Interface@(target[, network])::mapping.contains(key)
Interface@(target[, network])::mapping.get_or_use(key, default)
.get(key)returns the mapped value. The transition fails at runtime ifkeyis not present..contains(key)returns abool..get_or_use(key, default)returns the mapped value, ordefaultifkeyis absent.
These reads are only valid inside a final fn or a final {} block — they lower to the AVM get.dynamic, contains.dynamic, and get.or_use.dynamic instructions. Dynamic writes are not supported.
bank.aleo declares the Bank interface and implements it:
export interface Bank {
mapping balances: address => u64;
}
program bank.aleo: Bank {
mapping balances: address => u64;
fn deposit(user: address, amount: u64) -> Final {
return final { do_deposit(user, amount); };
}
@noupgrade
constructor() {}
}
final fn do_deposit(user: address, amount: u64) {
let prev: u64 = Mapping::get_or_use(balances, user, 0u64);
Mapping::set(balances, user, prev + amount);
}
A second program imports bank.aleo and reads its mapping through the interface. Since the read is cross-program, the interface name is qualified with bank.aleo:::
import bank.aleo;
program checker.aleo {
mapping snapshot: address => u64;
fn read_balance(target: field, user: address) -> Final {
return final { do_read(target, user); };
}
@noupgrade
constructor() {}
}
final fn do_read(target: field, user: address) {
let present: bool = bank.aleo::Bank@(target)::balances.contains(user);
let val: u64 = bank.aleo::Bank@(target)::balances.get_or_use(user, 0u64);
Mapping::set(snapshot, user, present ? val : 0u64);
}
When the reader is inside the same program that declares the interface, drop the program qualifier — Bank@(target)::balances.get(key) rather than bank.aleo::Bank@(target)::balances.get(key).
Dynamic Storage Reads
Interfaces that declare storage variables support dynamic reads with the same pattern. Storage reads always return an Option<T>:
Interface@(target[, network])::singleton // Option<T>
Interface@(target[, network])::vector.get(index) // Option<T>
Interface@(target[, network])::vector.len() // u32
Singleton storage is read by naming the variable directly (no trailing .op(...)). Vector storage supports .get(index) (out-of-bounds reads return none) and .len() (no arguments). Storage writes through the dynamic interface are not supported — use a dynamic call to an entry function that performs the write.
logger.aleo declares the Logger interface and implements it:
export interface Logger {
storage counter: u64;
storage entries: [u64];
}
program logger.aleo: Logger {
storage counter: u64;
storage entries: [u64];
fn bump(val: u64) -> Final {
return final {
counter = counter.unwrap_or(0u64) + 1u64;
entries.push(val);
};
}
@noupgrade
constructor() {}
}
A second program imports logger.aleo and reads its storage variables through the interface. Since the read is cross-program, the interface name is qualified with logger.aleo:::
import logger.aleo;
program reader.aleo {
mapping latest: u32 => u64;
fn snapshot(target: field, i: u32) -> Final {
return final { do_snapshot(target, i); };
}
@noupgrade
constructor() {}
}
final fn do_snapshot(target: field, i: u32) {
let n: u32 = logger.aleo::Logger@(target)::entries.len();
let entry: u64? = logger.aleo::Logger@(target)::entries.get(i);
let current: u64? = logger.aleo::Logger@(target)::counter;
let stored: u64 = i < n ? entry.unwrap() : current.unwrap_or(0u64);
Mapping::set(latest, i, stored);
}
Dynamic mapping reads are a type-checked alternative to the _dynamic_get, _dynamic_contains, and _dynamic_get_or_use intrinsics. The interface form verifies that the named mapping exists. It also verifies the key, value, and default types. The intrinsics accept arbitrary runtime identifiers, so the caller must do these checks. Use the interface form when an interface is available.
Dynamic Records
A dyn record is a record whose field structure is not known at compile time. It retains all the ownership and privacy properties of a regular record:
fn get_memo(rec: dyn record) -> u64 {
return rec.memo; // fails at runtime if `rec` does not have a field named `memo` of type `u64`
}
dyn record complements dynamic calls. A dynamic call can route logic to any compliant program. A dyn record lets the caller accept, inspect, and forward records from programs not known at compile time. The type system continues to apply its safety checks.
Where dyn record May Appear
dyn record is a first-class type, but its uses are intentionally narrow. The compiler accepts it in:
- function parameters and return types (entry
fn, helperfn,final fn), - local
letbindings, - tuples returned from a function.
It is rejected in:
mappingdeclarations andstoragevariables — public on-chain storage requires a concrete schema.Optional(dyn record?) — wrap concrete data instead.- Ternary expressions (
flag ? a : b) — the AVM has no select instruction for opaque records. - Inside arrays, structs, or other composite types —
dyn recordonly flows through top-level positions.
A common pattern receives a dyn record as a function parameter. Read the required fields with type annotations. Then, send the record to a dynamic call or return it from the function.
Reading Fields
The owner field is always accessible and always typed address:
fn check_owner(r: dyn record, expected: address) -> bool {
return r.owner == expected;
}
Any other field requires a type annotation at the point of access. The compiler accepts three sources for that annotation:
-
A
letbinding with an explicit type:fn balance_via_let(r: dyn record) -> u64 {let amount: u64 = r.balance;return amount;} -
The enclosing function's declared return type (the type flows in from the return position):
fn balance_via_return(r: dyn record) -> u64 {return r.balance;} -
An explicit cast on the access:
fn balance_via_cast(r: dyn record) -> u64 {return r.balance as u64;}
Reading a field with no annotation is a compile error:
fn balance_no_annotation(r: dyn record) -> u64 {
let amount = r.balance; // error — type of `r.balance` cannot be inferred
return amount;
}
A struct-typed field works the same way as a primitive — the binding must declare the struct type:
fn meta(r: dyn record) -> Metadata {
let m: Metadata = r.meta;
return m;
}
The call halts if the runtime field type does not match the declared type. For example, this occurs when r.balance is a field, but the binding declares a u64.
Fields cannot be reassigned. r.balance = 100u64; is a compile error.
Converting Between Static Records and dyn record
Three conversion paths exist:
-
Static record →
dyn record: explicit cast withas. Only valid when the source value is a concreterecord. Casting astruct, integer, or any other non-record type is a compile error.fn make_dynamic(my_token: Token) -> dyn record {let dynamic: dyn record = my_token as dyn record;return dynamic;} -
dyn record→ static record (explicit): not supported. The compiler rejectsr as Token. The runtime cannot verify that the actual layout ofrmatches the declared layout ofToken. Access each field separately with a type annotation. See Reading Fields. -
dyn record→ static record (implicit): occurs when a dynamic-call interface declares a concrete record parameter. See Case D. The call narrows the record implicitly. The call halts if the actual record layout does not match.
For the inverse direction at call sites — the four ways static and dynamic records interact across an interface — see the four cases below.
Dynamic Records and Dynamic Calls
Regardless of what the interface signature says, dynamic calls always take dynamic records as inputs and return dynamic records as outputs.
A dynamic call treats all record arguments as dyn record. It also returns all record values as dyn record. This behavior also applies when the interface uses a concrete static record type. The interface declaration and caller input produce four cases:
Case A — Interface expects dyn record, caller has dyn record
Pass the dynamic record directly with no conversion needed.
interface ARC20 {
fn transfer_private(token: dyn record, to: address) -> dyn record;
}
program caller.aleo {
fn main(target: identifier, token: dyn record, to: address) -> dyn record {
return ARC20@(target)::transfer_private(token, to); // direct pass-through
}
@noupgrade
constructor() {}
}
Case B — Interface expects dyn record, caller has a static record
Convert the static record explicitly to dyn record using as before passing it.
interface ARC20 {
fn transfer_private(token: dyn record, to: address) -> dyn record;
}
program my_token.aleo {
record Token { owner: address, amount: u64 }
fn do_transfer(target: identifier, token: Token, to: address) -> dyn record {
return ARC20@(target)::transfer_private(token as dyn record, to); // explicit cast
}
@noupgrade
constructor() {}
}
Case C — Interface expects a static record, caller has a static record
Leo implicitly converts the static record to dyn record at the call site. Nothing extra is required from the caller, though an implicit unsafe step occurs under the hood.
interface ARC20 {
record Token;
fn transfer_private(token: Token, to: address) -> Token;
}
program caller.aleo {
record Token { owner: address, amount: u64 }
fn main(target: identifier, token: Token, to: address) -> dyn record {
return ARC20@(target)::transfer_private(token, to); // implicit conversion under the hood
}
}
Case D — Interface expects a static record, caller has a dyn record
Leo implicitly casts the dynamic record to the expected static type at the call site. The return value is still dyn record.
interface ARC20 {
record Token;
fn transfer_private(token: Token, to: address) -> Token;
}
program caller.aleo {
fn main(target: identifier, token: dyn record, to: address) -> dyn record {
return ARC20@(target)::transfer_private(token, to); // implicit cast, returns dyn record
}
@noupgrade
constructor() {}
}
In all four cases, the return type of a dynamic call that involves records is always dyn record, regardless of what the interface declares.