Skip to main content
Version: 4.1.0

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 a set of functions, records, mappings, and storage variables that a program must provide. Interfaces are a compile-time concept and have no impact on the bytecode generated. They are only useful as a way to enforce structural contracts — ensuring that any program claiming to implement an interface actually provides all required functions, records, mappings, and storage variables — and to enable dynamic calls, where the caller knows what it can call without knowing which program it is calling at runtime. Interfaces can be declared outside the program {} block, in a submodule, or in a library package (including library submodules).

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 = self.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,
..
}
}
note

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

When a program implements multiple interfaces (via +) — or when an interface inherits from another interface (via :) — the compiler flattens the full set of inherited members into a single membership requirement and checks for conflicts member-by-member. Two interfaces that contribute the same member name must agree on its shape; otherwise compilation fails with 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 every field the parent prototype lists, with matching type and matching visibility mode (public, private, constant) for each. A field present in the parent prototype but absent from the child is itself a conflicting_record_field error — the trailing .. lets the implementing program add fields beyond what the interface requires, not for one interface to silently omit a field another interface requires.
  • 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:

  • Interface is the interface name.
  • target is an identifier value (or field) resolved at runtime — the name of the program to call into.
  • method is 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);
note

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 if key is not present.
  • .contains(key) returns a bool.
  • .get_or_use(key, default) returns the mapped value, or default if key is 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:

bank/src/main.leo
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:::

checker/src/main.leo
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:

logger/src/main.leo
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:::

reader/src/main.leo
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);
}
note

Dynamic mapping reads are a type-checked alternative to the _dynamic_get, _dynamic_contains, and _dynamic_get_or_use intrinsics. The interface form checks that the named mapping exists on the interface and that keys, values, and defaults have matching types; the intrinsics accept arbitrary runtime identifiers and leave that responsibility to the caller. Prefer the interface form whenever 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: while dynamic calls allow a program to route logic to any compliant callee, dyn record allows that same program to accept, inspect, and forward records from programs it has never seen at compile time, without losing the safety guarantees of the type system.

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, helper fn, final fn),
  • local let bindings,
  • tuples returned from a function.

It is rejected in:

  • mapping declarations and storage variables — 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 record only flows through top-level positions.

The most common pattern is to receive a dyn record as a function parameter, read whichever fields you need (with type annotations — see below), and either forward it to a dynamic call or pass it back out as the function's return value.

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 let binding 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;
}

If the access type at runtime does not match the type declared in the program (for instance, let amount: u64 = r.balance when the underlying record's balance field is actually a field), the call halts at runtime.

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 with as. Only valid when the source value is a concrete record; casting a struct, 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. r as Token is rejected by the compiler. The runtime has no way to validate that r's actual layout matches Token's declared layout, so the language requires you to access fields one at a time with type annotations instead (see Reading Fields).

  • dyn record → static record (implicit): happens at dynamic-call sites when an interface declares a concrete record parameter. See Case D below — the call performs the implicit narrowing for you, and the call halts at runtime if the actual record layout doesn't 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.

When making a dynamic call, all record arguments are treated as dyn record under the hood, and all record return values come back as dyn record — even when the interface uses a concrete static record type. There are four cases depending on what the interface declares and what the caller provides:

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.