Introduction & Dynamic Dispatch

Within the current SnarkVM design, all calls from Aleo programs are fixed: they must call a fixed function in a fixed program, with arguments and return types of a fixed concrete type. This means that the callgraph of any function can be determined at deployment time; either deployment of the program itself or (after the recent upgradability changes) the deployment of new versions of its dependencies.

With the introduction of “dynamic dispatch” or “dynamic calls”, Aleo programs are now allowed to make data/logic dependent function calls: a call.dynamic instruction takes:

  • A program ID (a register)
  • A function ID (a register)
  • A set of inputs (registers with operands)
  • A set of outputs (registers to assign results to)

Which results in a call to the function. From the callee this looks like any regular call instruction, meaning call.dynamic is forward compatible with existing Aleo programs.

This feature by itself would have made dynamic dispatch a fairly small change: essentially a duplicate of the existing call instruction + publicly exposing the function id of the callee from the caller. Such a limited implementation would still have potentially interesting consequences, for instance: reentrancy becomes a concern, since previously any recursive call would have had infinite depth and hence be implicitly disallowed.

However, such a limited implementation would also have had very limited utility because of the Aleo type system and the way it interacts with records: the most important data type in Aleo is the record, it allows Aleo to maintain private state across transactions. They are commitments to a set of “fields” owned by a user and are a “resource” (in the “affine typesystem sense”) meaning that feeding a record to a function consumes the record and it cannot be used in future calls. A record belongs to a specific program which monopolizes the construction/destruction of the record type: if any record could be created by any program that would make them entirely useless as a way to pass private authenticated state from one call to another.

The result of this is that every record in every program is a unique type. This would make call.dynamic instructions taking record types of very limited utility: the dynamic dispatch would only be possible to the program which has the record definition. This means that, for instance, it would not be possible to create an application which operates on a general token type: for instance Decentralized Exchanges (DEXs) are hard to implement, as you would need to explicitly multiplex on every supported token type: the DEX program would depend on the listed token (record) types.

To address this, the design introduces dynamic records: a weakly typed, polymorphic record representation.

The remainder of this report first describes the call.dynamic instruction format, then details the dynamic record type that enables polymorphism over records, and finally explains how type translation bridges static and dynamic types at call boundaries.

Dynamic Dispatch Instruction

Dynamic dispatch allows programs and functions to be invoked with identifiers that are determined at runtime via the call.dynamic instruction. This capability enables advanced control flows such as conditional execution, recursion, and flexible cross-program interactions.

Instructions

A dynamic call follows the instruction format below:

call.dynamic <PROG> <NET> <FUN> with <INS> (as <IN_TYPS>) into <OUTS> (as <OUT_TYPS>);

where:

  • <PROG>: a Field element representing the program name.
  • <NET>: a Field element representing the network name.
  • <FUN>: a Field element representing the function name.
  • <INS>: the input operands.
  • <IN_TYPS>: the input operand types and associated visibility.
  • <OUTS>: the destination registers.
  • <OUT_TYPS>: the destination register types and associated visibility.

In addition to the format above, dynamic calls impose explicit constraints on the admissible data types for inputs and outputs:

  • Inputs must not be of type Record, ExternalRecord, Future, or DynamicFuture.
  • Outputs must not be of type Record, ExternalRecord, or Future.

Consider the following example:

call.dynamic 'credits' 'aleo' 'transfer_public'
    with r0 r1 r2
    (as credits.aleo/credits.record address.public u64.public)
    into r3 r4
    (as record.dynamic future.dynamic);

In this example, the dynamic call invokes the credits.aleo/transfer_public function with three inputs taken from registers r0, r1, and r2. These inputs are typed as credits.aleo/credits.record (an ExternalRecord), address.public, and u64.public, respectively. The call produces two outputs, which are written into registers r3 and r4 with types record.dynamic and future.dynamic, respectively.

Dynamic Records

The solution is “dynamic records”, which is a weakly typed version of the existing external records. An external record is simply the way to access the fields of a record type defined in another program: the external record has the same fields as the record, but is only “authenticated” whenever it is fed to the program which consumes this record type: the “external” record becomes a record at that call boundary, which is then consumed.

The new dynamic records differ from the original external records in that they are a (variable size) “bag” of (key, value) pairs, instead of a struct: similar to map[string]interface{} compared to struct{}. Since dynamic records may have a different number of fields, yet must be represented in-circuit – meaning it must have a constant-size representation. This is achieved by computing a Merkle tree commitment over the key-value pairs and injecting the root into the circuit.

