S&P Global Completes Acquisition of OpenZeppelin Read the announcement

Miden Transaction Kernel v0.16 Audit

  • OpenZeppelin Security

Findings
Total issues: 27 (13 resolved, 1 partially resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 2 (2 resolved) · Low: 14 (11 resolved, 1 partially resolved)

Notes & Additional Information
11 notes raised (0 resolved)

Client Reported Issues
0 reported issues (0 resolved)

Scope

OpenZeppelin performed an audit of the 0xMiden/protocol repository, focusing on the diff between commits 37dd717 and 07ef8323c.

In scope were the following files:

crates/miden-protocol/asm/kernels
├── transaction
│   ├── bin
│   │   ├── main.masm
│   │   └── tx_script_main.masm
│   └── lib
│       └── api.masm
└── transaction-core
    └── src
        ├── account_update.masm
        ├── account.masm
        ├── asset_vault.masm
        ├── asset.masm
        ├── callbacks.masm
        ├── constants.masm
        ├── epilogue.masm
        ├── faucet.masm
        ├── fungible_asset.masm
        ├── input_note.masm
        ├── link_map.masm
        ├── memory.masm
        ├── mod.masm
        ├── non_fungible_asset.masm
        ├── note.masm
        ├── output_note.masm
        ├── prologue.masm
        └── tx.masm

System Overview

The Miden transaction kernel is the core program that executes and validates state transitions for accounts on the Miden rollup. Written in Miden assembly (MASM) and executed inside the Miden VM, it orchestrates the full lifecycle of a transaction, from ingesting inputs through producing a provably correct output state. The kernel operates as a trusted execution environment whose procedures are callable by untrusted account, note, and transaction script code through a controlled API, enforcing all protocol-level invariants such as asset preservation, access control, and storage integrity.

Changes Since the Previous Review

The in-scope diff spans 78 commits since commit 37dd717 and rewrites a substantial part of the kernel. The public syscall surface grew from 54 to 61 procedures, and the implementation was repackaged: kernels/transaction now only contains the main and tx_script_main binaries plus the api.masm kernel library, while the internal modules moved into a separate miden::tx_kernel_core library inside kernels/transaction-core. The functional changes group as follows.

Asset representation. Assets are no longer a single word. They are now split into an asset ID and an asset value, with the asset ID hashed before insertion into the vault sparse Merkle tree, and carry a composition attribute that governs how two values of the same asset combine. Vault insertion and removal were unified so that a single code path handles fungible and non-fungible assets, and the previous balance and non-fungible membership accessors were replaced by a generic asset getter.

Asset callbacks. A new callbacks module lets a faucet register hooks that the kernel invokes before an asset it issued is added to an account vault or to an output note. Whether the hooks run is determined by a flag in the issuing faucet's account ID and by the presence of the corresponding storage slot, and the hook may return a modified asset value that the kernel then commits.

Account update tracking. The account delta module was replaced by an account update module built around the new account patch model, which records storage patch operations in the patch commitment and computes a unified asset delta. A stub for native account upgrades was added, recording code and storage upgrade commitments in kernel memory without acting on them yet.

Stateful input notes. Input note assets became mutable during execution. Notes can now have individual assets or all of their assets removed, and the kernel exposes the assets a note was created with alongside its current state so that the epilogue can reconcile the two when building the output vault.

Note format. Note inputs were renamed to note storage, and a note attachments API replaced the previous single-attachment setter. The note identifier, note commitment, and nullifier computations changed, with the nullifier now binding the note metadata commitment. The note type encoding was reduced to one bit with an added metadata version, the encrypted note type was removed, and the maximum number of assets per note was lowered from 255 to 64.

Fees. Automatic fee deduction was removed from the epilogue. The kernel now exposes procedures to compute the transaction fee and to read the fee faucet ID, but no fee is charged by the kernel at the reviewed commit.

Context enforcement and foreign procedure invocation. Account-context authentication was split into a variant that tracks the calling procedure and a read-only variant that does not, and the output note and account procedures now consistently require an account context. Foreign procedure invocation was reworked from a pair of context start and end procedures into a preparation procedure followed by a single execution syscall.

Beyond these, the kernel absorbed several Miden VM and cryptography dependency upgrades, a memory layout reordering for little-endian access, the addition of type signatures to some public procedures, and the removal of the account storage mode concept.

Security Model and Trust Assumptions

The transaction kernel enforces protocol invariants within the Miden VM's provable execution model. All kernel procedures execute in a privileged context that is inaccessible to user-supplied code except through the controlled API surface. The security of the system rests on the following assumptions.

Operational

  • Account owners are responsible for deploying correct code.
  • Users employ secure auth modules, consume only trusted input notes, and verify all input data, output notes, and the account delta before approving a transaction.
  • The tx_prepare_fpi and tx_exec_foreign_proc syscalls are always called "atomically". Otherwise, other contexts could call tx_prepare_fpi to modify the data used by a subsequent FPI call.
  • Values returned by foreign procedure invocation may be outdated: the foreign account's state reflects the executor-chosen reference block, its commitment is not a transaction public input, and it is not revalidated against the account's on-chain state at inclusion. Responsibility for freshness is placed on the foreign account, which is expected to set a transaction expiration delta matched to how time-sensitive its data is.

Compiler and zkVM

  • The compilation of MASM into Miden VM instructions is correct.
  • The underlying Miden VM is properly constrained.
  • The Miden VM correctly executes the kernel code and produces a valid zero-knowledge proof of the execution trace. All arithmetic, memory operations, and cryptographic primitives (hashing, Merkle tree operations) behave correctly.
  • The VM's context separation mechanism prevents unauthorized cross-context calls. It is assumed that user programs execute in separate contexts and may only interact with the root context through controlled syscalls defined in api.masm.

Consensus

  • The batch and block kernels are expected to validate that the input notes are unique and to implement deferred validation of notes whose authentication was delayed by the transaction kernel.
  • Block data, chain data, account state, and input notes are authenticated against commitments provided as global inputs. The kernel assumes that these root commitments correspond to the canonical chain state.
  • It is assumed the operator and block producer enforce the transaction's expiration block number at inclusion, and bound how stale a reference block may be. Neither is enforced by the code in scope; the batch kernel that would aggregate per-transaction expirations is unimplemented at the reviewed commit.
  • All components that verify transaction proofs are assumed to be built against the transaction kernel version adopted by the network. Kernel version agreement is a deployment concern; the reference block's tx_kernel_commitment covers only the dynamically dispatched kernel procedure roots and does not bind the kernel program (main.masm) that produced the proof, which is fixed instead by the ProgramInfo compiled into each verifier. A version mismatch could result in a liveness concern.

Medium Severity

Minting Fails for Faucets Whose Vault Is Not Empty

The transaction kernel enforces asset conservation by comparing the input and output vault roots in epilogue::finalize_transaction. The input vault is not an independent structure: the prologue copies the initial account vault root into INPUT_VAULT_ROOT_PTR and then inserts the assets of every input note into it. Since a vault is a sparse Merkle tree whose nodes reside in the advice provider rather than in kernel memory, each read or write of an asset requires the Merkle witness for that asset ID to be present before the operation executes, unless the tree is entirely empty, in which case the paths are derivable from the empty subtree root constants. For account vault accesses the kernel emits a pre-access event so that the host can supply a missing witness during execution, but generic vault procedures cannot do so, as documented in asset_vault::peek_asset.

In order to keep minted assets consistent with the conservation check, faucet::mint credits the minted asset into the input vault without emitting a pre-access event, and the witness for the minted asset ID is never supplied by any other means. Consequently, a transaction that mints without consuming any asset-bearing input note, which is the case for a standard MINT note, aborts inside asset_vault::add_asset at the smt::peek that reads the current value of the minted asset whenever the faucet vault is not empty.

The same structural gap exists in faucet::burn. In the standard flow the burned asset arrives in an input note, so its witness is prefetched and the operation succeeds even when the faucet vault is not empty. Burning an asset held in the faucet's own vault, which the procedure documentation explicitly permits, succeeds only if an account vault operation on the same asset ID runs earlier in the same transaction and lazily loads the witness as a side effect. The correctness of that flow therefore depends on a call ordering that is not enforced or documented anywhere.

Consider emitting a pre-access event before faucet::mint and faucet::burn mutate INPUT_VAULT_ROOT_PTR, mirroring the pattern used by account::add_asset_to_vault, and extending the host to serve witnesses for input vault accesses.

Update: Resolved in pull request #3409.

Foreign Procedure Root Is Not Bound to the Foreign Account

Foreign procedure invocation is documented as the mechanism through which a note or transaction script reads the state of another account, with the canonical use case being a script that reads a price from an oracle account. It is implemented as a two-step kernel call: tx_prepare_fpi validates and stores the foreign account ID together with a caller-provided procedure root, and tx_exec_foreign_proc loads the account, makes it active through start_foreign_context, and executes the stored root. The protocol wrapper execute_foreign_procedure accepts both values from the caller and types the second as an AccountProcedureRoot.

The account state is authenticated against the account database, but the procedure root is only checked for non-zeroness before the dyncall that transfers control to it. Nothing verifies that the root belongs to the loaded account's commitment-verified procedure table, so the pairing of account ID and procedure is never enforced and assert_procedure_in_account is never reached for it. Because dyncall resolves its target through the host's procedure store, the executed code need not belong to any account at all, and a prover can serve any code whose root matches the value it supplied.

Mutation is not at risk, since every state-modifying kernel procedure requires the active account to be the native one, and the per-syscall caller authentication prevents a root outside the account's table from reading foreign state under its own identity. However, the executed code can invoke a genuine procedure of the foreign account through a nested dyncall, which restores a caller that the table accepts while the foreign account remains active. The executed code can therefore read authentic foreign state through the account's own interface and return altered values, so the sixteen output felts can be a manipulated function of real account state rather than a fabrication. Any consumer that fixes the account ID while accepting the procedure root from an untrusted source, such as a reusable component parameterized over which method to invoke, is exposed to values the account never produced, and the affected account has no means of protecting itself since it cannot prevent its identity from being paired with code outside its commitment.

Consider asserting the configured root against the loaded account's procedure table after the foreign context is started and before the dyncall, using assert_procedure_in_account so that call-tracking flags are not set on a foreign account. Since the procedure table is already loaded and validated against the account's code commitment at that point, this requires no additional data. Consider also removing the need to accept a procedure root from the caller, for example by accepting a procedure index and loading the corresponding root from the verified table, so that the executed procedure is bound to the account's committed interface by construction.

Update: Resolved in pull request #3575.

Low Severity

Notes Carrying 64 Callback-Enabled Assets Cannot Be Consumed by Their Recipient

The transaction kernel permits a note to carry up to 64 assets, as defined by MAX_ASSETS_PER_NOTE. Whenever an asset whose issuing faucet has callbacks enabled is added to an account vault or to an output note, the kernel invokes the corresponding faucet callback through maybe_start_faucet_callback_context, which starts a foreign account context against the issuing faucet unless that faucet is already the active account.

Foreign accounts are loaded into fixed account data blocks by account::get_account_data_ptr, which asserts that the allocated pointer stays at or below MAX_FOREIGN_ACCOUNT_PTR, limiting a transaction to the native account plus 63 distinct foreign accounts. Since end_foreign_context only moves the account stack pointer back and does not release the cached data block, this limit applies to the number of distinct foreign accounts touched over the whole transaction rather than to the number of contexts active at any single moment. A note holding 64 callback-enabled assets issued by 64 distinct faucets consequently requires 64 foreign account loads in order to be consumed, which is one more than the kernel permits.

The limit is not symmetric between note creation and note consumption. A transaction executed with one of the 64 faucets as the native account loads only the remaining 63 issuers as foreign accounts, so it can construct and emit such a note successfully. A recipient that is not one of those faucets must load all 64 issuers as foreign accounts, and the load of the 64th aborts with ERR_FOREIGN_ACCOUNT_MAX_NUMBER_EXCEEDED. The recipient cannot work around this by handling only part of the note, because the epilogue requires the input and output vault roots to match, and moving an asset into an output note invokes the same callback path as adding it to the account vault. As a result, the note and every asset it carries remain permanently unspendable for the intended recipient.

Consider aligning the maximum number of assets a note may carry with the number of distinct foreign accounts a consuming transaction is able to load, so that an otherwise valid note cannot require more foreign contexts than the kernel permits.

Update: Resolved in pull request #3431. The team stated:

“Reduce the protocol-wide MAX_ASSETS_PER_NOTE limit from 64 to 16”

Faucet Asset Callback Roots Are Not Verified Against Faucet Account Code

When an asset carrying the callback flag is added to an account vault or to an output note, the kernel reads a procedure root from a protocol-reserved storage slot of the issuing faucet and invokes it with dyncall. Before dispatching, maybe_start_faucet_callback_context checks that the slot exists and that it does not hold the empty word, but the root is never checked against the faucet's account procedures.

No other layer compensates for the missing check. Any arbitrary Word can be inserted into the callback slots without cross-checking it against the assembled account code, so the root can be misconfigured when the faucet is created. The slot can also be rewritten during a transaction: account_set_item requires only that the account is native and that the caller is one of its procedures. Slot identifiers carry no association with the component that declared them, so any procedure of the faucet can install any root into the callback slot.

A root that is not part of the faucet's code is not distributed together with the account code, so the MAST forest containing it may not be resolvable by the transaction executor, or a procedure not part of the faucet account may be executed. Every transaction that adds an asset issued by that faucet to a vault or an output note then fails or misbehaves during dynamic dispatch, which can leave the circulating supply of the asset untransferable. The failure surfaces only in transactions built by asset holders, not when the faucet is deployed or reconfigured.

Consider verifying that the callback procedure root is part of the account code before invoking it, for example by applying account::has_procedure once the callback context has been established.

Update: Resolved in pull request #3612. The team stated:

“verify faucet callback root against account code”

Non-Canonical Fungible Values Can Leave a Non-Empty ASSET_ID Slot With EMPTY_WORD Value

The kernel procedure input_note::remove_asset decides whether an asset is being fully removed by comparing the caller-supplied ASSET_VALUE against the stored value with a full-word equality via word::test_eq, but computes fungible partial removals through asset::split, which derives amounts via fungible_value_into_amount. That helper reads only the least significant limb and discards the three upper limbs without asserting they are zero, and the caller-supplied value is never validated as canonical along this path.

As a result, a caller can supply a non-canonical fungible value whose amount limb equals the stored amount but whose upper limbs are non-zero. The full-word equality check fails, so the partial-removal path is taken, and asset::split computes a zero remainder and writes EMPTY_WORD back to the value slot while leaving ASSET_ID intact. This produces a slot with a non-empty ASSET_ID and an EMPTY_WORD value, violating the documented invariant that a full removal clears the slot to (EMPTY_WORD, EMPTY_WORD). Because remove_all_assets treats a slot as already removed based solely on an empty ASSET_ID, the dangling slot is still counted as a present asset and folded into the removed-assets buffer, the returned count, and the assets commitment.

Consider treating a computed remainder of EMPTY_WORD as a full removal by clearing both ASSET_ID and ASSET_VALUE, and validating or canonicalizing the caller-supplied fungible value, for example via fungible_asset::validate, before performing the removal.

Update: Resolved in pull request #3606 and pull request #3755.

input_note::remove_asset Can Succeed When Asked to Remove an Empty Asset

The linear scan in remove_asset compares the caller-supplied ASSET_ID against each slot with word::eq and treats a match as the asset being present. A fully removed asset leaves its slot as (EMPTY_WORD, EMPTY_WORD), and the leading assert_supported_composition does not reject EMPTY_WORD because its extracted composition is COMPOSITION_NONE rather than COMPOSITION_CUSTOM. As a result, a call with ASSET_ID = EMPTY_WORD matches a previously cleared slot, is_found is set, and the ERR_INPUT_NOTE_ASSET_TO_REMOVE_NOT_FOUND assertion is bypassed. The procedure is reachable permissionlessly through the input_note_remove_asset kernel entry point, which forwards the caller-supplied identifier without validation.

The impact is limited to specification conformance. Only the remove_asset(EMPTY_WORD, EMPTY_WORD) input returns successfully, and it re-clears an already-empty slot, so no asset is created, moved, or destroyed. Any non-empty ASSET_VALUE still reverts in the partial-removal branch. The defect is that a call the documentation states must panic when the asset is not present instead returns success, removing that guarantee for callers that rely on it.

Consider rejecting an EMPTY_WORD or otherwise invalid ASSET_ID before the scan, for example by asserting that word::testz is false and validating the identifier via asset::validate_id.

Update: Resolved in pull request #3607.

get_storage_info Returns an Unverified Note Storage Item Count

The transaction prologue procedure process_note_num_storage_items reads an input note's number of storage items from the advice provider and writes it to kernel memory after checking only that it does not exceed MAX_NOTE_STORAGE_ITEMS. The value is never bound to the note's authenticated STORAGE_COMMITMENT. It is reconciled with that commitment only when a note script materializes the storage through active_note::get_storage, whose helper write_storage_to_memory recomputes the commitment over the stored count and asserts equality with NOTE_STORAGE_COMMITMENT.

The get_storage_info procedure, exposed to note and transaction scripts, returns num_storage_items for an input note directly from kernel memory without this reconciliation. The returned count is therefore controlled by the prover within [0, MAX_NOTE_STORAGE_ITEMS] and may differ from the count committed to by NOTE_STORAGE_COMMITMENT, despite being returned alongside an authenticated commitment. A note or transaction script that relies on this count without materializing the note's storage can be induced to execute with an incorrect value. No in-scope script consumes the standalone count, so the current impact is limited.

Consider reconciling num_storage_items with the note's STORAGE_COMMITMENT before it is exposed, so that get_storage_info provides the same guarantee already enforced in write_storage_to_memory.

Update: Resolved in pull request #3641. The team stated:

“addressed by verifying input-note storage item count in the prologue”

Procedure Roots Are Not Asserted To Be Unique During Account Creation

When a transaction executes against a newly created account, the prologue validates the account through validate_new_account. This routine asserts that the account ID is valid, the nonce is zero, the vault is empty, the seed satisfies the seed requirements, and that the storage slots are sorted and unique. The account procedures themselves are loaded and checked separately in save_account_procedure_data, which asserts that the number of procedures lies within the permitted range of MIN_NUM_PROCEDURES to MAX_NUM_PROCEDURES and that hashing the procedure roots reproduces the account code commitment.

Unlike the storage slots, which are explicitly checked for uniqueness by validate_storage, the kernel never asserts that the procedure roots of a new account are distinct. Because the code commitment is a sequential hash of the procedure roots, a set of roots that contains duplicates still produces a well-formed commitment and passes every kernel check performed during account creation. Uniqueness of procedure roots is enforced only off-chain, in the host-side AccountCode::from_parts constructor. An account whose data is assembled without going through that constructor could therefore be created with duplicate procedure roots, leaving the kernel as the last line of defense without a corresponding guarantee.

Duplicate roots waste procedure slots and make the call-tracking state (used by the epilogue and by standard auth components) ambiguous. was_procedure_called obtains the procedure index from an advice event supplied by the untrusted prover and validates it only by checking that the root stored at that index matches. Distinctness of the roots is the only thing pinning that index to one value, so when a root appears at two indices both satisfy the check, and a malicious prover can set the flag at one index and read it at the other.

Consider asserting within the kernel that the procedure roots of a new account are unique, mirroring the sorted-and-unique invariant already enforced for storage slots by validate_storage. This keeps the kernel's account creation validation self-contained rather than relying on the host to have constructed the account correctly.

Update: Resolved in pull request #3567. The team stated:

“Addressed by ensuring account procedure uniqueness and sorting in the transaction kernel.”

clear_fpi_memory Does Not Reset the Foreign Procedure Input Buffer

Foreign procedure invocation stores three prover-supplied values in fixed kernel memory during tx_prepare_fpi: the foreign account ID, the foreign procedure root, and the sixteenth foreign-procedure input value held at UPCOMING_FOREIGN_PROC_INPUT_VALUE_15_PTR. After the call completes, tx_exec_foreign_proc invokes clear_fpi_memory to reset this state, but clear_fpi_memory zeroes only the account ID and the procedure root. The input buffer is left holding the value from the previous call.

This is currently masked because the account ID and procedure root also serve as the sentinels checked at the start of tx_exec_foreign_proc, which reverts with ERR_FOREIGN_ACCOUNT_ID_IS_ZERO or ERR_FOREIGN_ACCOUNT_PROCEDURE_ROOT_IS_ZERO when either is zero. Reaching another foreign procedure invocation therefore requires a fresh tx_prepare_fpi, which unconditionally rewrites the input buffer before it can be read again, so a stale value cannot be consumed along the sanctioned path. The correctness of the reset thus depends on this ordering invariant rather than on the reset being complete. Since the memory accessors are exported and the sentinels can be written directly without going through tx_prepare_fpi, and since the split preparation and execution flow already carries cross-phase state hazards, relying on re-preparation instead of an explicit reset is fragile and leaves the kernel in an inconsistent state after each foreign procedure invocation.

Consider clearing UPCOMING_FOREIGN_PROC_INPUT_VALUE_15_PTR in clear_fpi_memory alongside the foreign account ID and procedure root, so that all foreign procedure invocation state is reset to a known value after each call and the correctness of the reset does not depend on the ordering of subsequent preparation steps.

Update: Acknowledged, not resolved. The team stated:

“This is currently not exploitable, see https://github.com/0xMiden/protocol/issues/3594#issuecomment-5307989355”

Kernel Removed-Asset Cap Has No Corresponding Limit in Rust Delta Construction

During the epilogue the transaction kernel computes the account patch commitment through compute_delta_commitment, which delegates the vault portion to commit_asset_delta. The delta commitment has a fixed canonical layout: the entire added-assets section (each added asset absorbed into the hasher) followed by an add-count trailer, and then the entire removed-assets section followed by a remove-count trailer. To produce that layout from a single link-map pass, the procedure hashes added assets inline and stages removed assets into procedure-local memory for a cheap linear replay after the add-count trailer. That staging area is a fixed-size buffer holding COMMIT_ASSET_DELTA_MAX_REMOVED_ASSETS (1024) assets, and the removal path asserts the running count has not reached the cap before staging each asset.

The buffer and its bound are a deliberate design choice, noted as adjustable in the constant's own documentation. The concern is that this bound has no counterpart on the Rust side. The equivalent AccountVaultDelta::append_delta_elements builds the same two-section commitment from heap-backed maps and imposes no limit on the number of removed assets, and neither the fungible nor the non-fungible delta constructors bound their entry count. The kernel bound is also asymmetric, since added assets are hashed inline and remain uncapped. As a result, off-chain tooling built on the Rust AccountVaultDelta can represent and construct a delta that removes more than 1024 distinct assets, which the kernel will then refuse to prove.

Consider reconciling the two sides so that tooling cannot build a delta the kernel will reject. Alternatively, to remove the limitation rather than mirror it on the Rust side, consider redefining the delta commitment so the added and removed sections are accumulated by two independent hasher states during the single link-map pass and combined once at the end (for example, by hashing the two section digests together). This needs no staging buffer and bounds neither section, but it changes the commitment format, so the kernel and AccountVaultDelta would have to adopt the new scheme together.

Update: Resolved in pull request #3623. The team stated:

“Enforces the max number of assets per delta op in the vault delta, both in the tx kernel and in AccountVaultDelta”

Inconsistent Use of Native and Active Account Accessors

The kernel memory module exposes two families of accessors for account state: the native family, which reads the account data section at a fixed pointer, and the active family, which resolves through the account stack and therefore returns the data of whichever account is active. Procedures that may run against a foreign account are expected to use the active accessors, and procedures whose semantics are defined only for the native account are expected to use the native ones. Several procedures of the second kind mix both families:

None of these is currently reachable with a foreign account active. The patch, vault and storage procedures run either from finalize_transaction, which asserts the context was switched back to the native account, or from API entry points such as account_set_map_item and account_add_asset, which assert the same. The prologue instances run before any foreign context can exist, since the active pointer is pinned to the native account at the start of account processing. Correctness therefore rests on invariants established by callers rather than on the procedures themselves, and nothing in these procedures records that dependency. Relaxing one of those assertions, or adding a caller that does not perform it, would silently produce state computed against the wrong account.

Consider using the native accessors throughout procedures whose semantics are defined for the native account, and reserving the active accessors for procedures that also serve foreign accounts. This is not sufficient for helpers that both kinds of caller share. find_storage_slot receives the storage slots section to search as a parameter but resolves that section's length internally from the active account, so it cannot be pointed at the native slot count without breaking the foreign account reads that legitimately rely on it. For helpers of this kind, consider taking the length from the caller alongside the base pointer, so that whichever code selects a section also determines its extent. That makes it impossible to search a section of one account using the length of another. Where equality of the active and native account is a deliberate precondition, consider documenting it on the procedure itself. Note that a few instances identified during the review were already addressed along these lines in pull request #3404.

Update: Resolved in pull request #3610. The team stated:

“Addressed by using native accessors in native-only account procedures.”

Faucet Mint Documentation Claims Issuance and Uniqueness Guarantees the Kernel No Longer Enforces

The documentation of the kernel mint procedure lists two panic conditions that the procedure does not implement: for fungible faucets, that the total issuance after minting is greater than the maximum amount allowed, and for non-fungible faucets, that the asset being minted already exists. In practice, mint only rejects unsupported asset compositions, validates that the asset originates from the executing faucet, and adds it to the transaction's input vault. It maintains no issuance counter and performs no cross-transaction uniqueness check. The only bounds come from asset_vault::add_asset, and since mint writes to the input vault, they apply within a single transaction rather than across the faucet's lifetime.

The same two conditions are repeated verbatim on the syscall-facing faucet_mint_asset wrapper and on the miden::protocol::faucet::mint library procedure that account components actually invoke, so a component author encounters the stale guarantees at every layer they would consult.

Both invariants were previously enforced in the kernel against the faucet system-data storage slot removed in PR #2335, and have since moved to the account-component layer, where the standard fungible and non-fungible faucets enforce maximum supply and once-only issuance respectively. That migration updated the doc comments of the per-asset-type mint helpers it deleted and those of the standard faucets, but left the two conditions on the public dispatcher and on both outer wrappers, where they have survived every subsequent refactor. An author writing a custom faucet component can therefore read the kernel as still guaranteeing supply caps and non-fungible uniqueness and omit the equivalent checks, allowing over-issuance or repeated minting of the same non-fungible asset.

The burn counterpart is not affected, even though its documentation sits in the same three blocks and was left untouched by the same migration. The bounds it describes are input-vault bounds rather than storage-slot bounds: asset_vault::remove_asset still rejects a fungible amount exceeding the vault balance and a non-composable asset absent from the vault, and the input vault is seeded from the native account vault and then extended with each input note's assets, which is exactly the "total input to the transaction ... via a note or the accounts vault" the doc comment names. Only the two mint conditions were backed by the removed storage slot, so only they went stale.

Consider updating all three mint doc blocks to describe the guarantees the procedures actually provide, and to state that cumulative supply limits and cross-transaction non-fungible uniqueness must be enforced by the account component.

Update: Resolved in pull request #3611. The team stated:

“Fixed the documentation.”

Storage Slot Type Is Not Constrained to Supported Values

Account storage slots are loaded from the advice provider by save_account_storage_data. Each record carries a slot type element whose permitted values are declared as constants for value, map, and array slots. The corresponding Rust type admits only value and map, and rejects any other value both on conversion and on deserialization.

The kernel never constrains the slot type element to that set. The typed accessors set_item, set_map_item and get_map_item_raw assert an exact type and therefore trap on a slot outside the domain, but commit_slot_patch branches only on equality with the value type and treats every other type, including the declared array type and undefined ones, as a map. A slot that is not a value slot is consequently committed into the account patch under the map classification without its type ever having been checked. The same unvalidated element is returned to callers by get_typed_item, which currently has no callers and is not exposed through the kernel API.

Consider asserting that the slot type belongs to the supported set during new-account validation, in validate_storage, which the kernel documents as the appropriate location for slot shape validation, noting that its comparison loop does not execute for accounts holding fewer than two slots and so cannot host the check as written. Keeping the kernel and Rust type domains in sync would additionally prevent a committed slot type that off-kernel consumers cannot parse. Consider also making commit_slot_patch dispatch strictly on the slot type and reject an unrecognized type rather than defaulting to map behavior, so that a type the kernel does not understand cannot be silently reinterpreted.

Update: Resolved in pull request #3608. The team stated:

“Fixed as suggested in the finding.”

Reserved Account State Elements From Advice Are Not Constrained at Creation

The account commitment is built from data supplied by the advice provider. process_account_data pipes the four core account words into memory while absorbing them into the hasher, and save_account_storage_data loads the storage slot records and verifies their sequential hash against the committed storage commitment. Two elements of that data are reserved by the canonical encoding and carry no meaning: index 1 of the account header word, documented in the kernel as [account_nonce, 0, account_id_suffix, account_id_prefix], and index 0 of each slot record, documented as [0, slot_type, slot_id_suffix, slot_id_prefix, SLOT_VALUE]. On the Rust side both are hardcoded to zero when the elements are produced (AccountHeader::to_elements, StorageSlotHeader::to_elements) and both are ignored when they are parsed back (AccountHeader::try_from_elements, AccountStorageHeader::try_from_elements).

Neither element is constrained anywhere in the kernel. The adv_pipe instruction copies and absorbs the header word without any opportunity to inspect it, validate_new_account constrains the account ID, the nonce, the vault root, the seed and the storage slots but not the reserved element, and validate_storage checks only that slot IDs are sorted and unique. Both elements are then stored verbatim and streamed back into the commitment by compute_commitment and refresh_storage_commitment. No kernel code reads or overwrites either element during execution, since set_native_account_nonce stores only the nonce and set_item_raw writes only the slot value. Whatever the advice provider supplies therefore persists into the account commitment and into every subsequent commitment of that account, so the documented canonical encoding is not enforced and the committed state cannot be reproduced by canonical tooling.

Consider asserting that both reserved elements are zero during new-account validation, placing the header check in validate_new_account and the slot check in validate_storage, which already runs for new accounts only and which the kernel documents as the appropriate location for slot shape validation. Note that the comparison loop in validate_storage does not execute for accounts holding fewer than two slots, so a per-slot check cannot be placed in the existing loop body. Consider also naming the reserved header offset in memory.masm, where index 1 is currently the only element of the header word without a name, and rejecting a non-zero reserved element in both try_from_elements implementations, so that the encoding is enforced on both sides.

Update: Resolved in pull request #3609. The reserved felt in the Account Header was changed to be the Account version in pull request #3662.

Non-Fungible Asset Replacement Is Mis-Encoded as a Pure Removal in the Account Delta

Every vault mutation records the affected asset in a link map keyed by asset ID, holding the value at the start of the transaction alongside the current value. update_asset preserves the first initial value it observes and overwrites the final value on every subsequent mutation. For an asset whose composition is none, compute_asset_delta infers the operation solely from whether the initial value is empty, and when it is not, reports a removal carrying the initial value while discarding the final one. Its documentation justifies this by stating that the final value must be empty according to the asset vault guarantees. No such guarantee holds across two operations. Addition requires the vault leaf to be empty and removal is all or nothing for such assets, so each invariant holds per operation only, and nothing rejects an entry whose initial and final values are both non-empty.

Reaching that entry requires two distinct values under one asset ID, which the kernel permits because a non-fungible asset ID binds only the first two elements of the value, as validate documents and enforces. Since the commit loop skips entries whose initial and final values are equal, the mis-encoding is triggered by exactly one shape: the first operation on the ID is a removal, the last is an addition, and the two values differ in their third or fourth element. Asset preservation constrains the rest of the route:

  1. The native account is the issuer of asset X and holds it in its own vault with value V1.
  2. Its code removes V1 from the account vault, leaving the link map entry at V1 and the empty word.
  3. It then adds V2, which shares the asset ID of V1, leaving the entry with two non-empty values.
  4. It burns V1 and mints V2, which mutate the input vault so that the asset preservation check still passes. Without the faucet operations the transaction traps here.
  5. The transaction executes, proves and verifies. The committed delta states that X with value V1 was removed, and omits the re-addition entirely.

Consider enforcing the invariant that, for an asset whose composition is none, the delta entry (INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE) never holds two distinct non-empty values, so that it is always reducible to a single addition, a single removal, or a no-op. The preferred place to enforce it is update_asset; this fails at the mutation that creates the unencodable entry and makes the assumption in compute_asset_delta's documentation hold by construction. The new check must be scoped to assets whose composition is none, since for fungible assets a non-empty to non-empty transition with unequal values is exactly a partial transfer. Asserting instead inside compute_asset_delta's composition-none branch is a weaker alternative: it is sufficient and nearly free, but it validates only the final entry rather than every write and reports the failure far from its cause. In either case the documentation should be corrected to point at the new assertion rather than at an asset vault guarantee that holds per operation but not across two.

Update: Acknowledged, not resolved. The team stated:

“see the reasoning here: https://github.com/0xMiden/protocol/issues/3600#issuecomment-5361421873”

Standard Note Scripts Do Not Verify That They Claim a Note's Full Assets

The kernel exposes procedures that remove a single asset or all assets from an input note selected by index, reachable from note scripts, the transaction script and account code alike. Single-asset removal supports partial amounts for composable assets: when less than the stored value is removed, the reduced value is written back to the note's asset slot and the slot stays occupied, so neither the note's asset count nor its number of non-empty slots changes. Removals never affect the note's initial assets information, which the prologue establishes as the record of what the note was created with.

Both P2ID and P2IDE bind the consuming account to the target recorded in note storage, then claim the note's assets through the basic wallet's add_assets_to_account, for example. That procedure removes all remaining assets and iterates over the result, skipping the loop without any assertion when the note holds none, and neither script compares what it sent against the note's initial assets information. If other code in the same transaction removed the assets beforehand, consumption still succeeds and the note is nullified, while the intended recipient receives nothing (or less than intended) and the removed assets remain available for an output note payable to another party.

The other asset-carrying standard scripts check only the number of assets obtained. SWAP, PSWAP and the fungible faucet burn path assert that exactly one asset was obtained, which fails closed against a full drain but not against a partial removal, since a reduced fungible amount still occupies its slot and is still counted. A SWAP or PSWAP consumer can therefore pay in full and receive a reduced offered asset, and a burn can retire less than the note carried. No standard note script verifies the value of what it claimed. The kernel-level remove_all_assets already returns a sequential hash over the assets it removed, built with the same scheme the prologue uses to validate a note's committed assets, so the two digests are equal if and only if nothing was removed or reduced beforehand.

Consider comparing the commitment of the removed assets against the note's initial assets commitment when claiming a note's assets, which covers full and partial removals at the cost of one word comparison. Should authorized deductions from input notes be introduced later, for example for fees, consider having the protocol maintain the expected post-deduction commitment so that the comparison keeps its meaning.

Update: Partially Resolved in pull request #3652. The team stated:

“Partially resolved by pull request #3650 pull request #3652.”

Notes & Additional Information

Unimplemented Features

The transaction kernel contains procedures whose behavior is a placeholder or that are explicitly marked as pending completion. These represent functionality that is not yet implemented, and the affected code paths currently execute stubbed or incomplete behavior. The following instances were identified, and further instances may be appended to this issue as they are discovered:

  • The account_upgrade syscall in api.masm records the code and storage upgrade commitments in kernel memory but does not apply the upgrade. As documented in the upgrade procedure, the actual application of the upgrade is not yet implemented and the commitment formats are not yet defined, so the procedure is a no-op beyond persisting the two commitments.
  • The output note fee computation in compute_output_notes_fee is not implemented. As indicated by the TODO comment, the procedure does not iterate over the output notes to compute their fee contribution and instead always returns an output note fee of 0, regardless of the notes produced by the transaction.

Consider tracking each of these items to completion and implementing the corresponding functionality before the affected features are relied upon in production, ensuring that account upgrades are correctly applied with well-defined commitment formats and that output notes contribute their intended fee. Consider also documenting the intended behavior and any interim security assumptions for each stubbed procedure while the implementation remains pending.

Update: Acknowledged, not resolved.

Foreign Account Lookup Reads Past the Account Region Before the Bound Check

The get_account_data_ptr procedure locates the memory block of a foreign account by scanning the fixed set of account slots. Each account occupies ACCOUNT_DATA_LENGTH (8192) elements, the native account resides at NATIVE_ACCOUNT_DATA_PTR (8192), and up to 63 foreign accounts may follow, so the last valid block begins at MAX_FOREIGN_ACCOUNT_PTR (524288, that is 8192 * 64). The lookup loop advances the pointer by one block per iteration and terminates only when the block's stored ID matches the requested foreign ID or reads as zero, the latter indicating an uninitialized slot. The MAX_FOREIGN_ACCOUNT_PTR bound is applied only after the loop exits rather than as a loop condition.

When all 64 slots are occupied and none matches the requested ID, the loop advances one block beyond the account region, to address 532480, and reads the ID elements there. That address is the start of the native account delta region at ACCOUNT_UPDATE_ASSET_PTR, so termination depends on the elements at offsets ACCT_ID_SUFFIX_OFFSET (2) and ACCT_ID_PREFIX_OFFSET (3) of that word reading as zero. Those elements are unused today, since offset 0 holds the asset delta link map pointer and ACCOUNT_PATCH_STORAGE_MAP_SECTION begins at offset 4, so the loop exits and the subsequent bound check reverts with ERR_FOREIGN_ACCOUNT_MAX_NUMBER_EXCEEDED as intended. Correct termination therefore rests on an implicit property of an adjacent, unrelated memory region rather than on an explicit bound.

Consider incorporating the MAX_FOREIGN_ACCOUNT_PTR bound directly into the loop's continuation condition, so that iteration cannot advance past the last valid account block regardless of the contents of neighboring memory.

Update: Acknowledged, not resolved.

account_upgrade Can Be Called Outside the Account Context

The kernel exposes its procedures to user code through exec_kernel_proc, which only bounds-checks the supplied offset before invoking the target through dynexec. Because note and transaction scripts execute with the native account active, any such script can reach account_upgrade (offset ACCOUNT_UPGRADE_OFFSET, 22), which guards solely with memory::assert_native_account. It is the only account mutator that does not authenticate its caller: siblings such as account_set_item, account_add_asset, and account_incr_nonce also enforce authenticate_and_track_account_origin or assert_auth_procedure_origin, which inspect the caller and reject invocations that do not originate from an account procedure. As a result, any note or transaction script can write attacker-chosen CODE_UPGRADE_COMMITMENT and STORAGE_UPGRADE_COMMITMENT values into kernel memory. The procedure is currently a no-op beyond storing these commitments, so the impact is latent, but the missing gating would become exploitable once the commitments are assigned meaning.

Consider gating account_upgrade to its intended caller context as the other account mutators do, for example with authenticate_and_track_account_origin or assert_auth_procedure_origin.

Update: Acknowledged, not resolved.

Misleading Documentation

Throughout the codebase, multiple instances of misleading documentation were identified:

  • The process_global_inputs and prepare_transaction doc comments document the global inputs as account_id_prefix, account_id_suffix, block_num, but the stack built in Rust and consumed by set_global_account_id places the suffix on top, so the order is reversed. These two are the only places with the inverted order; main.masm and tx_script_main.masm document it correctly.
  • The build_metadata doc comment states that the attachment num_words is initialized to 0, and the stack comments name the tag felt attachment_num_words_and_tag, a name that recurs twice more in the procedure. No such field exists: the canonical layout defines the second felt as [reserved (32 bits) | note_tag (32 bits)], and to_metadata_word writes only the tag. A running attachment word count is maintained by add_attachment, but in dedicated output note memory rather than in the metadata word, so the naming invites the reader to conclude that a write-back to the metadata is missing. The claim about the attachment schemes is accurate; only num_words is stale.
  • The write_exclude_notes procedure documents only operand stack inputs, even though it reads the list of output note indices to exclude from the advice map keyed by EXCLUDE_NOTES_COMMITMENT. Every other kernel procedure reading from the advice map declares it with an Advice map: line, as insert_and_validate_storage_map does.
  • In the asset lookup loop of add_asset_raw, the flag comparing the current asset pointer against the end pointer is labeled is_end_reached, but the comparison is an inequality, so the flag means the opposite. The same computation in the loop preamble is labeled has_assets. Only the label is wrong; the loop behaves as intended.
  • In the changed-asset branch of commit_asset_patch, the stack comment reads [ASSET_ID, FINAL_ASSET_VALUE, ...], but the preceding local memory instructions leave the stack untouched, so the state is [INITIAL_ASSET_VALUE, FINAL_ASSET_VALUE, ASSET_ID, ...].
  • The precondition documented for update_asset only covers calls after asset_vault::add_asset, but the procedure is also invoked from remove_asset_from_vault after asset_vault::remove_asset. Its inputs are likewise described only as the asset that is added.
  • The output_note::get_assets doc comment asks callers to make at least 4 * num_assets memory elements available whereas it should say 8 * num_assets. The same doc block understates the unknown-count bound as 4 * MAX_ASSETS_PER_NOTE, which should likewise be 8 * MAX_ASSETS_PER_NOTE, and describes the resulting layout as one where "each asset occupies one word". In fact ASSET_SIZE is 8 and write_assets_to_memory writes two words per asset, so all three statements understate the requirement by a factor of two.

Consider updating the documentation to consistently and accurately reflect the implemented behavior and requirements, keeping comments, stack annotations, and preconditions aligned with the current code.

Update: Acknowledged, not resolved.

Stale Delta Terminology Around Account Patch Machinery

The kernel tracks account state changes in two distinct representations. An account delta is the relative change, while an account patch is the resulting absolute state. Both are live: the epilogue commits to the patch, and compute_delta_commitment remains exposed to authentication procedures. Storage, however, is always expressed as a patch, since the delta reuses the same AccountStoragePatch type and the same kernel routines serve both commitments. Several comments still describe this shared machinery as a delta, in some cases contradicting the surrounding text within a single doc comment:

  • The storage commitment procedures are all named after the patch and are invoked by both compute_patch_commitment and compute_delta_commitment, yet each is documented as updating "the given delta hasher". In the same block, get_storage_slot_patch_op is described as returning "the storage delta operation" six lines above declaring its output as patch_op, and the changed-value branch of commit_value_slot_patch reads "only include in delta" seven lines after "we commit the patch if".
  • The doc comment of set_map_item opens by describing the storage map patch and then calls the link map entry a "delta entry".
  • In memory.masm, the comment on the storage map section constant refers to "storage map patches" and then to "the storage map delta", and the accessor for that section sits under an ACCOUNT DELTA section header even though it returns a pointer to the storage map patch.
  • In account.masm, insert_new_storage is documented as writing into the account delta, the helper it calls describes inserting entries into the in-kernel delta, and the map write path refers to account delta insertion and to the delta API. All of them reach the storage map patch through account_update::set_map_item.
  • The prologue attributes the duplication of the initial storage slots and the insertion of a new account's initial storage to the account delta, although both feed the shared storage patch computation.
  • The doc comment of finalize_transaction states that the procedure computes the account delta commitment, while the procedure computes the patch commitment and the remainder of the same comment consistently says patch.
  • The asset link map stores initial and final value pairs and is the shared source for both the delta and the patch commitments. Its constant is named ACCOUNT_UPDATE_ASSET_PTR, but the section comment presents it as the delta of the asset vault, and the stack comments label it asset_delta_map_ptr in both commit_asset_patch and update_asset, whose own doc comment describes it as the patch. Similarly, the asset, value and map domain constants are documented as belonging to the delta commitment even though they are absorbed into both commitments. The same wording reaches the call sites in account.masm, where both add_asset_to_vault and remove_asset_from_vault duplicate the asset ID for a "delta update" that is the update_asset call writing this map.

Consider replacing delta references attached to storage machinery with patch where appropriate, renaming the asset link map references and the domain constant comments to the neutral account update wording that their constants already use, and correcting the finalize_transaction doc comment.

Update: Acknowledged, not resolved.

Code Clarity

Multiple instances were identified where naming, documentation, or unreferenced declarations make the kernel harder to follow:

Consider renaming COMPOSITION_NONE to convey non-fungibility, removing the unreferenced constants, correcting the negations in the callback and input note accessor documentation, adopting a consistent convention for documenting and referencing procedure locals, reusing the existing constants in place of the remaining literals, removing the redundant swapw, and documenting adv.push_mapvaln.i in the Miden VM instruction reference.

Update: Acknowledged, not resolved.

get_requested_note_ptr Validates an Ignored Note Index

The get_requested_note_ptr helper returns either the active input note pointer or the pointer for a given index, selecting between them with cdrop. Because input_note::get_input_note_ptr runs unconditionally before the selection, the index is bounds-checked even when the active note was requested, contradicting the documented behavior that the index is ignored in that case. The panic condition is likewise documented without qualification.

Consider aligning the implementation with the documentation, or documenting that the index is validated regardless.

Update: Acknowledged, not resolved.

Input Note Assets and Output Note Contents Can Be Modified by Any Code in the Transaction

The input note removal procedures resolve their target through a helper that accepts any note index below the input note count, and they carry neither a native account check nor an account origin check. The output note mutators that append assets and attachments require only that the active account is the native account, which holds throughout note script and transaction script execution, whereas note creation and the account vault mutators also authenticate that the invocation originates from a procedure of the native account. Any note script or transaction script can therefore reduce any input note's assets, in whole or in part, and append to output notes it did not create. A hostile note script consumed alongside a valuable note can reassign that note's assets into an output note payable to another party while the epilogue's conservation check still passes.

The consequences are bounded. Assets can be neither created nor destroyed, the account vault can only be debited through an origin-authenticated procedure, note scripts run before the transaction script and so can only append to notes that already exist, and an account that exposes note creation or asset-moving procedures to note scripts is already exposed to a direct vault drain by any hostile script it consumes. The origin check on note creation bounds less than it appears, since the standard note creator component forwards a caller-supplied recipient unchanged and is callable from any note script.

What remains is that nothing in the protocol records who is entitled to reduce an input note's assets or to modify an output note, which leaves detection entirely to off-chain software. The transaction summary carries the account delta, the input notes and the output notes, so a signer can reconcile the initial vault balance plus all input note assets against the final vault balance plus all output note assets. That reconciliation must be performed per asset, and it cannot be performed by account code, which has no way to know which output notes were intended. Appended attachments have no conservation analogue and no standard note script verifies its own output note's attachments.

Consider documenting that any note script or transaction script may reduce any input note's assets and append to output notes it did not create, that protection is expected to live in each note script, and that wallet software is responsible for the reconciliation described above.

Update: Acknowledged, not resolved.

Documented Asset Existence Guarantees Do Not Hold For Empty Asset Values

The account vault follows its sparse Merkle tree's convention that a value of EMPTY_WORD denotes absence, so writing one is a no-op and no key holds it. An asset's value word is empty in two cases: a fungible asset whose amount is zero, since fungible_asset::validate bounds the amount only from above, and a non-fungible asset whose asset class and remaining value elements are all zero, since validation requires only that the value's first two elements match the asset class limbs of the ID and leaves the other two unconstrained. Both encode "nothing" and the vault correctly stores nothing for either, but several documented guarantees then do not hold.

account_remove_asset states that it panics if the asset is not found in the vault, for both the fungible and the non-fungible case. However, asset_vault::remove_asset never validates the asset (only rejects unsupported compositions), and it treats a value equal to the one currently stored as a full removal, so removing an empty value from an absent key takes that branch and succeeds. Any structurally valid asset ID paired with an empty value will do. faucet::burn reaches the same branch on the input vault, so it does not reject the burning of an asset the transaction never received, provided the prover supplies the witness proving that asset's absence. The vault's guard against re-adding an existing non-fungible asset keys off the stored value being non-empty, so it can never fire for an asset whose own value is empty, leaving the kernel's uniqueness guarantee vacuous for that asset. The standard non-fungible faucet does not rely on it and records issuance in its own registry. The Rust AssetVault does enforce the documented behaviour, returning asset-not-found errors for a zero amount or a previously empty value, so the two implementations disagree on the legality of the same operation. None of this creates or destroys value, since an asset with an empty value cannot be held in a vault.

Consider correcting those documented conditions to describe the actual behaviour, or aligning the kernel with the Rust implementation. Note that an empty value is the vault's own encoding of absence, since a full removal writes one back as the remaining balance, so any change that rejects empty values would have to account for that. Consider also documenting the underlying invariant, that an asset's value word is the sole carrier of existence, so that consumers of an output note's asset list read values rather than asset IDs or asset counts alone.

Update: Acknowledged, not resolved.

Partial Blockchain Leaf Count Is Not Bound to the Authenticated Reference Block Number

In process_chain_data, the advice map value keyed by the reference block's chain commitment has the form [[num_blocks, 0, 0, 0], PEAK_1, ..., PEAK_N], but mmr::unpack from miden-core-lib v0.25.3 pops the leading word and stores it as the MMR forest size before absorbing anything, and the commitment covers only the padded peaks. Nothing binds the stored count to the authenticated block number, so the host may pick any value whose population count preserves the hashed message length. Since mmr::get derives the peak slot, the tree depth and the relative position from that count, the word returned to authenticate_note as the commitment of a note's creation block can be an arbitrary internal node of the reference forest rather than a block commitment.

This was fixed upstream in miden-vm#3388, which prefixes the hashed preimage with [num_blocks, 0, 0, 0] in both MmrPeaks::hash_peaks and mmr::unpack and rejects advice whose preimage size disagrees with the encoded count. The fix is not present in any published miden-core-lib release up to 0.25.8, and the branch under review pins 0.25.7, so the reviewed code remains affected. Consider bumping the dependency once a release carries the fix.

Update: Acknowledged, not resolved.

Authentication Signatures Do Not Commit to the Transaction Script Root

The transaction summary that account authentication signs is built by create_tx_summary and commits only to the account delta, the input notes commitment, the output notes commitment, and a salt. The transaction script root is omitted, and it is not a public output of the kernel either. Since the transaction script executes before the epilogue invokes the authentication procedure, a relayer or prover can substitute an arbitrarily expensive script that preserves the signed commitments, and the signature remains valid.

No impact is currently reachable, because automatic fee computation and deduction were removed from the transaction kernel, leaving compute_fee as a query with no enforcement. That procedure still derives the amount from the current clock cycle, so the substituted script's cycles are already accounted for by the time authentication runs. Reintroducing fee payment would therefore make signers susceptible to fee griefing without any further change. The assert_tx_script_allowed primitive constrains which script roots an account will execute, but it is opt-in and the standard signature components do not use it.

Consider committing to the transaction script root as part of the signed transaction summary, or otherwise binding a signer-controlled fee bound before fee payment is reintroduced. This approach is already proposed in issue #2765, which tracks this finding.

Update: Acknowledged, not resolved.

Conclusion

The Miden transaction kernel is the program that enforces correct state transitions for every account on the Miden rollup. It executes inside the Miden VM and mediates all access that untrusted account, note, and script code has to protocol state. Following two prior reviews of the kernel, this engagement examined a diff of 78 commits that rewrites a substantial part of it, introducing programmable assets by splitting an asset into an identifier and a value, adding faucet callbacks that run when an asset enters a vault or an output note, making input note assets mutable during execution, changing the note format, and reworking foreign procedure invocation.

No critical or high severity issues were identified. Two medium severity issues were found. Foreign procedure invocation does not bind the procedure root it executes to the foreign account it makes active, so code outside the account's committed interface can return values under that account's identity. Separately, minting fails for faucets whose vault is not empty, because the Merkle witness for the minted asset is never supplied for the input vault write that mint performs.

The codebase remains well structured, and moving the internal modules into a separate miden::tx_kernel_core library gives the kernel a clearer boundary between its public surface and its implementation.

The Miden team is commended for its responsiveness throughout the engagement, for providing detailed explanations of design decisions, and for clarifying the intended behavior of components that are still under active development. These efforts greatly facilitated the review.

Appendix

Issue Classification

OpenZeppelin classifies smart contract vulnerabilities on a 5-level scale:

  • Critical
  • High
  • Medium
  • Low
  • Note/Information

Critical Severity

This classification is applied when the issue’s impact is catastrophic, threatening extensive damage to the client's reputation and/or causing severe financial loss to the client or users. The likelihood of exploitation can be high, warranting a swift response. Critical issues typically involve significant risks such as the permanent loss or locking of a large volume of users' sensitive assets or the failure of core system functionalities without viable mitigations. These issues demand immediate attention due to their potential to compromise system integrity or user trust significantly.

High Severity

These issues are characterized by the potential to substantially impact the client’s reputation and/or result in considerable financial losses. The likelihood of exploitation is significant, warranting a swift response. Such issues might include temporary loss or locking of a significant number of users' sensitive assets or disruptions to critical system functionalities, albeit with potential, yet limited, mitigations available. The emphasis is on the significant but not always catastrophic effects on system operation or asset security, necessitating prompt and effective remediation.

Medium Severity

Issues classified as being of medium severity can lead to a noticeable negative impact on the client's reputation and/or moderate financial losses. Such issues, if left unattended, have a moderate likelihood of being exploited or may cause unwanted side effects in the system. These issues are typically confined to a smaller subset of users' sensitive assets or might involve deviations from the specified system design that, while not directly financial in nature, compromise system integrity or user experience. The focus here is on issues that pose a real but contained risk, warranting timely attention to prevent escalation.

Low Severity

Low-severity issues are those that have a low impact on the client's operations and/or reputation. These issues may represent minor risks or inefficiencies to the client's specific business model. They are identified as areas for improvement that, while not urgent, could enhance the security and quality of the codebase if addressed.

Notes & Additional Information Severity

This category is reserved for issues that, despite having a minimal impact, are still important to resolve. Addressing these issues contributes to the overall security posture and code quality improvement but does not require immediate action. It reflects a commitment to maintaining high standards and continuous improvement, even in areas that do not pose immediate risks.