Aleo for Agents is offline at present

blog·2026-10-14·10 min read

Deep Dive: Why a Valid Aleo Request Does Not Belong to Every Valid Authorization

A cryptographic signature answers a narrow question: did the signer approve these request fields?

Aleo needs a harder answer. Did the signer approve this request as part of this execution tree?

Those questions sound close enough to merge in your head. They are not. A callee request can be correctly signed, contain valid inputs, name a real function, and still belong to another authorization. If the runtime accepts that request merely because it verifies on its own, the authorization boundary has quietly moved from "one approved execution" to "a bag of individually approved calls."

That is a bad boundary.

Four recent snarkVM changes make the intended boundary much sharper. PR #3523 checks callee signer commitments during execution. PR #3526 rejects authorizations containing requests execution never consumes. PR #3528 adds the signer-commitment check to Stack::authorize_requests. PR #3529 rejects a root request carrying the dynamic-call marker.

Read together, they describe one security rule: an Aleo authorization is a closed request tree rooted in one signing context. Every request must belong to that root, occupy a valid structural position, and be consumed by the execution it authorizes.

The authorization is a tree

An Aleo execution can cross program boundaries. A root function may call a second function, which may make another call. Authorization therefore cannot stop at the entry point. The signer authorizes the root request and the callee requests that execution is expected to use.

snarkVM stores those requests in pre-order traversal: parent first, followed by its descendants. The serialized object may look like a sequence, but its meaning is a tree.

Consider a wallet authorizing this call graph:

text
market.aleo/buy
  token_registry.aleo/transfer_private
    fee_policy.aleo/check_discount
  receipt.aleo/mint

The request order is conceptually:

text
R0 = market.aleo/buy
R1 = token_registry.aleo/transfer_private
R2 = fee_policy.aleo/check_discount
R3 = receipt.aleo/mint

Execution consumes R0, reaches the first call site, consumes R1, and then consumes R2 while running that callee. It later returns to the root and consumes R3.

Ordering alone does not prove membership. A request copied from another authorization can still sit in the right array position. Its signature can verify too. The runtime needs a value shared by every member of the intended tree and unavailable as a reusable cross-authorization credential.

Aleo uses the root transition view key for that job.

Root binding with scm

Each request carries a signer commitment, normally written as scm. The binding discussed in PR #3528 is:

text
scm = Hash(signer || root_tvk)

root_tvk is the transition view key of the root request. Every callee in the authorization is checked against that same root value.

Suppose Alice creates two authorizations for the same root locator:

text
Authorization A
  root_tvk = tvk_A
  A0: market.aleo/buy
  A1: token_registry.aleo/transfer_private

Authorization B
  root_tvk = tvk_B
  B0: market.aleo/buy
  B1: token_registry.aleo/transfer_private

Alice is the signer in both cases. Both transfer requests can be valid. Their signer commitments differ:

text
A1.scm = Hash(Alice || tvk_A)
B1.scm = Hash(Alice || tvk_B)

Now imagine an assembler replaces A1 with B1. Independent request verification may accept B1: Alice signed it, the locator is valid, and its own cryptographic checks pass. Root binding rejects it because execution expects Hash(Alice || tvk_A), while the imported request carries the commitment derived from tvk_B.

The signature still verifies. Tree membership does not.

That distinction is the whole issue.

Why the signer is included

A commitment to root_tvk alone would identify an authorization context, but the signer is part of the semantic claim Aleo needs to preserve. The commitment says that a named signer belongs to the root context represented by that view key.

Keeping both values in the hash also makes the validation rule explicit at the point where requests are assembled or consumed. The verifier is not inferring identity from a nearby object and hoping the relationship survives refactoring. It recomputes the commitment from the signer and the one accepted root key.

Why every path must check

PR #3528 exists because execute and authorize_requests did not enforce the same invariant. Execution rejected a foreign callee after the earlier fix, while request authorization could accept the same collection.

That mismatch matters. Validation APIs often become security boundaries outside their original design. Wallets use them before displaying approval screens. Provers may accept prebuilt authorization data from another process. Agent systems may construct requests in one service and execute them in another.

If one API says "valid authorization" while the execution path later says "foreign callee," callers gain a dangerous half-guarantee. Some will treat authorization-time success as final. Others will discover the mismatch only after spending time on proving or remote coordination.

My preference is strict and boring: every public path that claims to validate an authorization should enforce the same root-binding rule. A weaker preparatory validator needs a name that admits what it does, such as verify_individual_request_signatures.

Shape matters too

Root binding answers whether requests belong to one signing context. It does not prove that the request sequence has a valid shape or that execution used all of it.

PRs #3526 and #3529 close those structural gaps.

Unused requests are not harmless

Assume an authorization contains the correct requests for a purchase, followed by one extra request:

text
R0 = market.aleo/buy
R1 = token_registry.aleo/transfer_private
R2 = admin.aleo/rotate_operator

Execution of R0 and its transfer may finish without touching R2. Every consumed request was valid. The extra request may also carry the correct signer commitment.

Accepting the authorization would still be wrong.

An unused request creates ambiguity about what the authorization object means. One implementation might ignore the tail. Another might retain it for a later phase. A proving service could report that the whole authorization was accepted even though part of it had no relationship to the executed call graph.

PR #3526 makes completion part of validity: after recursive execution returns, no requests may remain. The consumer must empty the authorization queue.

Formally, if A is the ordered request sequence and Consume(root, A) returns the requests used while executing the root, acceptance requires:

text
Consume(root, A) = A

A prefix match is insufficient. So is a set-membership check. Order and total consumption both matter because the sequence encodes the traversal of the call tree.

The practical win is larger than tidier validation. Complete consumption prevents authorization smuggling, catches stale requests left after a program update, and exposes disagreement between the authorizer's predicted call graph and the executor's real path.

Agent developers should care about the last case. An agent may simulate a call, cache the generated authorization, then submit it after state changes alter a branch. Rejecting unused requests turns that drift into an explicit failure instead of silently accepting a broader object than execution needed.

A dynamic root has no parent

Dynamic dispatch changes how a callee target is selected. The marker associated with a dynamic request only makes sense when execution reaches a dynamic call site and has a parent frame against which the request can be checked.

The root has no parent call site.

Marking the root request as dynamic therefore describes an impossible tree shape. There is no enclosing instruction that selected the root at runtime, no caller frame supplying the dispatch context, and no parent edge that can justify the marker.

PR #3529 rejects that state in both authorize_requests and execute.

Rejecting impossible states early is better than assigning them accidental semantics. Dynamic-call code tends to contain alternate target-resolution branches. Letting a root enter one of those branches risks skipping assumptions that are safe only for callees.

Dynamic dispatch is already where Aleo's compiler and runtime boundaries become visible. I covered the compiler side in Why Leo's Stub-Ordering Bug Exposes Aleo's Real Composability Boundary. The authorization side follows the same principle: runtime flexibility does not erase structure. A dynamic callee is still a child in one authorization tree.

A concrete splice attempt

Picture a delegated proving service receiving authorizations from several wallet sessions. Two sessions belong to the same account and call the same market program.

text
Session A: buy 4 units from seller X
Session B: buy 40 units from seller Y

Both sessions produce valid roots and valid token-transfer callees. A queueing bug associates Session B's transfer request with Session A's root.

A validator that checks requests independently sees legitimate signatures throughout. Program locators are real. Input encodings are valid. Nothing looks malformed.

The tree checks fail in stages:

  1. Recompute the expected signer commitment using Session A's signer and root_tvk_A.
  2. Compare it with the commitment carried by Session B's callee request.
  3. Reject the mismatch before proving the mixed execution.
  4. If an extra request was appended instead, execute the tree and reject the non-empty remainder.

No malicious signer is required. Cross-session concurrency is enough. That makes these checks relevant to ordinary infrastructure, not only adversarial protocol puzzles.

Wallets and proving agents should preserve authorization objects as indivisible units. Do not pool callee requests by function locator. Do not deduplicate them because two serialized requests appear equivalent. Do not rebuild a request vector from independently verified parts unless the rebuilt object is checked against one root commitment and then fully consumed.

Useful local tests include four failure cases:

  • Replace one callee with a valid request from another authorization created by the same account.
  • Append a valid but unreachable request after the expected traversal.
  • Set the root's dynamic marker and confirm both authorization and execution reject it.
  • Reorder sibling requests whose function signatures happen to match.

The first test catches missing root binding. The second catches prefix-only execution. The remaining cases catch invalid shape and traversal assumptions.

Why Aleo needs stronger binding

Ethereum does not normally serialize user-signed internal calls beside a transaction. The EVM generates internal calls while executing the signed root transaction, so an external party cannot splice a separately signed internal-call object into the call stack.

Aleo has a different problem because private execution must carry enough authorization material for the expected request tree. That design supports private cross-program execution, but it also creates a composition surface that account-based runtimes do not expose in the same form.

The closest mental model is a capability graph. A request is a capability for one transition, yet its authority is scoped by the root authorization. Checking the capability's signature proves local validity. Checking scm proves session membership. Consuming the full ordered tree proves that the submitted capability graph matches the execution that occurred.

All of those checks are necessary.

For protocol implementers, the lesson is to state authorization invariants over the entire object rather than its elements. For wallet authors, approval UI should describe the root and derived call tree as one action. For agents, an authorization should have one owner, one lifecycle, and no request-level recycling.

The lion's rule is pleasantly dull: no foreign cubs, no orphaned requests, and no root pretending it has a parent.