This allows:

  • All dynamic records to have a constant-size in-circuit representation.
  • Fields of dynamic records to be accessed from circuits; the tree has a fixed depth.

The latter requires that the Merkle tree is honestly computed. This is achieved by proving the computation of the root of the tree in-SNARK. Concretely a dynamic record consists of:

  • owner: the (claimed) owner of the corresponding record.
  • root: the Merkle root of the record data.
  • nonce: the (claimed) nonce of the corresponding record.
  • version: the (claimed) version of the corresponding record.

Observe that dynamic records provide the same (lack) of guarantees as regular external records: namely that the fields of a dynamic record are not guaranteed to be correct, except when the program knows that the dynamic / external record is translated (more on that) to a record. Furthermore, observe that at the runtime even the dynamic record value is not tied to a specific record type, e.g. it is possible to upcast a record of type A into a dynamic record, then downcast this dynamic record to a record of type B.

Record Field Merkleization

Concretely, the root is the Merkle tree root of a binary Merkle tree with max depth of 5; support up to 32 entries. If the number of levels required is less than 5, the depth is padded by creating parents whose right child is a dummy. This is possible because at the point of casting, the concrete record type is known: meaning we know exactly how many fields we expect. Having a fixed depth tree is crucial as it allows a fixed circuit to operate on different dynamic records with varying numbers of leaves.

Leaf Hash

Each leaf corresponds to one record field, and is hashed as:

leafi=Poseidon8(entry_name || ToFields(entry_data))

Using the DynamicRecordLeafHasher constant as domain separator.

Path Hash

Each parent is formed as the hash of its left/right children:

parent=Poseidon2(left_child || right_child)

Using the DynamicRecordPathHasher constant as domain separator.

Example

For example, consider the following record:

Record {
    owner: aleo1d5hg...33ddah.private,
    amount: 100u64.private,
    token_id: 5field.private,
    _nonce: 0group.public,
    _version: 1u8.public
  }

To convert it into dynamic record, the fields will be merkleized as visualized below:

Merkle Visualization

Which then results in the following dynamic record:

Record {
    owner: aleo1d5hg...33ddah.private,
    root: ...field.public,
    _nonce: 0group.public,
    _version: 1u8.public
}

Dynamic Record Identifier

Similar to the static ExternalRecord, when traversing a call boundary (each call corresponding to SNARK) a dynamic record is produced/consumed/bound via a hiding commitment to the record: a published identifier derived from the record’s state and the “transaction view key” (tvk):

id=Poseidon8(function_idRdtvkindex)

Where:

  • function_id is the function ID of the callee
  • Rd is the serialization of the in-circuit dynamic record as field elements: ownerrootnonceversion.
  • tvk is the transition view key which produces/consumes the dynamic record
  • index is the index of the input operand or output destination that contains the dynamic record.

Dynamic Futures

Within the SnarkVM, a future represents a call to an out-of-circuit finalize function: an effect (or state assertion) to be applied beyond creating / consuming records. In practice this is simply a function identifier (to call), along with a set of arguments to invoke this function with; this is passed around as a commitment, with the preimage of the commitment provided by the function producing the future: the verifier then computes the id out-of-circuit. Program are also allowed to inspect futures which have been produced, e.g. a caller might inspect the contents of a future returned by a callee to check that some arguments match expected values.

Support for accessing the arguments of dynamic future, similar to dynamic records, was planned, but skipped for this update. As such, they currently serve solely to “pass through” an effect / check returned by a callee without having to supply a specific type.

Type Classes & Translation

Each transition (i.e., function execution) in Aleo includes a request authorization, where the program caller signs the transition request using a Schnorr signature derived from their account keys. This mechanism ensures that every transition is executed by the intended and authorized caller, and that the authorization is cryptographically bound to the transition’s public context. This authorization flow is essentially the same as its static counterpart: from the callee’s perspective a dynamic call is indistinguishable from a static call. From the caller’s perspective, when e.g. invoking a dynamic call with a dynamic record, an input ID for a dynamic record is computed and exported from the SNARK. To bridge the gap between the change in behavior from the caller (the new dynamic record input ID) and the forward compatibility of the callee, we need to “translate” the input/output IDs between the static and dynamic versions.

To enable forward compatibility, dynamic types can be implicitly translated: a caller can provide a dynamic record when the callee expects a record of a certain type. Let’s briefly outline which translations are possible and then delve into the mechanics of how soundness/privacy of this translation is achieved cryptographically.

Input Type Translation

The following table shows which caller input/argument types can be translated to which callee argument types in call.dynamic. A ✓ indicates that the call/conversion is allowed (with translation if noted), and ✗ indicates not allowed.

Input/Argument Type (Caller) Input/Argument Type (Callee)
Primitive record ExternalRecord record.dynamic
Primitive
record
ExternalRecord
record.dynamic ✓* ✓*

* Requires translation

Output Type Translation

Like for the inputs, the return/output values from a call can also be translated. Note that only a subset of the return values may be translated. The following table shows which callee output types can be returned to which caller output types in call.dynamic:

Output/Return Type (Callee) Output/Return Type (Caller)
Primitive record ExternalRecord record.dynamic future future.dynamic
Primitive
record ✓*
ExternalRecord ✓*
record.dynamic
future
future.dynamic

* Requires translation

Record Translation Circuit

Because we need to “glue” together functions operating on concrete records (“static records”) and the record typeclass (“dynamic records”), we need a way to cryptographically translate between the two, e.g. converting a commitment to a static record into a commitment to a dynamic record. This is implemented by a Translation circuit, which takes two commitments to objects of different types and enforces “correct translation”. It implements the following set of translations:

  • StaticSerialNumberDynamicRecord: A newly spent record is converted to a dynamic record (input case).
  • StaticCommitmentDynamicRecord: A newly created record is converted to a dynamic record (output case).
  • ExternalRecordDynamicRecord: An external record is converted to a dynamic record.

Note that the translation circuit is parameterized by the static record type: there is one such translation circuit for each static record type in the system. The translation behavior is controlled by two boolean flags (public inputs):

  • is_input: Determines the direction of translation:

    • true: Dynamic → Static (input case, uses serial number)
    • false: Static → Dynamic (output case, uses commitment)
  • static_is_external: Determines whether the static record is an ExternalRecord:

    • true: Uses ExternalRecord ID computation (HashPSD8)
    • false: Uses standard record ID computation (commitment or serial number)

The pseudocode for the translation circuit is as follows:

fn to_circuit_assignment_internal<RecordType, PROGRAM_ID, RECORD_NAME>(
    is_input: bool,              // true if input (dynamic->static), false if output (static->dynamic)
    static_is_external: bool,    // true if static record is ExternalRecord
    function_id: FunctionId,     // function ID of the callee
    translation_index: u16,      // index of this translation within the current batch
    input_output_index: u16,     // index of the input/output
    id_static: Field,            // public ID of static record
    id_dynamic: Field,           // public ID of dynamic record
) {
    // Private witness inputs for the circuit
    tvk: Field = private()
    gamma: Group = private()
    record_static: RecordType = private()
    record_dynamic: DynamicRecord = private()
    record_view_key: Field = private()

    // Compute static record commitment
    static_commitment = record_static.to_commitment(
        PROGRAM_ID,
        RECORD_NAME,
        record_view_key
    )

    // Compute static record serial number
    static_serial_number = compute_serial_number_from_gamma(
        gamma,
        static_commitment
    )

    // Compute ID for non-external static records
    id_static_non_external = if is_input {
        static_serial_number  // Input: use serial number
    } else {
        static_commitment     // Output: use commitment
    }

    // Compute ID for external static records
    id_static_external = HashPSD8(
        function_id,
        record_static.to_fields(),
        tvk,
        input_output_index
    )

    // Select appropriate ID computation based on external flag
    actual_id_static = if static_is_external {
        id_static_external
    } else {
        id_static_non_external
    }

    // Compute dynamic record ID
    actual_id_dynamic = record_dynamic.to_id(
        function_id,
        tvk,
        input_output_index
    )

    // Build Merkle tree over static record fields
    circuit_leaves = []
    for identifier, entry in record_static.data:
        leaf = [identifier.to_field()]
        leaf.extend(entry.to_fields())
        circuit_leaves.append(leaf)

    leaf_hasher = setup_hasher("DynamicRecordLeafHasher")
    path_hasher = setup_hasher("DynamicRecordPathHasher")
    circuit_tree = RecordMerkleTree(leaf_hasher, path_hasher, circuit_leaves)

    // Verify consistency between static and dynamic records
    assert_eq!(record_static.owner, record_dynamic.owner);
    assert_eq!(record_static.nonce, record_dynamic.nonce);
    assert_eq!(record_static.version, record_dynamic.version);
    assert_eq!(circuit_tree.root(), record_dynamic.root);
    assert_eq!(actual_id_static, id_static);
    assert_eq!(actual_id_dynamic, id_dynamic);
}

Pictorially the circuit looks as follows:

Record Translation Circuit