S&P Global Completes Acquisition of OpenZeppelin Read the announcement

Miden Standard Diff Audit

  • OpenZeppelin Security

Findings
Total issues: 65 (35 resolved, 3 partially resolved)
Critical: 2 (2 resolved) · High: 4 (4 resolved) · Medium: 4 (3 resolved, 1 partially resolved) · Low: 33 (15 resolved, 2 partially resolved)

Notes & Additional Information
22 notes raised (11 resolved)

Client Reported Issues
0 reported issues (0 resolved)

Scope

OpenZeppelin performed a diff audit of the 0xMiden/protocol repository between the 2ef8056323df258917d119383a7cdd49b064d88a base commit and the 8411bf093bde25285708faac152b6d7269009617 head commit.

The audit was limited to the crates/miden-standards crate. In scope were the following files:

miden-standards
├── asm
│   ├── components
│   │   ├── access
│   │   │   ├── authority
│   │   │   │   └── authority.masm
│   │   │   ├── pausable
│   │   │   │   └── pausable.masm
│   │   │   └── pausable_manager
│   │   │       └── pausable_manager.masm
│   │   ├── auth
│   │   │   ├── guarded_multisig
│   │   │   │   └── guarded_multisig.masm
│   │   │   ├── multisig
│   │   │   │   └── multisig.masm
│   │   │   ├── multisig_smart
│   │   │   │   └── multisig_smart.masm
│   │   │   ├── network_account
│   │   │   │   └── network_account.masm
│   │   │   ├── no_auth
│   │   │   │   └── no_auth.masm
│   │   │   ├── singlesig
│   │   │   │   └── singlesig.masm
│   │   │   └── singlesig_acl
│   │   │       └── singlesig_acl.masm
│   │   ├── faucets
│   │   │   ├── fungible_faucet
│   │   │   │   └── fungible_faucet.masm
│   │   │   ├── non_fungible_faucet
│   │   │   │   └── non_fungible_faucet.masm
│   │   │   └── policies
│   │   │       ├── burn
│   │   │       │   ├── allow_all
│   │   │       │   │   └── allow_all.masm
│   │   │       │   ├── min_burn_amount
│   │   │       │   │   └── min_burn_amount.masm
│   │   │       │   └── owner_controlled/owner_only
│   │   │       │       └── owner_only.masm
│   │   │       ├── mint
│   │   │       │   ├── allow_all
│   │   │       │   │   └── allow_all.masm
│   │   │       │   └── owner_controlled/owner_only
│   │   │       │       └── owner_only.masm
│   │   │       ├── policy_manager
│   │   │       │   └── policy_manager.masm
│   │   │       └── transfer
│   │   │           ├── allow_all
│   │   │           │   └── allow_all.masm
│   │   │           ├── allowlist/owner_controlled
│   │   │           │   └── owner_controlled.masm
│   │   │           ├── basic_allowlist
│   │   │           │   └── basic_allowlist.masm
│   │   │           ├── basic_blocklist
│   │   │           │   └── basic_blocklist.masm
│   │   │           └── blocklist/owner_controlled
│   │   │               └── owner_controlled.masm
│   │   ├── inspection
│   │   │   ├── code_inspection
│   │   │   │   └── code_inspection.masm
│   │   │   └── schema_commitment
│   │   │       └── schema_commitment.masm
│   │   ├── note/note_creator
│   │   │   └── note_creator.masm
│   │   └── wallets/basic_wallet
│   │       └── basic_wallet.masm
│   └── standards
│       ├── access
│       │   ├── authority.masm
│       │   └── mod.masm
│       ├── assets
│       │   ├── fungible_asset.masm
│       │   ├── mod.masm
│       │   └── non_fungible_asset.masm
│       ├── attachments
│       │   ├── mod.masm
│       │   └── network_account_target.masm
│       ├── auth
│       │   └── tx_script_allowlist.masm
│       ├── data_structures
│       │   └── mod.masm
│       ├── faucets
│       │   ├── policies
│       │   │   ├── burn
│       │   │   │   ├── owner_controlled
│       │   │   │   │   ├── mod.masm
│       │   │   │   │   └── owner_only.masm
│       │   │   │   ├── allow_all.masm
│       │   │   │   ├── min_burn_amount.masm
│       │   │   │   └── mod.masm
│       │   │   ├── mint
│       │   │   │   ├── owner_controlled
│       │   │   │   │   ├── mod.masm
│       │   │   │   │   └── owner_only.masm
│       │   │   │   ├── allow_all.masm
│       │   │   │   └── mod.masm
│       │   │   ├── transfer
│       │   │   │   ├── allowlist
│       │   │   │   │   ├── mod.masm
│       │   │   │   │   └── owner_controlled.masm
│       │   │   │   ├── blocklist
│       │   │   │   │   ├── mod.masm
│       │   │   │   │   └── owner_controlled.masm
│       │   │   │   ├── allow_all.masm
│       │   │   │   ├── basic_allowlist.masm
│       │   │   │   ├── basic_blocklist.masm
│       │   │   │   └── mod.masm
│       │   │   ├── mod.masm
│       │   │   └── policy_manager.masm
│       │   ├── fungible.masm
│       │   ├── mod.masm
│       │   └── non_fungible.masm
│       ├── inspection
│       │   ├── code_inspection.masm
│       │   ├── mod.masm
│       │   └── storage_schema.masm
│       ├── note
│       │   ├── mod.masm
│       │   └── note_creator.masm
│       ├── notes
│       │   ├── burn.masm
│       │   ├── faucet_policy_action.masm
│       │   ├── mint.masm
│       │   ├── mint_fungible.masm
│       │   ├── mint_non_fungible.masm
│       │   ├── mod.masm
│       │   ├── owner_action.masm
│       │   ├── p2id.masm
│       │   ├── p2ide.masm
│       │   ├── pause_action.masm
│       │   ├── pswap.masm
│       │   ├── rbac_action.masm
│       │   └── swap.masm
│       ├── tx_scripts
│       │   ├── expiration.masm
│       │   └── mod.masm
│       ├── wallets
│       │   ├── basic.masm
│       │   └── mod.masm
│       └── mod.masm
├── src
│   ├── account
│   │   ├── access
│   │   │   ├── pausable
│   │   │   │   ├── manager.rs
│   │   │   │   └── mod.rs
│   │   │   ├── authority.rs
│   │   │   ├── mod.rs
│   │   │   ├── ownable2step.rs
│   │   │   └── rbac.rs
│   │   ├── auth
│   │   │   ├── multisig_smart
│   │   │   │   └── component.rs
│   │   │   ├── network_account
│   │   │   │   ├── auth_network_account.rs
│   │   │   │   ├── mod.rs
│   │   │   │   ├── network_account.rs
│   │   │   │   ├── note_allowlist.rs
│   │   │   │   └── tx_script_allowlist.rs
│   │   │   ├── guarded_multisig.rs
│   │   │   ├── mod.rs
│   │   │   ├── multisig.rs
│   │   │   ├── no_auth.rs
│   │   │   ├── singlesig.rs
│   │   │   └── singlesig_acl.rs
│   │   ├── components
│   │   │   └── mod.rs
│   │   ├── faucets
│   │   │   ├── fungible
│   │   │   │   └── mod.rs
│   │   │   ├── non_fungible
│   │   │   │   └── mod.rs
│   │   │   └── mod.rs
│   │   ├── inspection
│   │   │   ├── code_inspection.rs
│   │   │   ├── mod.rs
│   │   │   └── schema_commitment.rs
│   │   ├── interface
│   │   │   ├── component.rs
│   │   │   ├── extension.rs
│   │   │   └── mod.rs
│   │   ├── policies
│   │   │   ├── burn
│   │   │   │   ├── allow_all.rs
│   │   │   │   ├── min_burn_amount.rs
│   │   │   │   ├── mod.rs
│   │   │   │   └── owner_only.rs
│   │   │   ├── mint
│   │   │   │   ├── allow_all.rs
│   │   │   │   ├── mod.rs
│   │   │   │   └── owner_only.rs
│   │   │   ├── transfer
│   │   │   │   ├── allowlist
│   │   │   │   │   └── owner_controlled.rs
│   │   │   │   ├── blocklist
│   │   │   │   │   └── owner_controlled.rs
│   │   │   │   ├── allow_all.rs
│   │   │   │   ├── basic_allowlist.rs
│   │   │   │   ├── basic_blocklist.rs
│   │   │   │   └── mod.rs
│   │   │   ├── manager.rs
│   │   │   └── mod.rs
│   │   ├── wallets
│   │   │   ├── mod.rs
│   │   │   └── note_creator.rs
│   │   └── mod.rs
│   ├── code_builder
│   │   └── mod.rs
│   ├── note
│   │   ├── burn.rs
│   │   ├── faucet_policy_action.rs
│   │   ├── mint.rs
│   │   ├── mod.rs
│   │   ├── owner_action.rs
│   │   ├── p2id.rs
│   │   ├── p2ide.rs
│   │   ├── pause_action.rs
│   │   ├── pswap.rs
│   │   ├── rbac_action.rs
│   │   └── swap.rs
│   ├── tx_script
│   │   ├── expiration_script.rs
│   │   ├── mod.rs
│   │   └── send_notes_script.rs
│   ├── lib.rs
│   └── standards_lib.rs
└── build.rs

The following categories of changed files were explicitly excluded from the scope of this engagement:

  • Test code, including src/testing/, all tests.rs / test.rs modules, and the crate's integration tests.
  • Build and packaging manifests, namely the per-component miden-project.toml files introduced by the packaging restructuring and Cargo.toml. Note that build.rs, which drives the compilation of both MASM projects, was reviewed.
  • The Warden component in all of its forms (asm/components/access/warden/warden.masm, asm/standards/access/warden.masm, and src/account/access/warden.rs), which the client has indicated is slated for removal.
  • src/account/auth/approver.rs, asm/standards/access/ownable2step.masm, asm/standards/access/pausable/manager.masm, asm/standards/access/pausable/mod.masm, and asm/standards/access/rbac.masm, which contain fixes and refactors already reviewed during the previous audit of this codebase.
  • The authentication library modules asm/standards/auth/guardian.masm, asm/standards/auth/mod.masm, asm/standards/auth/multisig.masm, asm/standards/auth/multisig_smart/mod.masm, asm/standards/auth/note_script_allowlist.masm, asm/standards/auth/signature.masm, and asm/standards/auth/tx_policy.masm.
  • asm/standards/data_structures/double_word_array.masm.
  • src/note/file.rs.

System Overview

Miden is a zero-knowledge rollup built around client-side proving. Rather than submitting transactions for the network to execute, users execute and prove transactions locally and publish only the resulting proof and state commitment. This design is what allows Miden accounts and their state to be private by default, and it shapes every abstraction in the protocol.

The two state abstractions are accounts and notes. An account bundles code (a set of callable procedures), storage (named value and map slots), and an asset vault. A note is a self-contained package that carries assets plus a script that runs when the note is consumed; notes are how value and instructions move between accounts, since one account cannot synchronously call into another. Accounts are assembled from components: each component contributes a set of procedures and the storage slots those procedures own, and an account's code is the union of the components installed at creation.

The miden-standards crate is the ecosystem's standard contract library. It plays a role comparable to a reference token and access-control library: it is not itself a deployed contract, but it provides the canonical, audited building blocks that users compose into their own accounts and notes. It has two halves that mirror each other.

  • asm/standards compiles into a single Miden Assembly (MASM) library published under the miden::standards::* namespace. It holds the reusable procedure implementations along with the entry points of every canonical note script and transaction script.
  • asm/components holds one Miden project per installable account component. Each compiles to its own package with a stable set of procedure roots, which is what an account actually installs.

The Rust half under src/ wraps both: it exposes typed component builders that emit an AccountComponent together with its storage layout and validated initial state, and it mirrors every on-chain note layout with a Rust builder and parser so that off-chain tooling and on-chain scripts agree on the encoding of a note's storage.

The reviewed diff spans roughly two months of development and 69 commits touching this crate. Beyond a large amount of refactoring, the substantive changes fall into the areas described below.

Non-Fungible Faucet

The most significant addition is the non-fungible faucet, a new account component that lets an account act as an NFT collection, alongside the pre-existing fungible faucet.

The design departs sharply from the ERC-721 model, because Miden assets are bearer instruments rather than entries in an issuer-held mapping. A non-fungible asset is a pair of words that physically resides either in an account's vault or inside a note. Its "token ID" is not chosen by the issuer: the asset's value word is an off-chain commitment hash(user_data, salt), and the protocol derives the asset class from the first two elements of that commitment. Consequently the faucet has no ownerOf, no balanceOf, and no approval mechanism. Once minted, an asset circulates without the faucet ever being consulted, and possession is the only authorization needed to act on it.

What the faucet does maintain is a status registry: a storage map from token ID to one of three states, namely never issued, issued, or burned. mint_and_send runs the active mint policy, asserts that the commitment has not already been issued, marks it issued, and creates an output note carrying the newly minted asset. receive_and_burn must be invoked from a note context, takes the single asset carried by the note, runs the active burn policy, and transitions the entry from issued to burned. Because the transition asserts the previous state, a burned token ID can never be reissued, making burns permanent for a given commitment.

The faucet reuses the shared policy, pausing, and authority machinery described below, so the same access-control configuration applies uniformly to fungible and non-fungible issuance.

Unified MINT and BURN Notes

Rather than introducing a parallel pair of note scripts for the new faucet, the diff unifies minting and burning behind a single MINT script and a single BURN script that work against both faucet kinds. The scripts determine which kind of faucet is consuming them by reflection, using the new CodeInspection component to test whether the consuming account exposes the fungible or the non-fungible variant of the target procedure, and then dispatch accordingly.

MINT notes are always public so they can be executed by the network, carry no assets themselves, and embed the asset to be minted directly in note storage. For fungible faucets the embedded asset ID binds the note to a single faucet, so a MINT note built for one faucet cannot be redirected to another. The output note produced on consumption may itself be private or public.

Faucet Token Policies

Policies are the procedures that gate faucet operations. The diff consolidates all policy state into a single TokenPolicyManager component covering four kinds:

  • mint, which gates mint operations,
  • burn, which gates burn operations,
  • send, fired by the protocol's on_before_asset_added_to_note callback when one of the faucet's assets is added to a note,
  • receive, fired by the protocol's on_before_asset_added_to_account callback when one of the faucet's assets is added to an account vault.

For each kind the manager owns an active-policy slot holding a procedure root, plus an allowed-policies map used for set-time validation. Mint and burn policies are dispatched by dynamic execution from wrappers invoked by the faucet. Send and receive are dispatched by wrapper procedures whose roots are installed in the protocol-reserved callback slots, so the kernel calls the wrapper, which applies the account-wide pause check and then forwards to the active policy.

The concrete policies shipped in this diff are AllowAll and OwnerOnly for mint and burn, the new MinBurnAmount burn policy which rejects burns below a configurable threshold, and the transfer policies AllowAll, BasicAllowlist, BasicBlocklist, and owner-controlled allowlist and blocklist variants.

Access Control and the Authority Component

Authorization for state-mutating administrative procedures is centralized in a single Authority component. Components that expose privileged setters, such as the policy manager or the faucet metadata setters, do not implement their own checks; they call the shared authority::assert_authorized helper. Installing Authority therefore selects the gating mode for every authority-gated procedure on the account at once, in one of three modes:

  • AuthControlled, under which assert_authorized is a no-op and the account's authentication component is the sole gate,
  • OwnerControlled, which pairs with the Ownable2Step component and requires the caller to be the current owner,
  • RbacControlled, which pairs with the role-based access control component and resolves a role per gated procedure.

Ownable2Step implements two-step ownership transfer, where a nominated owner must explicitly accept before the transfer takes effect. The RBAC component splits privileges into named roles and, following this diff, makes role administration itself fully role-based: every role has an effective admin role, which is its delegated admin when configured and the built-in ADMIN role otherwise. Delegation is exclusive, so once a role's administration is delegated, ADMIN loses authority over it. This permits a configuration in which no single key retains authority over the whole role graph.

Authority also gained an emergency freeze switch. When the frozen flag is set, every procedure that calls assert_authorized aborts. The freeze and unfreeze procedures deliberately bypass the flag so that the switch can always be toggled back, and they are themselves gated by the owner or by a role depending on the configured mode. Separately, the Pausable and PausableManager components provide an account-wide pause flag consulted by policy dispatch, asset callbacks, and metadata setters.

Configuration Notes

Because Miden accounts cannot be called synchronously by other accounts, administrative operations on a deployed account are delivered as notes. The diff introduces a family of configuration notes, one per administrative surface:

  • OwnerActionNote for Ownable2Step operations,
  • RbacActionNote for role grants, revocations, and admin delegation,
  • PauseActionNote for pause and unpause,
  • FaucetPolicyActionNote for switching the active mint, burn, send, or receive policy.

All four share the same shape. The action and its arguments are encoded into the note's storage, which is fixed at note creation and bound into the note commitment, and the consuming account authorizes the operation against the note sender rather than against any note argument. The security consequences of that choice are discussed below.

Authentication Components

The authentication components were substantially reworked. AuthSingleSigAcl had its semantics inverted from a trigger list to an exempt list: every account procedure now requires a signature by default, and only procedures explicitly enumerated in the configuration may execute without one. This makes the safe behavior the default, so that a newly added setter cannot silently become permissionless by being omitted from the configuration.

AuthMultisig and AuthGuardedMultisig enforce a threshold of approver signatures with optional per-procedure overrides, with the guarded variant additionally requiring a guardian co-signature intended to mitigate state-withholding scenarios on private accounts.

AuthNetworkAccount targets accounts operated by the network itself, which have no signature gate. It restricts a transaction to a fixed allowlist of note script roots and, newly in this diff, to a fixed allowlist of transaction script roots. Both allowlists are fixed at account creation, since the component intentionally exposes no procedure to mutate them after deployment.

Wallets, Note Creation, and Transaction Scripts

create_note was extracted from the basic wallet into a standalone NoteCreator component so that accounts can obtain note-creation capability without the rest of the wallet interface. Both continue to require authentication and must be combined with an authentication component. Separately, output-note and account kernel procedures were changed to require an explicit account context.

Two canonical transaction scripts were migrated into this crate: ExpirationTransactionScript, which sets a fixed transaction expiration delta, and SendNotesTransactionScript.

The remaining note scripts also changed. SWAP notes gained support for public payback notes, PSWAP notes now treat the requested amount as a minimum fill rather than an exact amount, P2IDE gained a distinct reclaim authority stored separately from the target, and the Rust side moved most note types to validated builders.

Packaging and Code Inspection

The crate migrated from ad-hoc MASM assembly to the miden-project build system. Each account component is now its own project producing its own package, and the shared standards library is compiled and seeded into an in-memory package registry that the component builds resolve against. The new CodeInspection component exposes read-only introspection over an account's own code, namely whether a given procedure root is present, the code commitment, the procedure count, and the procedure root at a given index. This is the mechanism the unified MINT and BURN scripts use for faucet-kind detection. The pre-existing schema_commitment machinery moved alongside it under a shared inspection module.

Security Model and Trust Assumptions

The following assumptions were taken as given for this review. They are properties of the surrounding protocol or deliberate design choices of the standards library rather than findings.

Authorization by note sender. Every configuration note, and the OwnerControlled and RbacControlled authority modes, authorize against the sender account ID recorded in the note's metadata. This authenticates which account created the note but says nothing about the code that ran when it was created. The check is therefore only meaningful if every account registered as an owner or a role member itself enforces strong authentication. Registering a permissionless account, for example one using NoAuth, as owner or role member provides no protection at all, since anyone can cause such an account to emit a note carrying an arbitrary script and that account's ID as sender.

Allowlists pin code, not inputs. Both the note-script and transaction-script allowlists of AuthNetworkAccount match exact MAST roots. A root pins a script's code but not the arguments it runs on: transaction scripts still receive caller-controlled arguments and advice-provider inputs, and note scripts receive caller-controlled note arguments. On an open network account anyone can supply those, so a root is only safe to allowlist when the script's effect is acceptable for every possible input. Both allowlists are also immutable after deployment, so a root that was not enumerated at creation makes the corresponding note permanently unconsumable.

The AuthControlled invariant. Under Authority::AuthControlled, assert_authorized is a no-op and the account's authentication component becomes the sole gate for every authority-gated setter. The authentication component must therefore authenticate every such setter root; if it does not, those setters are permissionless. This is why the inversion of AuthSingleSigAcl to exempt-list semantics matters, and it is the reason the standard faucet factories pair the two.

Immutability of the asset-callback flag. Send and receive transfer policies are only enforceable if the faucet's account ID was created with the asset-callback flag enabled, and that flag is an immutable property of the account ID. The policy manager therefore installs the callback slots whenever any transfer policy is registered, active or merely reserved. A faucet deployed without any transfer policy can never gain one, while a faucet deployed with a reserved policy can promote it later and have it apply to the entire circulating supply rather than only to assets minted after the switch.

Fixed policy sets. The allowed-policies maps are populated at account construction and the policy manager exposes no procedure to extend them. The set of policies a faucet can ever switch to is therefore fixed at deployment, and switching among them is gated by Authority.

Data availability of private state. For accounts held privately, the state behind the on-chain commitment lives off-chain. Whoever advances the account must share the resulting state with the other stakeholders. Any quorum smaller than the full approver set can advance a private multisig account and withhold the new state from the excluded approvers, permanently locking them out while retaining the ability to spend. This is a data-availability property of private state rather than an authorization flaw, and the mitigations are a public account, unanimity, or the guarded multisig variant whose guardian co-signs and forwards every state transition.

Off-chain non-fungible metadata. A non-fungible asset commits to hash(user_data, salt) and nothing more. The underlying data lives off-chain and its availability, integrity, and interpretation are outside the protocol's guarantees. Changing the data yields a different commitment and therefore a different token.

Bearer semantics for burns. receive_and_burn authorizes on possession: whoever sends the asset back to the faucet in a note gets it burned, subject only to the active burn policy. There is no owner check, because ownership is not tracked. Burns are irreversible for a given token ID.

Signature-scheme privacy. Approvers using the ECDSA over secp256k1 with Keccak scheme disclose their public key and signature at proving time and therefore do not obtain public-key privacy, whereas approvers using Falcon-512 with Poseidon2 do.

Privileged Roles

The standards library does not itself define deployed instances, so no on-chain addresses are applicable. The privileged roles below are those an account instantiated from these components will expose. Which of them exist on a given account depends on the components installed at creation.

Authentication signers. Under Authority::AuthControlled the account's authentication component is the ultimate authority over every gated procedure. Depending on the component installed this is a single signer (AuthSingleSig, AuthSingleSigAcl), a threshold of approvers with optional per-procedure overrides (AuthMultisig), a threshold plus a mandatory guardian co-signature (AuthGuardedMultisig), or nothing at all (NoAuth). Note that per-procedure thresholds are absolute counts rather than ratios, so growing the approver set silently lowers the effective signing ratio of every existing override.

Owner (Ownable2Step). The current owner may exercise every authority-gated procedure on the account under OwnerControlled, may toggle the emergency freeze, and may nominate a successor. Transfer completes only when the nominee accepts. Ownership can be renounced, which permanently removes the ability to administer the account.

RBAC roles. Under RbacControlled each gated procedure may be assigned its own role, keyed by procedure root, with unmapped procedures falling back to the ADMIN role. Typical roles are MINTER, BURNER, PAUSER, UNPAUSER, FREEZER, and UNFREEZER. The ADMIN role is seeded at construction and administers itself. Every other role is administered by its effective admin role, and delegation is exclusive, so a delegated role passes out of ADMIN's reach entirely. Revoking the last member of a role's effective admin role leaves that role permanently unmanageable, which makes ADMIN renouncement as consequential as ownership renouncement.

Freezer. Whoever holds the freeze authority can set the frozen flag on the Authority component and thereby halt every authority-gated procedure on the account. Because freeze and unfreeze bypass the flag, this is recoverable by the corresponding unfreeze authority. The flag has no effect under AuthControlled, where both procedures abort.

Pauser. Whoever holds the pause authority, through PausableManager, can set the account-wide pause flag, which blocks policy dispatch, the asset-transfer callbacks, and metadata setters.

Faucet administrator. Through FaucetPolicyActionNote and the policy manager's setters, the holder of the relevant authority can switch the active mint, burn, send, and receive policies to any root pre-registered in the corresponding allowed-policies map. Because send and receive policies are enforced by protocol-level callbacks on every asset movement, this authority extends to the entire circulating supply of the faucet's asset, not only to newly minted units. Depending on which policies were registered at deployment, this can include the ability to block transfers for arbitrary holders.

Network operator. For accounts using AuthNetworkAccount there is no signer. The effective privilege boundary is the pair of allowlists fixed at creation, and anyone able to construct a transaction may invoke any allowlisted script with arguments of their choosing.

Critical Severity

PSWAP Makers Can Lose Their Entire Offered Asset in Multi-Note Cross-Swaps

The pswap note script pays the swap creator through a P2ID payback note constructed in create_p2id_note. The requested asset may be sourced from the consumer's own vault (the account-fill leg) or from another note consumed in the same transaction (the note-fill leg, which exists to support cross-swap matching between complementary PSWAP notes). The note-fill leg deposits the requested asset through output_note::add_asset, which reads the target note index from the ninth stack position, directly beneath the eight elements of the asset.

In the note-fill branch, the note index is loaded and then displaced eight positions downward by movdn.8 before the asset is constructed. fungible_asset::create subsequently consumes three elements and produces eight, moving the index to depth sixteen. output_note::add_asset therefore reads residual zero padding at depth eight and credits the asset to output note index 0 rather than to the payback note just created. The account-fill branch and the equivalent asset-addition sequence in create_remainder_note position the index correctly and are unaffected. The defective branch remains stack-depth neutral and 0 is a valid note index, so no assertion is triggered, asset conservation still holds, and the transaction executes and proves successfully.

The defect causes loss of funds without any adversarial participation. It remains silent only while the payback note is the first output note of the transaction, which is the case for a single PSWAP note consumed on its own. As soon as a transaction consumes more than one PSWAP note through the note-fill leg, which is the ordinary matching pattern that the leg exists to serve, every leg after the first deposits its payment into output note 0. Executing a two-note cross-swap between complementary PSWAP notes, matched by a neutral party holding no assets and filled entirely through the note-fill leg, produces a first payback note carrying both legs' assets and a second payback note carrying none. The second maker's offered asset has already been released from their note at that point and is not recoverable, so an entirely honest matching transaction transfers one maker's proceeds to another.

The same defect can be exploited deliberately to increase the damage well beyond the accidental case. Because the consumer selects which notes are consumed and in which order, the consumer also determines which note occupies output index 0, and can arrange for that note to be one they control:

  1. The attacker creates a PSWAP note naming themselves as creator and arranges for it to be consumed first, so that its payback note becomes output note 0.
  2. The attacker consumes an arbitrary number of victim PSWAP notes in the same transaction, filling each through the note-fill leg.
  3. Each victim leg constructs its own payback note but deposits the requested asset into output note 0.
  4. Every victim payback note is created empty while output note 0 accumulates the assets owed to all of them, and the attacker consumes that note as its creator.

Existing coverage at pswap_note_note_fill_cross_swap_test and pswap_note_combined_account_fill_and_note_fill_test exercises the affected path but does not detect the defect, because each expected asset is asserted with any across all output notes, and both assertions are satisfied by the single note that received everything.

Consider removing the movdn.8 so that the note index remains where fungible_asset::create naturally leaves it, immediately beneath the constructed asset, matching the sequence already used in create_remainder_note. Consider also asserting in the cross-swap tests that each payback note carries exactly the asset its own creator requested, rather than asserting the presence of each asset somewhere among the output notes, so that a misdirected deposit cannot be masked by a correctly filled sibling note.

Update: Resolved in pull request #3469 at commit 09ada8c.

Indexed Asset Removal Lets Any Note Steal From Another Note Before It Executes

input_note::remove_asset and input_note::remove_all_assets, defined in crates/miden-protocol/asm/protocol/src/input_note.masm, resolve the note they operate on through get_requested_note_ptr. That resolver only substitutes the currently active note's pointer when is_active_note is set; both public wrappers default is_active_note to 0, so any note script can target any other input note in the same transaction purely by its index, regardless of whether that note is the one currently executing. Because input notes execute sequentially in index order within a transaction, a note authored at a lower index can call input_note::remove_asset against a note at a higher index and reduce, or fully clear, what that note will find in its own vault by the time its own script runs. Partial removal of a fungible asset writes the remainder back into the same slot rather than clearing the entry, so any downstream num_assets == 1 check the victim note performs still passes against whatever amount is left. The kernel's asset conservation check compares the total input and output vaults for the whole transaction, not per note, so this redistribution between notes is invisible to it as long as the totals still balance.

The following proof of concept demonstrates the mechanism against a P2ID note:

  1. Alice creates a P2ID note carrying 1000 X, targeted at Dave.
  2. Dave consumes it alongside a note authored at a lower index, whose script calls input_note::remove_asset(X, 999, note_index = 1), taking 999 X for itself, and creates a new output P2ID note addressed to Bob carrying the stolen 999 X.
  3. Partial removal of the fungible asset writes the remainder back into the same slot rather than clearing it, so when Alice's note's own script runs next, add_assets_to_account simply reads and credits whatever is left in the note's vault, which is 1 X, to Dave's account. P2ID performs no check against the amount the note was actually created with.
  4. The ledger closes: input {1000 X} equals output {999 X in the new note to Bob} + {1 X credited to Dave}, and the transaction proves.
  5. Dave, the intended recipient of the full 1000 X, receives only 1 X, while the other 999 X ends up with Bob.

Every note script that reads its attached assets through active_note::remove_all_assets/get_assets shares this exposure, with variations in how the theft manifests:

  • P2ID and P2IDE: as shown in the proof of concept, both call the shared add_assets_to_account helper, which credits the account with whatever the note's vault currently holds, with no amount check at all. A lower-indexed note can remove most of the note's asset and redirect it to a freshly created output note addressed to a third party, leaving the intended recipient with only the residual amount.
  • SWAP: the payback amount sent to the note's creator is a fixed value read from note storage, independent of what is actually received. SWAP reads its offered asset the same way as P2ID, via active_note::remove_all_assets followed by an eq.1 count check. A prior note can steal most of the offered asset before SWAP runs; the consumer still pays the full fixed price for the requested asset but receives only the residual offered amount, and the stolen difference goes to whoever authored the malicious note.
  • PSWAP: the note reads its offered asset the same way, and the proportional fill payout is computed against whatever amount is left after a prior note's removal rather than the amount originally offered. This lets a consumer pay a price far below the rate the offering party intended, since the fill formula's denominator is scaled down along with the stolen offer, while the stolen difference is diverted elsewhere.
  • Fungible faucet BURN's receive_and_burn procedure checks num_assets == 1 only, never the amount. A prior note can remove most of the amount meant for destruction before BURN runs; the residual is burned and passes every check, while the stolen amount escapes destruction and remains in circulation. Non-fungible BURN's equivalent receive_and_burn has the identical count-only check but is not exploitable this way, since non-composable assets cannot be partially removed; a full theft would leave zero assets, causing the same count check to fail and revert the entire transaction.
  • B2AGG's bridge-out path reads its asset via active_note::remove_all_assets, checks num_assets == 1, then calls bridge_out::bridge_out with whatever amount remains, with no amount cross-check. A prior note can siphon most of the intended bridged amount, so only the residual is actually bridged out to the destination chain while the rest is retained on Miden by the attacker.

Consider restricting input_note::remove_asset and input_note::remove_all_assets so that get_requested_note_ptr can only resolve to the currently active note, removing indexed access to any other input note's assets entirely. If cross-note indexed removal by index is an intentional capability that must be preserved, consider having the kernel record each input note's original asset amounts at prologue time and expose a way for a note's own script to read that recorded baseline and compare it against what it currently finds in its vault. This would let P2ID, P2IDE, SWAP, PSWAP, BURN, and B2AGG, all of which need this integrity guarantee, assert for themselves that their assets were not altered by an earlier note, while still allowing the kernel to permit cross-note removal for scripts that do not depend on it.

Update: Resolved in pull request #3451 and pull request #3652. Indexed input-note asset removal is now gated on the native account context, so note scripts, transaction scripts and foreign accounts reached through FPI can no longer target another input note by index. PSWAP additionally prices each fill against the note's initial offered asset and aborts with ERR_PSWAP_OFFERED_ASSET_ALTERED when the remaining offered asset differs from it.

High Severity

Non-Fungible MINT Notes Can Be Consumed By Any Faucet, Permanently Destroying Mint Entitlements

A Miden transaction executes against a single native account, and notes are the mechanism by which one account asynchronously instructs another. Note consumption is permissionless at the protocol level: any account exposing the procedures a note script invokes may consume a public note. Note tags provide routing only and, as documented in note_tag.rs, are not validated in any way and act only as best-effort filters. The standards library provides a single MINT note that instructs a faucet to mint the asset recorded in the note storage, dispatching to the fungible or non-fungible faucet by reflection on the procedures the consuming account exposes.

The fungible variant commits the complete asset, both ASSET_ID and ASSET_VALUE, to the note storage, and fungible::mint_and_send asserts that the asset derived for the consuming account equals the stored ASSET_ID. The non-fungible variant records only the asset commitment: the faucet identifier supplied to MintNoteStorage is discarded during serialization and survives only in the routing tag. non_fungible::mint_and_send derives the issuer from the active account and accepts no parameter identifying the intended faucet, so no equivalent bind exists on the path reached from mint_non_fungible. The bridge specification states the intended property as an enforced permission, "Consumer: Target faucet only", names the ASSET_ID comparison as its enforcement mechanism, and explicitly excludes the NetworkAccountTarget attachment as a consume-side bind.

Any account exposing the CodeInspection component and a non-fungible mint_and_send can therefore consume a MINT note addressed to a different faucet. No protocol-level faucet account type exists, and the kernel's mint validates only that the minted asset's issuer equals the executing account, a condition the consuming account satisfies by construction because the asset is derived from its own identifier. The consumption succeeds, which spends the note nullifier and permanently prevents the intended faucet from consuming the note, while the recipient receives an asset carrying the intended commitment under a different issuer. The fungible assertion instead fails the transaction, and because a failed transaction publishes no nullifier, the fungible note survives for its rightful consumer. Preparing the attack requires one account and one transaction. No race against the intended consumer is required while the target faucet is paused, because execute_mint_policy begins with a not-paused assertion that the intended faucet fails and an attacker-controlled faucet does not.

The consequence is unrecoverable where the note is emitted as a one-shot effect of a state transition that has already been committed. The AggLayer claim path has this shape for fungible assets, marking a leaf permanently claimed before emitting the MINT note, so an equivalent non-fungible integration would lose the entitlement with no path to reissue. Remediation after deployment is also unavailable: the kernel exposes a code commitment getter and no setter, the account upgrade procedure is documented as a no-op whose recorded commitments are never applied, and an account identifier is derived from the initial code commitment. Because a non-fungible asset identifier embeds the issuing faucet identifier, deploying a corrected faucet yields a different asset identifier for the same commitment rather than migrating an existing collection.

Consider committing the intended faucet identifier to the non-fungible MINT note storage and asserting it against the active account before minting, mirroring the target check in p2id, or alternatively requiring the active_account_matches_target_account check already used by the AggLayer note scripts. Either approach can be applied within the note script alone, leaving deployed faucet code unchanged, and aborts the transaction without spending the nullifier so that the note remains available to its intended faucet. Consider also adding a regression test mirroring the existing cross-faucet test for the fungible path.

Update: Resolved in pull request #3482 at commit ff0a355 and at commit 406825e.

Multisig And Guarded Fungible Faucet Factories Disable Transfer Policy Enforcement

TokenPolicyManager enforces send and receive restrictions through protocol-reserved asset callback slots, invoked by the kernel whenever an asset is added to an account vault via callbacks::on_before_asset_added_to_account or to an output note via callbacks::on_before_asset_added_to_note. Whether the kernel invokes these callbacks at all is gated by AssetCallbackFlag, a bit encoded immutably into the issuing faucet's account ID at creation. TokenPolicyManager documentation states the invariant plainly: whenever at least one send or receive policy is registered, the faucet's account ID must be created with AssetCallbackFlag::Enabled, a condition has_transfer_policy reports.

create_singlesig_user_fungible_faucet and create_network_fungible_faucet compute this flag correctly and pass it to the builder. create_multisig_user_fungible_faucet and create_guarded_user_fungible_faucet take the same TokenPolicyManager parameter but never call with_asset_callbacks, leaving the builder's default of AssetCallbackFlag::Disabled. Because this flag is encoded into the account ID at creation, it cannot be corrected after deployment.

The callback slots themselves are still installed correctly by TokenPolicyManager regardless of which factory is used, since that logic does not depend on the factory. What breaks is only the kernel's decision to invoke them: with the flag disabled, the callback procedures return the asset unmodified without reading the installed slots at all. A faucet built through either affected factory with an allowlist, blocklist, or pause-gated transfer policy therefore behaves as an unrestricted, freely transferable token for its entire circulating supply, while every other part of the configuration, including the storage slots and the policy getters, reports the policy as active. Mint and burn policies are unaffected, since the faucet's own procedures invoke those directly rather than through this callback path; the exposure is limited to send and receive policies, but within that scope it is total, silent, and unrecoverable without redeploying under a new account ID and migrating the supply.

Consider calling with_asset_callbacks(AssetCallbackFlag::from(token_policy_manager.has_transfer_policy())) in create_multisig_user_fungible_faucet and create_guarded_user_fungible_faucet, matching the singlesig and network factories. Consider also checking the equivalent non-fungible faucet factories for the same omission, and adding a construction-time assertion that rejects building any faucet with a registered send or receive policy unless the resulting account ID has callbacks enabled, so a future factory cannot reintroduce this gap silently.

Update: Resolved in pull request #3547 at commit 13983ef and in pull request #3658 at commit b8ae934.

Management Notes Do Not Bind Their Managed Account, Permitting Privilege Escalation Across Accounts

Four note scripts in the standards library instruct the consuming account to perform a privileged configuration change: owner_action, rbac_action, pause_action, and faucet_policy_action. In each case, the managed account appears only in the note metadata tag, set via NoteTag::with_account_target, and never enters the note storage, the recipient, or an attachment. None of the four scripts compares the active account against an intended target. Note tags are documented as best-effort filters that are not validated in any way, so the association between a management note and the account it configures is advisory rather than enforced.

Each script does perform an authorization check, but every such check resolves the note sender against the consuming account's own state. assert_sender_is_owner and assert_sender_is_role_admin establish whether the sender may act on the account currently executing, not whether that account is the one the note was written for. The initial owner is a plain constructor parameter of Ownable2Step, and although an ownership transfer requires two-step acceptance, the value set at creation does not, so any account identifier can be claimed unilaterally. An attacker can therefore create an account carrying the same component, record the victim note's sender as its owner or role admin, and satisfy the check. Two of the four scripts deviate from this baseline. The pause action requires no identity claim at all, because assert_authorized returns without performing any check when the account's authority is AUTH_CONTROLLED, on the stated basis that the account's auth component has already gated the call. The faucet policy action requires more preparation, because the supplied policy root must both exist on the consuming account and appear in its allowlist (policy_manager); absent that setup the transaction reverts and the note is not consumed.

A note's nullifier is derived from the note alone, namely its serial number, script root, storage commitment, asset commitment, metadata, and attachments commitment, and does not incorporate the consuming account. Consumption by any account therefore spends the note globally, and the account it was written for can never consume it afterward. The effect is not limited to denying the intended target the update; the configuration change is instead applied to whichever account satisfies the authorization check, which is a privilege escalation whenever that account is not one the note's issuer controls. This occurs whenever the same owner or role-admin identity is legitimately registered on more than one account, a normal outcome for an operator managing several deployments, since none of the four scripts distinguish which of those accounts a given note is meant for. It also occurs without any prior relationship to the note's issuer at all on network accounts: their default authentication permits anyone to invoke any exposed procedure, so the only gate on a management action is the secondary layer these four scripts rely on, namely Ownable2Step or RBAC checking the note's sender. Consuming the note against a different network account that happens to recognize the same sender as owner or role admin therefore lets anyone trigger the privileged action on that account, with no signature from, or prior relationship to, the note's issuer required. Where the note carries an emergency control, such as the pause action, this can also reverse a live safeguard on an account that was deliberately left in its current state. The attack costs at most one account and one transaction, and can be repeated against each replacement note, including notes issued to recover from a previous instance.

Consider binding each management note to its managed account and performing that check before any other logic, so that a consumer other than the intended target aborts and the note is not spent. Requiring a NetworkAccountTarget attachment and asserting active_account_matches_target_account, as every AggLayer note script already does, is the more direct option, and because the attachments commitment is an input to the nullifier the binding cannot be stripped or repointed. Committing the managed account identifier to the note storage and asserting it against the active account, as p2id does, offers an equivalent guarantee, since the storage commitment is likewise an input to the recipient digest and the nullifier. Because these four scripts share a single shape, consider applying the check as a required element of the management note pattern so that notes added subsequently inherit it by default.

Update: Resolved in pull request #3455 at commit b9750ad.

Transfer Policy Enforcement Reads Faucet State From Prover-Chosen Historical Reference Blocks

TokenPolicyManager's send and receive policies are enforced through a foreign procedure invocation. invoke_callback opens a foreign context against the issuing faucet, and invoke_transfer_policy reads the active policy root from that foreign account's storage. Like any foreign procedure invocation, this read is a snapshot anchored to the transaction's reference block rather than the faucet's current state, and it is never reconciled against the live chain when the transaction is included. Since executors may choose arbitrary reference blocks, the policy state a transfer is checked against is effectively selected by whoever proves the transaction.

A transaction only expires, and is thereby forced to re-anchor to a recent block, if it explicitly calls tx::update_expiration_block_delta. invoke_transfer_policy never does, so a transfer built against an old reference block remains valid indefinitely and is checked against whatever allowlist, blocklist, or pause state the faucet had at that block, not its current one:

  • A faucet owner allows account B to receive its token, then later revokes that permission.
  • After the revocation, B builds a transfer that anchors to a reference block from before the revocation and reuses the matching foreign-account witness.
  • The transfer is checked against the pre-revocation allowlist and succeeds, crediting B in full, even though B is no longer allowed as of the chain's current state.

The same behavior applies to a pause: pausing a faucet after a transaction's chosen reference block does not stop that transaction, since its pause check still reads the pre-pause state.

Consider having the transfer-policy and pause callbacks call tx::update_expiration_block_delta with a faucet-configured delta, so a transfer is only valid within a bounded number of blocks of its reference block.

Update: Resolved in pull request #3748 at commit ddce24d and pull request #3881 at commit c7142b1. The dispatcher shared by invoke_send_policy and invoke_receive_policy now limits the transaction's expiration to twenty blocks after the reference block and asserts that the faucet is not paused before reading the active policy root, so both checks also apply when no policy is active. A transfer can therefore only be included within twenty blocks of its reference block, which bounds how long a revoked allowlist entry, a pause, or a later policy activation can be bypassed with stale faucet state.

Medium Severity

Multiple Call-Invoked Getters Trap Due To Excessive Return Stack Depth

Returning from a call with more than 16 elements on the operand stack causes the VM to trap; when there are fewer than 16 elements, the stack is automatically restored back up to 16. Five getters in the standards library return with more than 16 elements and therefore fail on every invocation:

As a result, none of these values can currently be read through the account's public interface.

Consider fixing the stack cleanup in each of the five procedures so they return at depth 16 or less, and consider additionally adding test coverage that invokes each getter via call.

Update: Resolved in pull request #3470 and pull request #3645.

Unconsumed Administrative Action Notes Retain Authority Indefinitely

The standards library provides four administrative notes: pause_action, owner_action, rbac_action, and faucet_policy_action. An owner or role holder creates one of these notes to request a privileged action, such as pausing a faucet or granting a role, and the account carries the action out when the note is consumed.

There is no limit on when a note can be consumed. No script reads the block number, no counter marks a note as outdated, and the protocol offers no way to withdraw a note once it exists. A note therefore stays valid indefinitely, for as long as its sender remains authorized, until somebody consumes it. The missing control is available in the same directory, since p2ide reads the current block number and refuses to run before a chosen height. Several of the actions also overwrite state without checking what is already there. set_mint_policy confirms that the new policy appears on an approved list but never looks at the policy currently in force, and that list is fixed when the account is created and thereafter only ever read, never written, so a policy allowed once remains allowed for the life of the account.

Who can consume such a note depends on how the account authenticates. If it requires a signature, only the key holder can submit the transaction, and the risk is that an operator's own tooling runs an outdated instruction. However, all four note types are documented as public and intended for network execution, and each fixes the public note type when converted into a protocol note, so an operator cannot issue them privately to limit who sees them. The network authentication component checks no signature. It confirms only that the transaction script, if any, and every input note script appear on approved lists, which are never changed after the account is created. Any deployment that manages a network faucet through these notes must add those note scripts to that list, and once it does, anyone may submit the transaction that consumes an outstanding note and therefore chooses the moment it takes effect. No shipped constructor adds them today, as create_network_fungible_faucet approves only the mint and burn note scripts. The component's own guidance nonetheless invites the change, advising that a script may be allowlisted when its effect is safe for every possible input: the four administrative scripts discard their note arguments and read their action from note storage fixed at creation, so they satisfy that stated test while remaining unsafe for the timing reasons described here.

Notes also run in the order the submitter chooses, and a later note sees whatever an earlier note in the same transaction wrote. Where any party may execute a transaction against the managed account, the following outcomes are reachable:

  • Supply drain. A leftover note that restores a permissive mint policy can be placed ahead of an attacker's own mint note in one transaction. The policy change and the minting happen together, so the supply is drained before any observer can react.
  • Ownership takeover. If a planned ownership handover is called off, cancelling the nomination does not remove the outstanding note, because the nomination was never written. That note and the nominee's own acceptance note can later be consumed together, making the nominee the owner.
  • Restored access after removal. A leftover note that grants a role restores it after that role was deliberately revoked. An administrator cannot pre-position a revoke note as a defense, because revoking a role from an account that does not hold it fails.
  • Emergency pause defeated. A leftover unpause note clears the pause flag without checking whether the account is paused. Placing a leftover pause note after it restores the flag, so the pause state afterwards appears unchanged.
  • Permanent loss of control. A leftover note that renounces ownership sets the owner to zero. No party can restore an owner afterwards, and every owner-gated action fails permanently.

Freezing the account does not help. A frozen account rejects pause_action and faucet_policy_action notes rather than consuming them, so those notes are not used up and remain available afterwards. The other two are unaffected entirely, because ownable2step and rbac do not consult the authority component, so owner_action and rbac_action notes still succeed while the account is frozen. Removing the sender's ownership or role invalidates that sender's outstanding notes that depend on the authority removed, but changing the sender account's signing keys does not, because authorization is tied to the account identifier, which never changes. The only dependable option is for the account to consume each unwanted note itself, which requires knowing the note exists and acting first. The project has already described this risk for a different feature: the transaction documentation warns that a prover may anchor to an old block "to bypass cross-account authorization such as roles or allowlists" and asks the affected account to defend itself with an expiration delta. No comparable control exists for a note that changes ownership, roles, pause state, or mint policy.

Consider storing a block height in each administrative note and checking it against the current block number before acting, following the pattern already used by p2ide, so that a note cannot be held indefinitely. Consider checking the expected state before overwriting it, so that a policy setter refuses a change from a policy the note's author did not anticipate; the pausable module already defines assert_paused for this purpose and nothing calls it. If a broader control is preferred, consider a per-account counter that every administrative note must match, so that increasing it invalidates all outstanding notes at once. Consider also allowing the approved-policy list to be reduced, so that a policy retired after a launch phase can be removed rather than remaining permanently available, and adding tests for delayed and out-of-order consumption, which the current suites for all four notes omit.

Update: Resolved in pull request #3715.

TokenPolicyManager Schema Omits Its Own Callback Slots, So Schema-Built Faucets Deploy With Transfer Policies That Never Run

TokenPolicyManager::component_metadata() declares a storage schema of exactly eight slots: the four active-policy-root slots and the four allowed-policy-root map slots. The component's real storage layout is larger than that. TokenPolicyManager::manager_storage_slots() appends two further protocol-reserved callback slots, on_before_asset_added_to_account and on_before_asset_added_to_note, whenever has_transfer_policy() is true. These callback slots hold the wrapper procedure roots the kernel dispatches to in order to enforce the active send and receive policies; without them, the account has no way of registering that enforcement at all.

Anyone reconstructing the component through the schema-driven path rather than through TokenPolicyManager's own Rust constructor never sees this second slot pair. AccountComponent::from_library builds storage_slots by calling metadata.storage_schema().build_storage_slots(init_storage_data), which iterates the schema's own declared slots and nothing else; an init entry naming a slot the schema does not know about is silently dropped rather than rejected. A faucet built this way, for example by tooling that reconstructs an AccountComponent from a published package plus initialization data, ends up with the eight manager slots fully populated with active policy roots and allowlists, and with no callback slots at all, even though the deployer's intent, expressed through that same initialization data, was to have transfer policies enforced.

The consequence surfaces only later, and silently. Where the asset issuer's account ID was created with the enabled asset-callback flag, the kernel looks up the callback slot by name on each transfer; when the slot is absent, the lookup reports it as not found and the callback returns the unmodified asset value, taking the same code path it would take for a faucet that was never configured with transfer policies in the first place. Nothing reverts, nothing logs, and nothing in the account's own state betrays the gap: the active send and receive policy roots and their allowlists are all present and correctly set, so an operator or auditor inspecting the account's storage would see a fully configured policy manager. Because storage slots are fixed at account creation, along with the account ID and its storage commitment, the missing slots cannot be added to an already-deployed faucet; the only remedy is to redeploy under a new account ID with the callback slots correctly installed, and migrate the circulating supply to it.

Consider including the two protocol callback slots in TokenPolicyManager::component_metadata() whenever has_transfer_policy() is true, or declaring them unconditionally, so the schema and the component's real storage layout can no longer disagree. Consider also having schema-driven construction reject initialization entries that name a slot absent from the schema, rather than dropping them, so this kind of gap fails loudly at build time instead of shipping silently into a deployed account.

Update: Resolved in pull request #3648 at commit 17a5f56.

Network Faucet Factories Leave Every Administrative Action Permanently Unreachable

The AuthNetworkAccount authentication component gates a network account on two allowlists of script roots, one for the input notes the account may consume and one for the transaction scripts that may run against it. Its auth_network_transaction procedure runs on every transaction and rejects it unless the transaction script root is allowlisted and every consumed input note carries an allowlisted script root. Both lists are fixed at account creation, the component exports no procedure to mutate them after deployment, and account code is immutable, so a root omitted at creation makes the corresponding note permanently and silently unconsumable.

create_network_fungible_faucet installs PausableManager, TokenPolicyManager and the selected AccessControl variant, each of which exports authority-gated administrative procedures, and then hardcodes the note allowlist to the MintNote and BurnNote roots and the transaction script allowlist to the canonical expiration setter. Those administrative procedures are reachable only through notes: pause and set_mint_policy are account procedures gated by authority::assert_authorized, which under Authority::OwnerControlled resolves to is_sender_owner and under Authority::RbacControlled resolves role membership against the note sender in the same way. The standard delivery vehicles for those calls are the action notes PauseActionNote, FaucetPolicyActionNote, OwnerActionNote and RbacActionNote, none of whose script roots appear in the hardcoded allowlist, so authentication aborts any transaction that consumes one. Allowlisting a transaction script would not restore these paths either, because a transaction script has no note sender to authorize against, and the factory signature exposes no parameter for extending either list. create_network_non_fungible_faucet repeats the identical construction next to the same bundled components.

Every account produced by these factories therefore loses pause and unpause, the four token policy switches, ownership transfer, and role grants and revocations, deterministically and from the moment of creation, so the safety and governance controls the factory documentation advertises are inert. The unreachable pause gates live behavior, as execute_mint_policy begins by asserting that the account is not paused. The condition stays silent until the first administrative attempt, which is typically an incident response such as tightening a mint policy after a key compromise, and it cannot be repaired on chain, since the allowlist has no setter and redeploying produces a different faucet identifier that orphans already issued assets, leaving migration of existing token holders as the only remediation. The divergence is currently untested, because the test helper that mirrors the factory omits AuthNetworkAccount and accepts an arbitrary authentication method, so no test exercises an administrative note against the allowlist the factory actually ships. The AggLayer bridge, by contrast, enumerates its management notes in allowed_notes.

Consider resolving the mismatch in one direction rather than leaving both halves in place. One option is to widen the note allowlist built by both factories to cover every management note root implied by the components they install, that is PauseActionNote, FaucetPolicyActionNote, and whichever of OwnerActionNote or RbacActionNote matches the selected access control variant. Another is to expose the allowlist as an explicit parameter that callers extend, chained onto the mandatory MintNote and BurnNote entries and defaulting to that management set, following the pattern already used by the test chain builder. If network faucets are instead intended to be administration free, the alternative is to stop bundling PausableManager and the authority-gated policy setters and to correct the factory documentation accordingly. In every case, consider documenting the intended operational model for these accounts, in particular whether administration is expected to flow through management notes or through allowlisted transaction scripts, and adding an integration test that builds a faucet through the factory itself, with its real authentication component, and drives one administrative action end to end so that any future divergence between the allowlist and the bundled management surface fails a test.

Update: Partially Resolved in pull request #3330, pull request #3355 and pull request #3392. AuthNetworkAccount now exports four authority-gated allowlist setters and its constructor always allowlists the NetworkAccountConfigNote that drives them, so neither list is fixed at deployment. The fungible factory still pins its note allowlist to the MintNote and BurnNote roots and the non-fungible factory repeats it, so the management notes of the bundled PausableManager, TokenPolicyManager and AccessControl components remain unconsumable on a newly created faucet. Reaching them further requires the deployer's FeePolicyManager to schedule a fee for every added root, because the auth procedure prices each input note and BasicConstantFeePolicy aborts on an unscheduled root, with no on-chain way to extend the schedule.

Low Severity

Exec Procedures Incorrectly Assume Caller-Provided Stack Padding

Exec-invoked procedures elsewhere in crates/miden-standards/asm/standards/ do not document trailing stack padding in their Inputs/Outputs, since exec inlines into the caller's stack and has no ABI guarantee that specific slots are blank, unlike call, whose fixed calling convention does guarantee padding. The following procedures document pad(...) on their inputs regardless:

Beyond the documentation inconsistency, the mint_public and mint_private procedures in both files also act on this assumption directly. mint_public uses movdn.8 to relocate the assumed-padding region, then overwrites it via mem_loadw_le and mem_load with no preceding padw. mint_private does the same with its first mem_loadw_le and mem_load calls. Since these are exec procedures, nothing guarantees the caller's stack in that region is actually blank; if a future caller held meaningful data there, these instructions would silently overwrite and destroy it.

Consider replacing the direct overwrites of assumed-blank stack space with an explicit padw (or equivalent) before each load, matching the safe pattern already used elsewhere in the same procedures, and removing the pad(...) annotations from the affected doc comments so they are consistent with how every other exec procedure in the standards library documents its stack contract.

Update: Resolved in pull request #3668 at commit f24003f.

Component Namespace Mismatch Makes NoteCreator::create_note_root Panic Unconditionally

The Rust wrappers resolve a component's procedure roots by concatenating its NAME constant with a procedure name and matching the result against the compiled package exports by exact path equality. For the note creator component the two sides disagree: the manifest publishes it under miden::standards::components::wallets::note_creator, while NoteCreator::NAME is miden::standards::components::note::note_creator, matching the component's actual location under asm/components/note. No export matches, and the failed lookup is converted into a panic, so the public create_note_root accessor panics on every invocation, since it is backed by a LazyLock that forces this lookup on first access.

The defect is masked in-tree, since no caller exists in the repository and the lookup is deferred behind the LazyLock, leaving continuous integration green. It surfaces downstream, where procedure roots are the identifiers used to configure per-procedure access control such as RBAC role maps, ACL allowlists, and asset callback slots. An integrator combining the note creator component with any such scheme must obtain the root of create_note and encounters the panic on first call. The same mismatched NAME also flows into NoteCreator::component_metadata(), which reports the same wrong namespace without panicking, so tooling that keys configuration off the canonical component name receives a name that does not match the compiled component it describes. Note creation itself is unaffected, and because the account code commitment covers procedure roots alone, account state is not affected either.

Consider correcting the manifest namespace to miden::standards::components::note::note_creator, matching NoteCreator::NAME and the component's location under asm/components/note, and updating the package name and the .masp asset path referenced by account_component_code! to match. Where a display name and a published namespace are intended to differ, the established alternative is a dedicated lookup-path constant, as TokenPolicyManager defines. Consider also having get_procedure_root_by_path callers return a Result rather than panic on a failed lookup, so a future namespace divergence surfaces as a recoverable error instead of an unconditional panic. A test forcing evaluation of every procedure-root accessor in miden-standards would catch a future divergence in continuous integration rather than downstream.

Update: Resolved in pull request #3495 and pull request #3473.

Delay-Only Procedure Policies Can Permanently Brick multisig_smart Procedures

set_procedure_policy accepts a policy with immediate_threshold = 0 and delayed_threshold > 0 (a delay-only policy) without rejecting it. enforce_procedure_policy always evaluates policies in immediate mode, and compute_proc_policy_contribution panics whenever a procedure carries a policy configured only for the opposite mode. As a result, any procedure assigned a delay-only policy becomes permanently uncallable starting with the next transaction. Because set_procedure_policy is itself subject to policy evaluation and is the only procedure that writes the policy map, targeting set_procedure_policy with a delay-only policy leaves no way to correct it afterward. The same combination can also be baked into an account's initial storage through the public with_proc_policies and with_delay_threshold constructors, since deployment-time validation there checks only for duplicate roots and threshold bounds.

The delayed execution path is not implemented, so a delay-only policy has no working use today. Reaching this state requires the signing quorum to configure an unsupported combination, and that same quorum already has simpler ways to render the account unusable, such as rotating its own auth keys to something nobody controls. This is a self-inflicted misconfiguration of an incomplete feature rather than an exploit with an adversary on the other side.

Consider rejecting delay-only policies until delayed execution is implemented, requiring delayed_threshold == 0 whenever immediate_threshold == 0 in both the on-chain setter and the Rust constructors, and adding a regression test that exercises a policy update targeting set_procedure_policy itself.

Update: Resolved in pull request #3781.

PswapNote::remainder_note Recomputes a Tag that the On-Chain Script Only Ever Copies

On a partial fill, the PSWAP note script builds the remainder note's tag by reading it directly off the parent note's own metadata via exec.active_note::get_metadata followed by exec.note::metadata_into_tag, then passing that value unchanged into create_remainder_note. The kernel accepts whatever tag arrives this way, checking only that it fits a u32. The remainder therefore always carries the exact same tag as its parent, whatever that tag happens to be.

PswapNote::remainder_note does not mirror this. It derives the remainder's tag by calling PswapNote::create_tag, which recomputes a canonical tag from the note type, script root, and the two faucet IDs, rather than reusing the parent's actual tag. PswapNote has no field for the original tag to begin with: TryFrom<&Note> for PswapNote never reads it off the parsed note, and From<PswapNote> for Note always re-derives it through the same create_tag call. As long as a PSWAP note was itself built through this library, its tag already equals what create_tag would produce, and the two paths agree. They only diverge when a PSWAP note was constructed by hand with a tag create_tag would not have chosen, for instance a private, non-canonical tag aimed at a specific counterparty. The kernel does not require the canonical tag and does not reject one, so such a note executes normally on chain, including its own remainder handling, which faithfully copies the non-canonical tag forward at every partial fill.

Since the tag is part of the metadata word hashed into both the NoteId and the Nullifier, a Rust-side caller reconstructing the remainder note off chain with remainder_note computes a different tag than the one the chain actually used, and ends up with the wrong NoteId and Nullifier for it. The real remainder note that was created on chain is unaffected: its assets, recipient, and consumption all proceed correctly, and no value is misrouted. The consumer of the parent note cannot influence the tag either, since the script only ever copies it forward. What breaks is off-chain tracking: a wallet or indexer computing the expected remainder identifier through remainder_note looks for the wrong note and cannot find the real one. Because the party who chose the non-canonical tag in the first place is the same party holding the parent Note, they already have the true tag available and are the one best positioned to reconstruct the remainder correctly; the gap is in the library's own reconstruction path rather than in anyone else's ability to track the note.

Consider having PswapNote retain the parent note's tag when parsed, and having remainder_note reuse that stored tag instead of recomputing it through create_tag, matching the on-chain script's copy-forward behavior. Consider also validating at parse time that a PswapNote's tag matches what create_tag would produce, so that reconstruction stays well defined for any note the library did not itself construct, or so that non-canonical tags are rejected outright if canonical tags are meant to be required.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Documented Companion-Component Requirements Are Not Enforced at Build Time

Several standard components document, in their own doc comments, that they require another specific component to also be installed on the account, but nothing in AccountBuilder/TokenPolicyManager actually checks for this at build time:

AccountBuilder::build validates only structural properties of the merged components (procedure and storage slot counts, MAST root collisions, auth component presence and uniqueness); it never checks that a component's documented companions are actually present. A faucet or account built with any of the above components but missing its documented companion deploys successfully and looks correctly configured, but the affected operation panics on the missing slot the first time it is used, permanently, since the account's components are fixed after deployment.

A related mechanism already exists for policies registered through TokenPolicyManager: each policy descriptor carries a components field, and registering a policy automatically installs whatever it lists there. This does not help in any of the cases above, since the missing companion in each case requires per-deployment configuration data (an owner account ID for Ownable2Step, an initial allow/block list for BasicAllowlist/BasicBlocklist) that the affected component's own zero-argument constructor has no way to supply.

Consider having AccountBuilder/TokenPolicyManager reject a build when an installed component's documented companion is missing, so the mistake surfaces as a build error rather than a permanently broken account.

Update: Acknowledged, will resolve. The team stated:

“Part of it is resolved by this PR: https://github.com/0xMiden/protocol/pull/3527. The other part will be resolved by a future refactor discussed in the PR”

Non-Fungible Asset Values Are Documented as Verified Commitments but Are Accepted Unchecked

The non-fungible faucet module header and the mint_and_send documentation state that the asset value is the off-chain commitment hash(user_data, salt). The procedure accepts an arbitrary word. The only constraints applied are that the active mint policy does not modify the value and that the derived token ID has not already been issued. Nothing establishes that the value is a hash output, that a salt was used, or that any party knows a preimage, so a value copied from another party's published commitment or chosen for its structure is accepted identically to a correctly constructed one.

The Rust helper documentation is explicit that the faucet never observes user_data or salt, which confirms that the stated identity model is a client-side convention rather than an enforced property. Presenting it as a definition can lead integrators to treat a minted asset value as an attestation to off-chain data that the faucet has validated, and to omit the client-side checks that would make that assumption hold.

Consider restating the identity model as a convention that clients are expected to follow, and noting explicitly that the faucet accepts the value opaquely and validates neither its construction nor knowledge of a preimage. Where the construction is intended to be a guarantee rather than a convention, consider enforcing what is enforceable on-chain through a dedicated mint policy, since the policy hook already receives the full asset value.

Update: Resolved in pull request #3484.

Private Faucets with Callbacks Enabled Can Render Their Assets Unusable

The fungible faucet factory create_singlesig_user_fungible_faucet derives the asset callback flag from has_transfer_policy and forwards the caller-supplied account type to the account builder unchanged, with no constraint relating the two. A faucet that registers a send or receive policy can therefore be created as AccountType::Private, whose state is stored off-chain with only a commitment published on-chain. Both the account type and the AssetCallbackFlag are immutable bits of the account ID that are fixed at creation, so the combination cannot be corrected on an existing faucet, and AccountBuilder defaults to a private account, which makes the combination the result of omitting an argument rather than of a deliberate choice.

Every movement of an asset issued by such a faucet requires the faucet's own state as a transaction input. Adding the asset to an account vault through add_asset_to_vault and adding it to an output note through add_asset both dispatch the issuing faucet's callbacks, and maybe_start_faucet_callback_context starts a foreign context against the faucet whenever the faucet is not the active account, before the callback procedure root is looked up in its storage. The skip conditions documented for on_before_asset_added_to_account, namely a missing storage slot or an empty slot value, are evaluated only after the foreign state has been loaded, so the load occurs even when the faucet registers no callback procedure. The executor answers that load exclusively from the data store through get_foreign_account_inputs, and a store that cannot supply the account aborts execution. A holder who cannot obtain a committed state of the private faucet is therefore unable to prove either the receipt or the sending of the asset, which leaves the asset unusable without the issuer supplying witness data. Only the faucet issuer can produce this configuration, and only assets issued by that faucet are affected.

Consider rejecting the combination at construction time, either by returning an error from the user faucet factory when a transfer policy is configured and a private account type is requested, or by enforcing the constraint in AccountBuilder so that any account created with asset callbacks enabled must be public. Consider also documenting that enabling callbacks makes the faucet's state a required transaction input for every holder of its assets, and that the foreign account load is performed even when no callback procedure is registered.

Update: Partially Resolved in pull request #3812. The type-level documentation of AccountBuilder now records that an enabled asset callback flag makes the issuing account's state a required input of every transaction that moves one of its assets, the kernel callback dispatch procedure documents that the foreign state is loaded before the callback slot is read, and TokenPolicyManager states that holders of a private faucet's assets must obtain that state out of band. The recommended construction-time constraint was not adopted: create_singlesig_user_fungible_faucet still forwards the caller-supplied account type unchanged, and build derives the callback flag from the installed callback slots without relating it to the account type, which still defaults to AccountType::Private.

Zero-Amount Mints Emit Faucet-Origin Notes Without Changing Token Supply

The fungible faucet mint procedure executes the active mint policy, derives the amount from the policy-adjusted asset value word, and applies only upper bounds to that amount (mint_and_send). No lower bound is enforced, so an amount of zero satisfies every check, including the constraint that the amount not exceed the remaining mintable supply, which holds trivially even when the remaining supply is zero (ERR_FUNGIBLE_ASSET_DISTRIBUTE_AMOUNT_EXCEEDS_MAX_SUPPLY). The remainder of the transaction proceeds normally: the kernel accepts a zero amount as a well-formed fungible asset value (validate), the vault updates are no-ops because a zero amount is the representation of an absent asset, and an output note is created with the faucet account as its sender.

Because the token supply is left unchanged by such a mint, a faucet that has already minted out its entire supply remains able to emit notes attributed to it, with zero being the only amount that still passes the supply checks. No assets are created and the supply accounting stays consistent, so the consequence is confined to off-chain consumers: indexers or interfaces that treat a note originating from a faucet as evidence of a mint can be misled by notes that carry no value.

Consider enforcing a minimum mint amount so that zero-amount mints are rejected.

Update: Resolved in pull request #3666 at commit f38dab6.

Duplicate Procedure Roots in Multisig Thresholds Cause Panic During Component Construction

The with_proc_thresholds builder accepts a caller-supplied list of per-procedure thresholds, validates only that each threshold is at least one and no greater than the number of approvers, and then stores the list verbatim. Repeated AccountProcedureRoot entries are not rejected. When the resulting configuration is converted into an AccountComponent, the procedure-thresholds slot is populated by a call to StorageMap::with_entries whose result is unconditionally unwrapped. That constructor reports a DuplicateKey error whenever a key repeats, even when both entries hold the same value, so a configuration that names the same procedure root twice makes the conversion panic instead of returning an error.

The panic is reachable through the wallet constructors create_multisig_wallet and create_guarded_wallet, both of which forward the caller-provided vector into that conversion. The guarded variant is affected identically, because its configuration delegates to the multisig builder and AuthGuardedMultisig delegates its component conversion to the multisig one. The consequence is that a fallible builder returning Result accepts input that later panics during account construction, and the conversion documents no panic condition. The sibling AuthMultisigSmart component already rejects the same class of input in validate_proc_policies, so the omission is inconsistent with the surrounding code.

Consider rejecting repeated AccountProcedureRoot values in with_proc_thresholds with a structured error, mirroring the uniqueness check already performed by validate_proc_policies, which would also cover the guarded variant. Alternatively, consider propagating the StorageMap::with_entries error during component conversion rather than unwrapping it.

Update: Resolved in pull request #3246 at commit 6ac2574, c322f73, and 9e60f28.

Truncated Procedure Roots Can Render Distinct Custom Procedures Identically

The name accessor renders the Custom variant of AccountComponentInterface by slicing the hexadecimal form of each procedure MAST root to its first nine characters, which retains the 0x prefix and seven hex digits out of the sixty-four that a full root produces. The roots collected into that variant come from a set of distinct entries in from_procedures, so two entries that render alike always denote genuinely different procedures, and the rendered list carries no index, deduplication, or other disambiguator.

The effect is confined to a human-readable label. Equality, serialization, and every protocol decision operate on the full root, and no caller of name exists in the repository, so the string is consumed only by downstream tooling. Because a MAST root is a deterministic hash of compiled code, however, a component author can grind trivial variants until a root matches a chosen 28-bit prefix. An operator who compares custom procedures through a renderer built on this label may therefore accept one procedure as another.

Consider including a collision-resistant identifier in the Custom display string, such as the full MAST root hexadecimal form or a substantially longer prefix and suffix pair. If the shortened form is retained, consider documenting that it is not a unique identifier and must not be relied upon to distinguish procedures.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Off-Chain P2IDE Consumability Analysis Diverges From Note Script Reclaim-Height Semantics

P2IDE notes store the timelock and reclaim block heights as raw field elements, and the note script compares them against the reference block number with the field-element lte operation in assert_unlocked and reclaim_note. There is no u32 bounds check, and a stored reclaim height of zero is treated as "reclaim permanently disabled," not "reclaimable since genesis." Storage values are otherwise unconstrained: NoteStorage::new bounds only the item count, so a sender can assemble a reclaim height by hand through public Rust APIs, whether that height is exactly zero or larger than u32::MAX, even though the typed constructor accepts only a BlockNumber. Off-chain, both values break the same consumability analysis, in opposite directions and through different mechanisms.

An out-of-range reclaim height hides a note the target can genuinely consume. The target branch of main never reads the reclaim height at all, so the note stays consumable on chain regardless of that field's value. However, decode_block_height rejects any non-zero height that does not fit a u32, and P2ideNoteStorage::try_from runs before any consumability logic, so is_consumable converts that decode error into NeverConsumable for the whole note. A field the target branch never reads is therefore enough to hide a note the target could actually spend.

A reclaim height of exactly zero, conversely, makes the checker report a note as reclaimable when reclaim is permanently disabled. is_consumable_inner resolves an absent reclaim height with unwrap_or_default, turning "disabled" into plain block zero, and takes the maximum of that and the timelock height. The reclaimer branch therefore reports the note as consumable once the timelock elapses, or immediately with no timelock, while the script rejects every reclaim attempt at every block with ERR_P2IDE_RECLAIM_DISABLED. This needs no unusual configuration, since the default reclaimer is the sender, distinct from the target, and is the common case.

In both directions, can_consume returns as soon as the static analysis yields any answer, so the batch path that executes the script and would otherwise expose the correct behavior is never reached for these notes. A wallet or indexer relying on this analysis hides a note its target could have consumed, or offers a reclaim that fails as soon as it is executed, wasting the work spent building it. The on-chain script itself is unaffected in every case; the divergence is confined to off-chain classification.

Consider handling the reclaim height as its own well-defined domain. For the target branch, consumability should not depend on decoding the reclaim height at all, since the script never reads it there. For the reclaimer branch, an absent or zero reclaim height should resolve to a NeverConsumable (or explicitly disabled) status rather than block zero, and a height exceeding the script's representable range should be handled explicitly rather than rejected outright, so that both branches of the off-chain analysis match what assert_unlocked and reclaim_note actually enforce on chain.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Send Notes Script Generation Performs Unbounded Work Before the Output Note Limit Is Enforced

SendNotesTransactionScript::build accepts a slice of partial notes and never inspects its length. For each note it appends a fixed block of assembly to a single generated procedure, plus a further block per asset (see move_asset_to_note_body), and inserts one advice map entry per note attachment, before handing the assembled source to compile_tx_script. Generation time, generated source size, and advice map size therefore grow with the number of notes the caller supplies. The remaining inputs to generation are already bounded, since per-note attachment and asset counts are enforced by NoteAttachments::new and NoteAssets::new, which leaves the note count as the only unbounded dimension.

The transaction kernel caps the notes a single transaction may create at MAX_OUTPUT_NOTES_PER_TX and enforces that cap with ERR_TX_NUMBER_OF_OUTPUT_NOTES_EXCEEDS_LIMIT on every note creation, so a script generated for a longer note list can never execute successfully. Because the builder applies no length check of its own, a caller that passes such a list pays for full source generation, assembly, and partial transaction execution before learning that the request was impossible. In a host application that builds these scripts from a note list supplied by another party, the same missing bound may allow that party to impose generation and assembly cost proportional to the list it submits.

Consider rejecting note lists longer than MAX_OUTPUT_NOTES_PER_TX at the start of script generation and returning a dedicated error, so that a request the kernel is guaranteed to reject fails before any source generation or assembly work is performed.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Note Attachment Limits Are Enforced Only After Full Collections Are Parsed and Hashed

NoteAttachments::read_from reads an attachment count from a single untrusted byte and then deserializes that many NoteAttachment values into a vector before any protocol limit is applied. Both limits, MAX_COUNT (4) and MAX_NUM_WORDS (512), are checked by NoteAttachments::new, which runs only once the whole vector has been materialized. Each attachment parsed along the way has already had its words copied into an element buffer and hashed by NoteAttachmentContent::new.

The per-attachment limit supplies no earlier bound either: MAX_NUM_WORDS is 256 words, while the wire format encodes the word count as a byte holding the count minus one, so the value reconstructed in NoteAttachmentContent::read_from can never exceed 256 and that check can never reject a value read from the wire. Nothing therefore rejects an attachment collection until the final aggregate check, so a collection far larger than the protocol permits is parsed and hashed in full before it is discarded. Note::read_from reaches this path for every note and every transaction that is decoded.

The discarded work is bounded by the size of the input, because each declared word must be backed by thirty-two bytes actually present in the reader and the collected vector never preallocates the declared count. A service that limits the size of accepted messages therefore also limits this path. That bound is left entirely to callers: the crate re-exports BudgetedReader and the associated read_from_bytes_with_budget entry point, but no deserialization performed inside the crate runs under a budget.

Consider validating the declared count against NoteAttachments::MAX_COUNT immediately after it is read, and accumulating the total word count while attachments are parsed, so that an oversized collection is rejected before it is materialized and hashed. The same fail-late idiom appears in the deserialization of NoteStorage, NoteAssets, InputNotes, and OutputNoteCollection, so any fix is best applied consistently across them.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Fixed Script Module Paths Can Misattribute Execution Errors in a Shared SourceManager

Scripts compiled from string sources are registered in the source manager under constant module paths. compile_tx_script and compile_note_script always pass the TX_SCRIPT_MODULE_PATH and NOTE_SCRIPT_MODULE_PATH constants as the default path, and parse_script_str turns that path into the URI under which the source text is stored. The default source manager keeps an existing entry only when both the URI and the content match, so compiling a second script of the same kind against the same manager repoints ::tx_script or ::note_script at the newer source file.

Two compilations can end up sharing a source manager without any explicit setup. CodeBuilder derives Clone over an Arc<dyn SourceManagerSync>, and the documented workflow for building several scripts is to clone the builder, while a single transaction that consumes two input notes whose scripts were both compiled from strings registers both under ::note_script. Because the executor resolves the source of a runtime error purely by URI in get_label_and_source_file, a failure raised in one script can be rendered against the text of another, yielding a source snippet that points at unrelated code. The consequence is confined to diagnostics: MAST roots, note commitments, and account state are unaffected, lookups by source identifier remain correct so assembly-time diagnostics still resolve properly, and write_into omits debug information from serialized note scripts, which keeps the mis-rendering inside a single process. Sources supplied as an already loaded source file, a parsed module, or a filesystem path do not register under the constants and are therefore unaffected.

Consider deriving a unique default module path for every compilation, for example from a per-builder counter or a hash of the source text, rather than the fixed ::tx_script and ::note_script values. The in-repository note builder already follows this pattern in testing/note.rs, where the URI is derived from the note serial number. Alternatively, consider instantiating a separate source manager per compilation wherever source text may be rendered back to a caller. The same consideration applies to the fixed _tx_context_code URI in context.rs, which avoids the collision today only because registration immediately precedes execution.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Unbounded Approver Set Can Make Multisig Authentication Unprovable

ApproverSet::new validates only that the threshold is non-zero, that it does not exceed the number of approvers, and that no public key commitment repeats. No upper bound is placed on the size of the set, while authentication cost scales with the whole set rather than with the threshold. verify_signatures counts down from num_of_approvers and performs a storage map opening, a hash merge, and an advice map probe for every index, and the only exit condition of its loop is the counter reaching zero. Since auth_tx runs that loop before invoking compute_transaction_threshold, the number of signatures actually required is not yet known while the loop executes. Account creation is free and happens off chain, whereas every later authentication pays the linear cost, so an owner who configures a set large enough that the first auth_tx exceeds provable cycle limits permanently locks whatever the account holds. No third party can impose such a configuration: the approver set is fixed at creation or changed through update_signers_and_threshold, an account procedure gated by auth_tx and therefore requiring a quorum that could already move the account assets.

A second configuration input of the same component is unvalidated. The conversion of an AuthMultisig into an AccountComponent builds the per-procedure threshold map, keyed by procedure root, and resolves the result with an unwrap. with_proc_thresholds checks only threshold magnitudes and never rejects a repeated procedure root, and create_multisig_wallet forwards the caller vector unchanged. Passing the same procedure root twice therefore panics inside a public constructor instead of returning a structured error.

Consider enforcing an explicit maximum approver count in ApproverSet::new, and rejecting duplicate procedure roots in with_proc_thresholds so that the component conversion cannot panic. In addition, consider computing the effective transaction threshold before verifying signatures, so that the verification loop can stop once the required number of valid signatures has been collected.

Update: Resolved in pull request #3723 at commit 0a8e893.

Unvalidated OnBlockSlot Parameters Can Overflow the Consumability Window Computation

The OnBlockSlot variant of NoteExecutionHint describes a repeating window in which a note is expected to be consumable, encoded as three bytes named round_len, slot_len, and slot_offset. Neither the decoder in from_parts, which checks only that the unused high payload byte is zero, nor the on_block_slot constructor places any bound on the three values or on their relationship to each other, so a payload in which all three bytes are 255 decodes without error. The values are fully controlled by the note author, since the network account target attachment stores the hint and decodes it back without a bounds check.

The decoded bytes are consumed by can_be_consumed as shift amounts in 1 << round_len and 1 << slot_len, and then in unchecked u32 multiplication and addition. A shift amount of 32 or greater panics when overflow checks are enabled, and is otherwise masked into the range 0 to 31, yielding a window unrelated to the encoded parameters. The multiplication of slot_offset by the slot length in blocks can exceed u32::MAX even when both shift amounts are in range, for instance with round_len of 1, slot_len of 25, and slot_offset of 255. That out-of-range values reach this arithmetic unhindered is illustrated by the crate's own round-trip test, which encodes and decodes a slot_len of 33. The hint is advisory metadata that no kernel or note script enforces, and can_be_consumed has no caller outside its unit test in this repository, so the consequence falls on external consumers of the library: a consumer compiled with overflow checks aborts when evaluating a single malformed public note, while a consumer compiled without them silently computes an incorrect scheduling window.

Consider rejecting out-of-range round_len, slot_len, and slot_offset values at construction and decode time, in both on_block_slot and from_parts, so that an OnBlockSlot hint cannot exist with parameters its own window computation cannot represent. Alternatively, or in addition, consider making can_be_consumed total by widening the intermediate arithmetic to u64 or by using the checked shift, multiplication, and addition operations and returning a defined result when the computation does not fit.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Non-Canonical Role Symbols Break Off-Chain Authority Decoding

The Authority component's RbacControlled mode maps each gated procedure root to the role symbol required to invoke it. On-chain, assert_authorized_rbac reads the mapped value from the procedure-roles map, treats zero as an unmapped procedure and falls back to the ADMIN check, and forwards every other field element to assert_sender_has_role, whose only validation is assert_role_symbol_non_zero. The accepted key space on-chain is therefore every non-zero field element. The Rust representation is stricter: read_roles_from_storage decodes each stored value with RoleSymbol::try_from, which admits only the canonical encodings between MIN_ENCODED_VALUE and MAX_ENCODED_VALUE.

Because the decode occurs inside the entry loop and its error propagates, a single non-canonical entry makes Authority::try_from_storage return InvalidRoleSymbol for the whole account, leaving off-chain introspection of that account's permissions unavailable. On-chain authorization is not weakened, since a non-canonical role key is enforced as strictly as a canonical one, and role symbols granted through the RoleBasedAccessControl component itself are never decoded back from storage. The divergent state can only be introduced at deployment by the account deployer, as no assembly procedure writes the procedure-roles slot and the metadata-driven initialization paths validate every map entry against the component schema, which rejects non-canonical role symbols. Only the raw AccountComponent::new route, which checks the slot count alone, can install such a value.

Consider validating the mapped value against the canonical role symbol encoding on-chain, either in assert_authorized_rbac or in the role symbol check shared by the RBAC entry points, so that both representations accept the same set of values. Alternatively, consider making off-chain decoding resilient by representing an unrecognized role field element as an opaque value rather than failing the reconstruction of the entire component.

Update: Resolved in pull request #3524.

Malformed Execution Hint Hides Network-Targeted Notes From Off-Chain Routing

A NetworkAccountTarget attachment encodes the target account identifier in the first two felts of its word and a note execution hint tag in the third. The on-chain targeting path never reads that third felt: into_target_id discards it, and active_account_matches_target_account asserts only that a target attachment of one word exists before comparing the two identifier felts against the active account. The attachment constructor new places whatever felt occupies the hint slot into the word without a range check. The Rust decoder applies a stricter rule: try_from parses the target identifier first and then discards it whenever from_parts rejects the hint, which occurs for any tag above three and for a non-zero payload on the tags that require an empty payload.

Because of this asymmetry, a public note carrying an unrecognized hint encoding remains consumable by its intended network account on-chain while is_network_note and the typed network-note constructor that shares the same decoding classify it as not network-targeted, so the note is not picked up for automatic routing or indexing. The attachment is fixed at note creation and bound into the note commitment, so only the note creator can reach this state, and no note producer in the repository emits a hint other than the always-executable one. The note is also not stranded, since a network account authorizes by note script allowlist rather than by signature in auth_network_transaction, which leaves the consuming transaction buildable by hand. What is lost is automatic discovery of the note.

Consider relaxing the Rust decoding path so that an unrecognized or invalid execution hint no longer discards the successfully parsed target account identifier, which aligns the off-chain classification with the on-chain check that ignores the hint entirely. Alternatively, the attachment helpers could validate the hint encoding when the attachment is created, so that a value the decoder will later reject cannot be committed to. Note that selecting the first validly decodable attachment rather than the first matching one would introduce a new divergence, since both the Rust and the MASM lookups treat the first attachment of the scheme as canonical.

Update: Resolved in pull request #3811.

Account Interface Inference Always Includes an Empty Custom Component for Standard-Only Accounts

Account interface inference builds the list of AccountComponentInterface values by matching the exported procedure roots of an account against the known standard components, removing every matched root from a working set (extract_standard_components), and placing whatever remains into a custom bucket. The final step of from_procedures pushes AccountComponentInterface::Custom unconditionally, without checking whether any unmatched root is left. For an account assembled purely from standard components the working set is drained to empty, so the inferred interface always contains a Custom variant holding an empty vector.

That result contradicts the documented meaning of the variant, which is described as holding all procedures that are not part of a standard interface used by the account (Custom), and the interface is rendered under the literal name Custom([]) (name). Off-chain consumers of this classification, such as wallets, explorers, and client libraries, that treat the presence of Custom as a signal that non-standard code is present will observe a false positive for every account, including accounts built exclusively from standard components. The transaction kernel and account assembly do not read this classification, so the consequence is confined to callers of this API.

Consider appending the Custom variant only when at least one procedure root remains unmatched after standard extraction, or exposing an explicit accessor such as has_custom_procedures so that callers do not have to rely on variant presence to determine whether non-standard code is present.

Update: Resolved in pull request #3824.

Compilation Errors Eagerly Render Unbounded Diagnostic Strings

CodeBuilder converts every parse and assembly failure into a CodeBuilderError through build_error_with_report, which formats the complete diagnostic report into an owned string at the moment the error is constructed, with no size limit and regardless of whether the caller ever displays it. Both failure paths of compile_tx_script route through this helper, as do the other compilation entry points of the builder.

The rendered output can grow faster than the source that produced it. The pinned version of miden-assembly-syntax batches the semantic errors of a module into a single syntax error holding an uncapped collection of related errors, and the graphical renderer selected by the default std feature reprints a source snippet for each one, so a single long line carrying many errors yields output on the order of the error count multiplied by the line length. A caller that exposes MASM compilation to third-party source text would therefore pay disproportionate CPU and memory cost on a failed compile, and would embed the entire rendered diagnostic in log entries. No call site in this repository compiles untrusted text, so the exposure applies to downstream integrators that wire external input into the builder.

Consider enforcing a hard cap on the rendered diagnostic in build_error_with_report, for example truncating after a fixed number of bytes or lines, and storing the report so that it is rendered only when displayed, as AccountComponentAssemblyError already does in miden-protocol. Since Report does not implement core::error::Error and cannot be held as a source error, the deferred form requires a dedicated field and a Display implementation. Documenting that any untrusted compilation boundary must also bound its input size would complement the cap.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

PSWAP Lineage Depth Tracking Diverges Between The On-Chain Script And The Rust Helpers

PSWAP notes track how many partial-fill rounds a swap has been through in an attachment word [amount, order_id, depth, 0]. On chain, get_current_depth reads the parent depth from the consumed note's attachment and returns it incremented by one, using plain field addition with no range check, and the result is stamped into the attachments of both output notes: the payback P2ID and the remainder PSWAP. Nothing constrains this value when an original note is constructed or decoded: build validates only that the offered and requested assets use different faucets, and parent_depth returns the depth element verbatim.

This lack of validation breaks off-chain reconstruction in two distinct ways, both requiring the same precondition: only the party constructing the original note can plant a non-standard starting depth, since attachments are folded into the note commitment, and a legitimate lineage cannot reach either failure mode through real partial fills.

First, the off-chain reconstruction helpers, payback_note and remainder_note, assume an original note sits at depth zero and offset its serial number by the round count accordingly. An original note built with a non-zero depth breaks that assumption silently: the helpers derive serial numbers, recipients, and commitments that differ from what the script actually produces, so the creator cannot recover their own payback note. Reaching a non-zero depth requires only that the creator plant one at construction; it is never validated away.

Second, the on-chain depth is an unbounded field element incremented by plain addition, while the typed PswapNoteAttachment represents it as a u32, so a depth exceeding u32::MAX makes pswap_output_attachment return an explicit error instead of a silently wrong reconstruction. Because the field addition performs unchecked modular reduction, a depth planted one below the field's modulus wraps to a small value on the very next increment, converting what would otherwise be an out-of-range error into the same silent-mismatch failure mode described above. Reaching the u32 bound through real partial fills would need billions of rounds, and reaching the full field modulus far more; both are reachable only by a creator directly setting the starting value.

In both cases, depth is never read in an on-chain branch or assertion, so no mismatch here can redirect assets or change what the kernel accepts; the exposure is confined to off-chain tooling that reconstructs a swap's lineage.

Consider rejecting any non-zero depth when an original PSWAP note is constructed or decoded, and bounding the on-chain increment in get_current_depth to the same range the off-chain type uses, so a hand-crafted starting depth can neither desynchronize the reconstruction helpers' zero-depth assumption nor overflow the field increment into a misleadingly small value.

Update: Resolved in pull request #3777.

MINT And BURN Notes Can Become Unconsumable When Faucet Policies Change

The MINT and BURN notes are validated against faucet policy state that is read when the note is consumed rather than when it is created. The main script of the BURN note calls the fungible receive_and_burn or non-fungible receive_and_burn procedure, which runs execute_burn_policy before burning, and the MINT note reaches mint_and_send, which runs execute_mint_policy before minting. Both entry points assert that the faucet is not paused and then dynamically execute whichever policy root occupies the active policy slot at that moment. That state stays mutable after the note exists: set_burn_policy and set_mint_policy replace the active root, set_min_burn_amount raises the burn threshold with no upper bound, and the owner-controlled policies evaluate against the current Ownable2Step owner. A policy switch or a pause applied after a note is created therefore causes that note to abort on consumption, as the test suite shows for a pre-staged burn note once the policy is switched to owner-only, and for burning and minting once the faucet is paused.

The consequences differ between the two note kinds. A MINT note carries no assets and holds the asset to mint in its storage, so a rejected mint strands nothing and the mint simply does not take place, leaving harm only where the note was exchanged for something outside the protocol. A BURN note does carry the asset, and the fungible faucet decrements token_supply only after the burn policy passes. That supply was already counted when the asset was minted, so for as long as the note remains unconsumable the amount continues to occupy headroom against max_supply and cannot be minted again. Where the maximum supply is configured as immutable, set_max_supply rejects any attempt to raise the cap in compensation, and the asset cannot be redirected to another account either, because burn panics unless the executing account is the issuing faucet of the asset. One case requires no administrative action at all: the min_burn_amount policy compares the amount against the configured minimum at consumption time and nothing checks that minimum at creation time, so a BURN note for an amount below the current minimum is unconsumable from the moment it exists.

Consider validating the burn amount against the active policy when a BURN note is created, so that notes which can never satisfy the policy are not produced. Consider also recording the burn predicate in force at creation time in the note itself and validating consumption against that snapshot, or adding a reclaim branch that allows the sender to recover the asset when the burn cannot proceed. BurnNote declares zero storage items and can therefore carry neither a policy snapshot nor a reclaim recipient today. If evaluation against live faucet state is the intended design, consider documenting that outstanding MINT and BURN notes are subject to whichever policy is active when they are consumed.

Update: Resolved in pull request #3705 at commit 07284d5. The team stated:

“The corresponding issue is discussed under: https://github.com/0xMiden/protocol/issues/3704
Opened a PR for a temporary solution in this PR: https://github.com/0xMiden/protocol/pull/3705
However, this come to the conclusion in this two comments as planned not to do: - https://github.com/0xMiden/protocol/issues/3704#issuecomment-5402801047 - https://github.com/0xMiden/protocol/issues/3704#issuecomment-5406401063”

Management Actions Can Be Executed From Private Notes

The standardized management notes are documented as public notes intended for network execution, with the note storage encoding a privileged configuration change. The Rust side encodes that expectation by fixing the note type at construction, for example in From<PauseActionNote> for Note, which always sets NoteType::Public. Note metadata is nevertheless generic, since PartialNoteMetadata::new stores whichever note type it is given, and none of the four management scripts reads the metadata of the active note: pause_action, rbac_action, owner_action, and faucet_policy_action each drop the note arguments and dispatch on the storage selector directly. A note carrying the same script root and the same storage but built with NoteType::Private therefore executes identically, which means the invariant the standard documents holds only in one Rust conversion and not in the code that runs on chain.

Authorization is unaffected, since the called procedures resolve the note sender and the kernel pins that sender to the account that emitted the note, so only the owner or role admin who already holds authority over the action can produce a note that passes. Network routing is also unaffected, because AccountTargetNetworkNote::new rejects non-public notes. What remains is a hardening gap: any client that builds these notes outside miden-standards loses the property silently, and off-chain monitors that assume every management note is public would not observe the encoded action before it executes, since a private note publishes only its commitment together with sender, note type, tag, and attachments. The same property is already asserted on chain elsewhere in the codebase, at bridge_out, which makes the omission an inconsistency rather than an unimplemented convention.

Consider asserting active_note::is_public at the start of each management note script, and factoring the check into a shared helper so that future management scripts inherit the invariant by construction.

Update: Resolved in pull request #3779.

Failed Library Linking Leaves CodeBuilder In An Inconsistent State

The CodeBuilder type owns a single Assembler for its entire lifetime and exposes link_static_library and link_dynamic_library, which take the builder by mutable reference and forward to Assembler::link_package. In miden-assembly version 0.24.1, as pinned in Cargo.lock, that call records the library entry before registering any module and then registers the library modules one at a time, aborting mid-loop with a duplicate-module error when a module path collides with a path already registered by another linked library. No rollback is performed, so the library entry and any modules registered before the collision remain in the linker.

A caller that handles the returned error and keeps using the same builder therefore continues with linker state that no longer matches the set of successfully linked libraries. Module paths from the failed library stay reserved and can make a later legitimate link fail with a duplicate error that does not correspond to any real conflict, and repeating the identical link reports success, because the already recorded library entry short-circuits the operation, while the module graph remains incomplete. The failure then resurfaces as an unresolved symbol during a subsequent compilation. The effect is confined to off-chain script compilation and produces compilation errors rather than incorrect compiled output. The # Errors documentation on both methods does not indicate that a failed call leaves the builder inconsistent, and only these two methods can leave a live inconsistent instance, since the chaining variants with_statically_linked_library and with_dynamically_linked_library consume the builder and drop it on error.

Consider treating a link failure as fatal for the builder instance, for example by recreating the internal Assembler or by marking the builder unusable once a link returns an error, and documenting that a failed link does not leave the builder unchanged. Consider also raising the missing rollback with the miden-assembly maintainers so that library linking becomes transactional, committing registered modules and the library entry only once the whole package has been linked.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

PSWAP Remainder Notes Drop Network Account Routing Metadata After Partial Fills

A PSWAP note can be routed to a network account by carrying a NetworkAccountTarget attachment, registered as scheme 2 in NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME. The note documentation recommends setting that attachment for consumption in network transactions. On a partial fill, create_remainder_note builds a fresh output note and adds only the PSWAP lineage attachment, without copying any attachment carried by the parent note. The Rust construction path behaves identically in create_remainder_pswap_note, which sets the lineage word as the sole attachment of the remainder.

Consequently, the first partial fill strips the routing target from the order. The remainder no longer satisfies is_network_note, which requires a public note whose attachments decode as a NetworkAccountTarget, so components that index or automatically consume notes on behalf of the targeted network account stop recognizing the leftover order. Any account can trigger this by consuming the note with a minimal fill amount. Assets remain safe, because the remainder inherits the tag and note type of the parent and the creator can still reclaim the unfilled asset, but a documented routing capability stops applying after the first partial fill without any signal to the note creator. Carrying both attachments is permitted at the protocol level, where a note may hold up to MAX_ATTACHMENTS_PER_NOTE attachments, but PswapNote models a single optional attachment and rejects any note carrying more than one.

Consider copying the attachments of the parent note, at minimum NetworkAccountTarget, onto the remainder in create_remainder_note and its Rust counterpart, and extending PswapNote to model multiple attachments so that routing metadata can coexist with the PSWAP lineage attachment. If discarding routing metadata on remainder notes is intended, consider documenting that behavior and qualifying the recommendation to route PSWAP notes through a NetworkAccountTarget attachment.

P2IDE Notes With Out-Of-Range Reclaim Heights Are Spendable On Chain But Reported As Never Consumable

P2IDE notes hold the timelock and reclaim block heights as raw field elements in note storage, and the note script compares them against the reference block number with the field-element lte operation in assert_unlocked and reclaim_note, without any u32 bounds check. Storage values are otherwise unconstrained, since NoteStorage::new bounds only the number of items and the kernel constrains only that count and the storage commitment. A sender can therefore assemble the storage vector by hand through public Rust APIs and produce a P2IDE note whose reclaim height exceeds u32::MAX, even though the typed constructor accepts only a BlockNumber.

Such a note stays consumable by its target on chain, because the target branch in main never reads the reclaim height. Rust decoding rejects any non-zero height whose canonical value does not fit into a u32 in decode_block_height, and P2ideNoteStorage::try_from runs before any consumability logic, so is_consumable converts the decoding error into a NeverConsumable status that can_consume returns without ever executing the note. A spendable note is thus hidden from its target by standard tooling, while the batch checking path, which executes the notes instead of relying on this classification, remains unaffected. The divergence is one-sided, as an out-of-range timelock height is rejected by both the script and the decoder.

Consider aligning the note script and the Rust decoder on a single height domain. One option is to enforce u32 bounds in the script before the height comparisons, which makes the script reject such notes and turns a currently spendable note into an unspendable one. The other is to handle out-of-range heights explicitly on the Rust side so that classification follows the on-chain semantics. Whichever direction matches the intended design, consider documenting the height domain as an invariant of P2IDE note construction and covering it with tests.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

P2ID and P2IDE Conversions Ignore Recipient Note Tag Length Preference

An Address carries a note tag length routing parameter through which the receiver states how many bits of its account ID prefix should be encoded into the tags of notes sent to it. The address.md documentation describes this value as the tag length that the sender is expected to create, and the type exposes it through note_tag_len and to_note_tag.

The conversions of the standard payment notes into protocol notes do not honor that preference. Both From<P2idNote> for Note and From<P2ideNote> for Note derive the tag with NoteTag::with_account_target, which always applies the default length of 14 bits, and the builders accept only an account ID as the target, so no routing preference can be threaded through them. A receiver that advertises a length other than the default and filters incoming notes on the exact tag derived from its own address does not match P2ID or P2IDE notes produced by these conversions, and a receiver that selects a shorter length for privacy has 14 bits of its account ID prefix published in note metadata instead of the number it chose. The consequences are limited to off-chain note discovery and to the unapplied privacy preference, since the tag plays no part in authorizing consumption, which is gated by a comparison against the target account ID held in the note storage. No code in the repository reads an Address when building a note, so the mismatch arises only for integrators that derive their sync tags from addresses.

Consider threading the recipient Address (or an explicit tag length) through the P2idNote and P2ideNote construction and conversion APIs and deriving the tag with NoteTag::with_custom_account_target, so that the advertised length is preserved in the resulting note. Where accepting an Address is not intended at this layer, consider documenting that these conversions always emit the default account target tag and exposing a supported way to set the tag explicitly.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Unchecked Attachment Length Allows Out-Of-Bounds Write In PSWAP Note Script

The PSWAP note script derives the depth of the swap chain from an attachment carried by the consumed note. get_current_depth declares a four-element procedure-local buffer, passes its address to active_note::write_attachment_to_memory, and then discards the returned word count without checking it. That routine delegates to note::write_attachment_to_memory, which accepts no capacity argument and writes as many words as the committed attachment contains into the destination pointer, its only validation being that the written data hashes to a commitment the note creator chose. Attachment selection in find_attachment matches on the scheme alone, and nothing ties the PSWAP scheme to a single word, while attachment content is accepted up to the MAX_NUM_WORDS limit of 256 words. Any note creator can therefore cause up to 1020 field elements of chosen data to be written past the end of the four-element buffer.

At the audited commit the overflow produces no observable effect. Procedure frames grow upward, so the write lands above the frames of the callers rather than inside them, the depth element read afterwards still holds the value supplied in the first attachment word, and no procedure reachable from this flow reads a local before writing it. The write nevertheless escapes its declared bounds with fully attacker-supplied data, so any procedure later added to this flow that reads a local before initializing it would silently consume that data.

Consider asserting that the located PSWAP attachment consists of exactly one word before reading the depth element, and rejecting the note otherwise. If multi-word PSWAP attachments are meant to be tolerated, consider instead allocating a buffer sized for the maximum permitted attachment and validating the attachment layout before use.

Update: Resolved in pull request #3761.

Private SWAP Payback Notes Are Deanonymized by Public SWAP Data

In private payback mode, SwapNote::create builds the payback recipient as a P2ID recipient targeting the sender, with a serial number that payback_serial_from_swap derives from the SWAP serial number by incrementing its least significant element, and embeds the resulting recipient digest into the SWAP note storage through SwapNoteStorage::new_private. That digest is produced by compute_recipient_digest from the serial number, the note script root, and the storage commitment, and P2idNoteStorage holds nothing beyond the target account identifier. When the SWAP note itself is public, PublicOutputNote publishes the entire note, including storage and serial number, so every preimage of the stored payback digest becomes public.

Any passive observer can therefore recompute the payback recipient digest, confirm the account it targets, and derive the payback details commitment, which NoteDetailsCommitment forms by merging that digest with the commitment to the requested asset, itself published in the SWAP storage. Blocks publish PrivateOutputNote headers carrying exactly that commitment, so the supposedly private payback note can be matched to the public SWAP without any privileged access or interaction. The payback Nullifier becomes computable for the same reason, since its serial number, script root, storage commitment, asset commitment, and metadata are all recoverable from public data, which removes the unlinkability between creation and consumption that a private note's secret serial number is meant to provide. No assets are exposed, because the P2ID script still restricts consumption to the target account, but the confidentiality property is not the one the code describes: swap.masm states that the stored digest is opaque and that the payback target cannot be learned from storage alone. The same derivation additionally allows the consumer of a private SWAP note to confirm the payback target.

Some correlation is unavoidable for a public SWAP note, since a consumer must be able to build the payback note, but the deterministic relationship between the two serial numbers is not required.

Consider drawing the payback serial number independently of the SWAP serial number in private payback mode, given that the private branch of the SWAP script consumes the precomputed recipient digest directly and never recomputes the serial number. Consider also revising the comments and documentation that present the stored payback recipient as opaque, so that the stated guarantee matches the behavior that the implementation delivers.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

MINT and BURN Note Dispatch Prevents Use With Faucets Supporting Both Fungible and Non-Fungible Assets

The documentation acknowledges that "an account can technically issue different types of assets simultaneously, for example, both a fungible and a non-fungible asset" (asset.md:42), and nothing in the code prevents composing both the FungibleFaucet and NonFungibleFaucet components onto a single account. However, both the MINT and BURN note scripts select which faucet interface to call by reflecting on the consuming account via procref and CodeInspection::has_procedure, unconditionally preferring the fungible procedure whenever it is present and only falling back to the non-fungible one otherwise.

On an account exposing both interfaces, every MINT or BURN note is routed to the fungible path regardless of which asset kind it was actually built for. For MINT, a note built with the non-fungible storage layout fails the fungible layout's storage item count checks (mint_fungible.masm#L87, mint_fungible.masm#L120-L129) and reverts, as a side effect of an unrelated layout check rather than a dedicated guard.

For BURN, fungible::receive_and_burn does not check storage item counts, and its composition check accepts non-fungible assets. A misrouted BURN note carrying a non-fungible asset has that asset's commitment read via fungible_asset::to_amount, which does not validate that the value is fungible, treating one element of the commitment as a token amount. The transaction only fails if this amount exceeds the faucet's current token supply, which depends on the supply relative to the field size and on the commitment value, which is fixed when the non-fungible asset is minted and can be chosen by the minter. When the check passes, the non-fungible asset is removed from the vault, the fungible token supply counter is decremented by that amount, and the non-fungible asset's status registry is never updated to Burned, leaving it out of sync with the asset's removal.

Consider using separate, faucet-kind-specific MINT and BURN notes instead of a single reflection-based dispatcher, so each note is bound to one asset kind by construction rather than by which procedures the consuming account happens to expose.

Update: Partially Resolved in pull request #3553, pull request #3482 and pull request #3308. The fungible receive_and_burn now validates the asset composition before the burn policy runs or the token supply is touched, and the non-fungible receive_and_burn carries the symmetric check; the two MINT storage layouts were unified so a misrouted MINT note is rejected by the faucet-binding assertion rather than by an item-count check. The dispatch is unchanged: the MINT and BURN scripts still select the fungible procedure whenever the consuming account exposes it, so an account composing both faucet components cannot mint or burn its non-fungible assets through these notes. The team treats faucets serving both asset kinds as out of scope until ERC-1155-style faucets exist.

Unvalidated Fill Faucet Makes Predicted Payback Notes Diverge From On-Chain Execution

The PswapNote helpers reproduce the swap note logic off-chain so that a client can predict the notes that a consumption will create. execute combines the caller-supplied account_fill_asset and note_fill_asset into a single payback asset and forwards it to create_payback_note, which places that asset into the payback note verbatim. Neither step relates the faucet of the fill assets to the requested faucet recorded in note storage, which is read only to build the remainder note. FungibleAsset::add rejects two fills whose faucets disagree with each other, but it never compares either fill against the requested faucet, and the branch that handles a single fill asset does not call it at all.

The note script cannot reproduce that behavior. On-chain, only the fill amounts are taken from the note arguments while the payback faucet is loaded from note storage, as in pswap.masm, and the note arguments carry bare amounts with no faucet, so a consumer cannot steer the on-chain faucet. A caller that supplies a fill asset from a faucet other than the requested one therefore obtains a predicted payback note whose assets commitment, and consequently whose note details commitment and note identifier, differ from the note the transaction actually creates. Nothing surfaces the discrepancy, because expected-output-note checking compares only note recipients, which are unchanged. The effect is confined to off-chain prediction and client-side accounting for the caller that supplied the incorrect faucet; the remaining outputs and the on-chain result are unaffected.

Consider validating that every provided fill asset uses the faucet returned by requested_faucet_id before the fills are combined, or deriving the payback asset from that faucet and the computed fill amount, so that the off-chain helper follows the same storage-driven semantics as the note script.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Assetless Notes Produce Transaction Scripts That Fail With Stack-Depth Errors

NoteAssets allows a note to carry between zero and 64 assets, and several first-party standard notes, such as MintNote, are built with an empty asset set. When a send-notes script is generated for an account exposing the basic wallet interface, move_asset_to_note_body emits the sequence that restores the operand stack only inside the per-asset loop. The call to create_note leaves the stack deeper than the frame the script started with, push_attachments is stack neutral, and finalize_note removes a single element. A note that carries no assets therefore skips the loop entirely and ends five elements too deep, and every additional assetless note adds five more.

The transaction script is entered from the kernel with dyncall, and the virtual machine requires the body of a call to return at a stack depth of 16. A script generated for an assetless note consequently aborts on return with a stack-depth error instead of executing. The faucet branch of the generator rejects notes that do not carry exactly one asset through FaucetNoteWithoutAsset, but the wallet branch applies no equivalent check, so an explicitly supported note shape is accepted at build time and fails at execution or proving time with an error that does not point at its cause. No adversary is involved and no state change occurs, since the failure surfaces locally before the transaction is applied.

Consider emitting the stack normalization unconditionally after note creation, outside the per-asset loop, so that the frame is restored regardless of how many assets the note carries. If assetless notes are not intended to be supported by this generator, consider rejecting them at build time with a dedicated error instead. In either case, consider extending the transaction script tests to cover notes with zero assets, including several such notes in a single script.

Update: Resolved in pull request #3302 at commit 47112cd and in pull request #3250 at commit edf3767.

Standard Components Sharing a Procedure Root Are Misreported as Custom

Account interface inference collects the exported procedure roots of an account into a mutable set and passes that set through a fixed sequence of standard component checks in from_procedures. Each check in extract_component matches when every root of the candidate component is present in the set, and then unconditionally removes those roots from it. Roots that survive the sequence are reported as Custom. Because a match claims its roots permanently, a root exported by two standard components is consumed by whichever component appears first in extract_standard_components, and the second component can no longer satisfy the all-roots-present condition.

The fungible faucet component and the code inspection component are a concrete instance, since both re-export has_procedure from code_inspection.masm, and add_procedure stores a root shared across components only once. An account that installs both components is therefore reported as a fungible faucet alone, with the three roots unique to the code inspection component placed in the Custom bucket. Reordering the sequence does not resolve the collision, because neither root set is a subset of the other, so checking code inspection first would suppress fungible faucet detection instead. The consequence is an incorrect component list returned by a public off-chain helper, which can mislead tooling and capability checks built on AccountInterface. The shipped faucet constructors do not install the code inspection component, so reaching the condition requires a hand-composed account, and the same collision would apply to the non-fungible faucet component once it is added to the standard component enumeration.

Consider matching each standard component against an unmutated copy of the exported root set and deriving the Custom bucket only after the full component set has been determined, or tracking each root with a reference count so that a shared root can satisfy more than one standard component.

Update: Acknowledged, will resolve. The team stated:

“The corresponding miden issue is opened here: https://github.com/0xMiden/protocol/issues/3822. A bigger refactor is planned, see here: https://github.com/0xMiden/protocol/issues/2621”

Notes & Additional Information

Notes Have No Standard Way To Declare Or Enforce Who May Consume Them

Note scripts in the standards library fall into two groups with respect to who may consume them: notes that commit a target account and enforce it, and notes that are open to any consumer by design. Nothing in the code distinguishes either group from a note where the check is simply missing by omission. p2id and P2IDE are examples of the first group: they commit a target account identifier to note storage and assert it against the active account before doing anything else. pswap is an example of the second group: it has no target check in its fill path by design, since any account may consume it and receive the offered asset in exchange for the requested one, which is the entire point of a swap note.

Two already-reported issues show what happens when a note falls into neither group: the note reads as though it should restrict its consumer, but nothing commits to or enforces one. H-01 found that the generic MINT note script derives the minting faucet from whichever account consumes it, with no field anywhere in the note committing to which faucet was intended, so a MINT note addressed to one faucet can be consumed by a different one. M-02 found that owner_action, rbac_action, pause_action, and faucet_policy_action record their managed account only in the note tag, which is documented as an unvalidated, best-effort filter, and none of the four scripts compares the active account against an intended target; the sender-based check each script performs establishes who may act on the consuming account, not whether that account is the one the note was written for. In both cases the gap is the same: the note never states, in a form the kernel or the script can check, which account it is for.

The burn path illustrates that the underlying property can hold without ever being stated. receive_and_burn is safe against the equivalent substitution only because the asset it operates on already carries a faucet identity fixed at mint time, which validate_origin can check independently of who is currently executing; nothing in receive_and_burn itself declares that the note may only be consumed by its issuing faucet, and a future change to how burns are validated could remove that guarantee without touching anything that currently reads as a target check, because there is no such check to remove.

Consider defining a standard way for a note to state who may consume it, either as a committed value in note storage or as an attachment (mirroring the NetworkAccountTarget attachment already used by AggLayer note scripts, or the storage-based approach p2id already uses), and requiring every note script to either enforce it or explicitly opt out for notes that are open to any consumer by design, such as pswap. Consider going further and making this an enshrined property of notes at the protocol level rather than a per-script convention, so that omitting the check is a visible, structural choice rather than something that can only be discovered by reading each script's logic.

Update: Resolved in pull request #3820.

Inline TODO Comments Are Not Tracked Outside the Source

The following inline TODO comments exist in the codebase:

An inline TODO only surfaces to whoever happens to read that specific file, and has no owner, priority, or deadline attached to it, so it can persist indefinitely without anyone noticing.

Consider tracking each of these as an entry in an issue tracker instead of, or in addition to, the inline comment, so that outstanding work is visible and assignable independently of who happens to open the file.

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

“https://github.com/0xMiden/protocol/issues/3716 is opened, so Miden team is acknowledged for a general advise, too.”

Inconsistent Dispatch Mechanisms Across Standard Note Scripts

The note scripts in crates/miden-standards/asm/standards/notes/ use three different mechanisms to select between multiple code paths, rather than one consistent approach:

  • Reflection on the consuming account's exposed procedures: mint.masm and burn.masm select fungible versus non-fungible faucet handling via procref and CodeInspection::has_procedure.
  • Note storage item count: mint_fungible.masm and mint_non_fungible.masm select public versus private output-note handling by comparing the total number of note storage items against a threshold.
  • An explicit selector value stored directly in note storage: faucet_policy_action.masm, owner_action.masm, pause_action.masm, and rbac_action.masm all read an integer selector from a fixed note storage slot and dispatch on it directly.

The third mechanism is the most explicit and self-describing: the selector is a dedicated value with one purpose, read directly from a known slot. The first two mechanisms select the same kind of information, which asset kind or note mode is intended, indirectly rather than directly. Reflection determines the branch from what the consuming account happens to expose rather than from what the note itself was built for, so a note's intended kind depends on properties of the account it happens to be consumed against. Storage item count overloads one number to serve two unrelated purposes, selecting the mode and sizing the variable-length storage that follows, so the mode cannot be read without also knowing the exact threshold used for that specific case.

Consider adopting the explicit storage selector pattern already used by the action notes uniformly across the library, so a note's intended path is always read from a dedicated value rather than inferred from account capabilities or storage size. Alternatively, consider using a separate note per asset kind or mode instead of dispatch, removing the need for a selector altogether.

Update: Resolved in pull request #3792.

Guardian Rotation Allows Configurations Rejected At Deployment Time

update_guardian_public_key lets the multisig quorum rotate the guardian public key to any value, including an existing approver's key, without a signature from the current guardian and without comparing the new key against the approver set. verify_signature skips the guardian signature check whenever update_guardian_public_key is the only non-auth procedure called in the transaction, so a quorum meeting the multisig threshold can set the guardian to one of its own approvers in a single transaction. From that point on, the same signature satisfies both the multisig check and the guardian check, even though AuthGuardedMultisigConfig::new rejects this exact configuration at deployment time.

Rotation already allows the quorum to set the guardian to any key it controls without the current guardian's cooperation, so this gap does not grant the quorum meaningfully more power than the rotation design already permits. Its main practical relevance is catching an accidental misconfiguration during a legitimate rotation, such as mistakenly rotating the guardian to an existing approver's key.

Consider re-validating guardian != approver when rotating the guardian, mirroring the check already performed in AuthGuardedMultisigConfig::new, most naturally as a deployment-time or off-chain safeguard rather than an on-chain assertion.

Update: Resolved in pull request #3764.

P2ideNoteStorage::new Accepts A Reclaim Height That Silently Collides With "Reclaim Disabled"

P2ideNoteStorage::new takes reclaim_height: Option<BlockNumber> and does not validate it. None and Some(BlockNumber::GENESIS) both encode to Felt::ZERO, and the note script treats a stored 0 as "reclaim disabled", not as height zero.

There is no real use for Some(BlockNumber::GENESIS). Genesis has always passed by the time a note exists, so a caller writing it almost certainly means "reclaimable with no delay," not "never reclaimable." Since the constructor accepts the value silently, that caller gets a permanently unreclaimable note instead.

Consider having P2ideNoteStorage::new and P2ideNote::new reject a reclaim height that encodes to zero, and stating on those constructors and on reclaim_height that None disables reclaim while Some(1) is the lowest height that enables it.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

AccountCode Validates Procedure Roots Inconsistently, Letting Duplicates Reach And Panic interface()

AccountCode::read_from checks the declared procedure count and that each root exists in the attached MastForest, while AccountCode::from_parts asserts only that the count falls within the permitted range. Neither rejects duplicate roots, so a procedure list like [root, root] is accepted as valid AccountCode. AccountCode::interface() assumes duplicates cannot occur: it collapses self.procedures into a BTreeSet and calls AccountCodeInterface::new via .expect(...). When duplicates shrink that set below the two-procedure minimum AccountCodeInterface::new enforces, which happens once every root in the list is identical, .expect(...) panics.

A wallet, SDK, or block explorer that deserializes an account it received (over the network, from a user) and calls the public Account::code_interface() or PartialAccount::code_interface() accessor on it crashes instead of getting an error if that account's code contains duplicate procedure roots. Since neither constructor rejects such roots, anyone who controls the account bytes handed to such a caller can craft one that panics it on inspection.

Consider rejecting duplicate procedure roots in AccountCode::read_from, and either extending the documented panic conditions of AccountCode::from_parts to cover them or making that constructor fallible. Consider also having interface() return a Result instead of panicking on AccountCodeInterface::new's error.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

swap.masm Accepts Storage Shapes That SwapNoteStorage Decoding Rejects

swap.masm::main branches on the payback note type and reads only the slots relevant to that branch: the private branch reads the recipient digest at [8..11] and never touches the target account ID slots at [14..15]; the public branch reads the target account ID at [14..15] and never touches [8..11]. Neither branch asserts that the slots it ignores are zero, and NoteStorage::new itself commits whatever felts it is given with no semantic validation. A SWAP note can therefore have non-zero values in whichever slot pair its own payback mode does not use and still execute and consume normally on chain.

SwapNoteStorage::try_from enforces the opposite: it requires [14..15] to be zero for private payback and [8..11] to be zero for public payback, rejecting any note that does not. A note that the chain treats as valid and spendable is therefore one that this decoder refuses to parse. Off-chain tooling built on SwapNoteStorage would see such a note as malformed and fail to track, display, or otherwise act on it, even though it is fully consumable on chain.

Consider closing the gap in swap.masm by asserting that the unused slot pair is zero in each branch, matching what SwapNoteStorage::try_from already requires. Alternatively, consider relaxing SwapNoteStorage::try_from to ignore mode-inactive slots rather than reject on them, if the canonical shape is meant to be enforced only by construction and not by decoding.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

SWAP And PSWAP Note Builders Do Not Reject Zero Amounts On Either Side Of The Trade

Neither note type validates that the amounts it trades are non-zero, at construction or on chain:

  • SWAP's requested amount: SwapNote::create does not check requested_asset's amount, and swap.masm moves whatever amount is stored without checking it is non-zero.
  • SWAP's offered amount: the same constructor also never checks offered_asset's amount, and the consume path in swap.masm only checks that exactly one asset is present, not its amount.
  • PSWAP's offered amount: PswapNoteBuilder::build checks only that the offered and requested faucets differ, and pswap::main accepts the note regardless of the offered amount.
  • PSWAP's minimum requested amount: the same builder never checks min_requested_amount, and calculate_output_amount uses it as fill_reference without asserting it is non-zero.

A zero requested amount lets a consumer take the offered asset while giving up nothing, which the note's own creator configured directly and transparently, not a bug in what the note does. A zero offered amount produces a note with nothing of value to give, which nobody would rationally consume. A zero min_requested_amount on PSWAP additionally leaves fill_reference at zero, which under default note arguments reaches u128::div as a zero divisor and aborts the consuming transaction instead of failing at construction.

Consider rejecting a zero amount on either side of the trade in SwapNote::create and PswapNoteBuilder::build.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

CodeBuilder's Shared SourceManager Grows Unbounded Across Clones

CodeBuilder::new constructs a builder backed by DefaultSourceManager by default, held behind an Arc. CodeBuilder is Clone, and its documentation recommends cloning when compiling multiple scripts under the same configuration; every clone shares that same Arc<DefaultSourceManager> rather than getting its own. compile_tx_script passes that shared manager into parse_script_str on every call.

DefaultSourceManagerImpl stores loaded sources in an append-only list and never removes an entry once added, so every source it loads is retained for the lifetime of the manager, including sources that fail to compile. A service that keeps one base CodeBuilder alive and clones it per request accumulates every distinct source across the shared manager, with memory usage growing without bound over the service's lifetime. CodeBuilder exposes no method to clear or evict what has already been loaded; the only way to reclaim that memory is to discard the builder entirely and construct a new one via CodeBuilder::with_source_manager.

Consider documenting DefaultSourceManager's retention behavior on CodeBuilder and on DefaultSourceManager itself, and adding an API to reset or evict accumulated sources rather than requiring callers to discard and rebuild the whole CodeBuilder. Consider also recommending that long-lived services compiling untrusted or highly varied sources construct a fresh SourceManager periodically or per request instead of sharing one indefinitely.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

network_account_target::new Docstring States The Wrong Attachment Scheme

network_account_target::new's docstring says the returned attachment_scheme is 1, but the procedure pushes NETWORK_ACCOUNT_TARGET_ATTACHMENT_SCHEME, which is 2. Every actual consumer, on-chain and off-chain, looks up scheme 2; only the docstring says 1.

Consider correcting the docstring to state 2.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Documentation Understates What A Private Note Actually Publishes

A private note publishes three things, only one of which hides its contents. Its details_commitment is a hash committing to the recipient (script, storage, serial number) and assets without revealing them. Its metadata is not a hash: sender, note type, and tag are written as plaintext felts. Its attachments are also published as plaintext content, not as a commitment to it.

Three places describe this as if only the hash were published:

Consider updating all three to state that a private note publishes its details commitment (a hash), its metadata (sender, type, tag, in plaintext), and its attachments (in plaintext), and withholds only the note's script, storage, serial number, and assets.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Standard Component Recognition Depends On The Currently Embedded Procedure-Root Set

StandardAccountComponent::extract_component recognizes a standard component by matching an account's procedure roots against the roots of the component currently embedded in the binary. If a future miden-standards release changes a standard component's compiled output, an account built against an older release would no longer match, fall into AccountComponentInterface::Custom, and, if that component was the account's auth component, cause AccountInterface::new to panic on auth_count == 0.

Nothing in this codebase calls AccountInterface::from_account or extract_component outside test infrastructure, and no such cross-version mismatch exists today, since there is only one shipped procedure-root set per component. This is a hypothetical gap in an off-chain-only mechanism with no current consumer: reaching it requires a future version bump that changes a standard component's compiled roots, plus some off-chain tool inspecting accounts built under the older version. The impact if it did occur is also bounded: the tool crashes or misclassifies an account it should recognize, which surfaces immediately as a bug to fix, not a security consequence.

Consider making AccountInterface construction fallible instead of panicking, and tracking historical procedure-root sets (or a stable per-component version marker) if and when a standard component's compiled output is expected to change across releases.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Minimum Burn Amount Error Message Misstates the Accepted Threshold

The min_burn_amount burn policy check for fungible faucets is inclusive: lte accepts a burn of exactly the configured minimum. Its error constant says the amount must exceed the minimum, a strict inequality that contradicts the check. This message is raised on assertion failure, so it is the exact wording callers see.

Consider rewording the constant to inclusive phrasing, for example "amount to be burned must meet or exceed specified minimum burn amount".

Update: Resolved in pull request #3474.

Redundant dupw/dropw Around Non-Destructive loc_storew_le

In mint_and_send, ASSET_VALUE is duplicated with dupw, stored to local memory with loc_storew_le, and the duplicate is then removed with dropw. Since loc_storew_le stores the top word to local memory without consuming it from the stack, calling loc_storew_le.ASSET_VALUE_LOCAL alone already leaves ASSET_VALUE on the stack, making the surrounding dupw and dropw unnecessary.

Consider removing the dupw and dropw and calling loc_storew_le.ASSET_VALUE_LOCAL directly.

Update: Resolved in pull request #3660 at commit 6835901.

Missing Invocation Label In Public Procedure Doc Comments

The following public procedures omit the Invocation: label that appears in other public procedures' doc comments:

Consider adding the Invocation: label to these procedures' doc comments for consistency with the rest of the codebase.

Update: Resolved in pull request #3286, pull request #3375, pull request #3383 and pull request #3503.

Mint Policy Interface Allows Overriding Note Tag, Type, and Recipient Without Documented Rationale

The execute_mint_policy invokes the mint policy interface via dynexec. The mint policy interface then passes a faucet's configured mint policy the full [ASSET_VALUE, tag, note_type, RECIPIENT] and allows it to return modified values for all four. In fungible.masm, the policy-adjusted tag, note_type, and RECIPIENT flow directly into the output note without further validation against the caller-supplied values. The non_fungible.masm mint flow only re-validates that ASSET_VALUE is unchanged after the policy runs, with no equivalent check for tag, note_type, or RECIPIENT.

While adjusting the minted amount has a documented rationale for fungible assets, no rationale is documented anywhere in the repository for why a mint policy would need to override the note's tag, note_type, or RECIPIENT. The currently shipped mint policies (allow_all, owner_only, for both fungible and non-fungible faucets) only perform access control and leave these three values unchanged, so this capability is unused in practice while still being exposed to any custom policy a faucet developer registers.

Consider evaluating whether the mint policy interface should allow a policy to only accept or revert a mint request based on the provided tag, note_type, and RECIPIENT, rather than allowing it to silently change these values.

Update: Resolved in pull request #3760.

Component Packages Are Returned With Debug Sections And No Option To Strip Them

compile_component_code returns the assembled package exactly as the assembler produced it. Debug section emission is enabled by default in the assembler and cannot be disabled from this crate, so every component package produced through CodeBuilder carries debug sections and the API offers no way to obtain a stripped one. Retaining them is intentional for in-memory use, since the debug information backs source-mapped runtime errors, and the protocol types that cross a serialization boundary drop the information by construction. A caller that extracts the underlying package through into_library and serializes it directly, however, retains those sections unless it separately calls Package::without_debug_info, which the builder does not document.

Consider exposing an explicit option on CodeBuilder to strip package-owned debug sections before the component package is returned, or documenting on compile_component_code that the returned package retains debug information and that callers who serialize it are responsible for stripping the sections.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Note Storage Is Written And Hashed Before Its Length Is Validated

Both owner_action::main and faucet_policy_action::main call active_note::get_storage before the dispatched action asserts the storage length it expects. That accessor delegates to write_storage_to_memory, which loads the entire storage preimage from the advice map, writes every element to memory, and recomputes the storage commitment with no early exit. The extra cost is bounded and the aborted transaction yields no proof, so the practical effect is wasted work on input the script goes on to reject rather than a usable denial of service. The ordering is not specific to these two scripts: active_note exposes no accessor that returns the storage commitment and item count on their own, so every note script in the repository processes full note storage before it can validate the length.

Consider adding an active_note accessor that returns the storage commitment and num_storage_items without piping the preimage to memory, modeled on input_note::get_storage_info, and having note scripts reject unexpected storage lengths through it before calling active_note::get_storage.

Update: Resolved in pull request #3563.

P2ideNote::target Documentation Overstates Exclusive Consumption

The documentation on target describes the returned account as the only account that can consume the note. A P2IDE note has a second consumption path: when the executing account does not match the stored target, the note script falls through to reclaim_note, which admits the configured reclaimer once reclaim is enabled and the reclaim height is reached. The sentence is inherited from the equivalent P2ID accessor, where it is accurate because that script has no reclaim branch. The remainder of the library models the two authorized spenders correctly, including the P2IDE branch of is_consumable_inner, which reports the reclaimer as authorized after the greater of the reclaim and timelock heights. The inaccuracy is therefore confined to the accessor documentation, but a reader who relies on it may form an incorrect view of which accounts can spend a reclaimable note.

Consider rewording the documentation of target to describe the target as the intended recipient rather than the exclusive consumer, and to state that a note configured with a reclaim height may also be consumed by the reclaimer once that height is reached.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Expected Storage Size for MINT Notes Matches Only One of Four Valid Layouts

The StandardNote helper classifies a note by its script root and reports the number of storage items that a note of that kind is expected to have. All four MINT variants (fungible or non-fungible, with a private or public output note) are produced by the same note script and therefore share a single script_root, so from_script_root cannot distinguish them. Their layouts nevertheless differ: the MintNote constants define exactly 13 items for a private fungible note, exactly 9 for a private non-fungible note, and a minimum of 20 or 16 items for the respective public variants, which grow with the storage of the output note recipient. The mint.masm script documents and enforces that same set of accepted sizes, while the MINT arm of expected_num_storage_items returns NUM_STORAGE_ITEMS_PRIVATE unconditionally, under a doc comment that carries no caveat, unlike the adjacent OWNER_ACTION and RBAC_ACTION arms which state that they return an upper bound.

The reported value of 13 is neither exact for three of the four layouts, nor an upper bound, since the public layouts start at 16 and 20 items, nor a lower bound, since a private non-fungible note holds 9 items, so it cannot be used safely under any reading of the accessor. The function has no caller in the repository, and the storage size validation that gates execution is performed by the note script itself, so note execution is unaffected. An integrator that relies on this accessor to validate, filter, or size MINT note storage may reject or mishandle valid MINT notes.

Consider keying the reported size on the additional metadata that distinguishes the variants (fungible versus non-fungible, private versus public), or returning the set of valid sizes instead of a single value, and documenting that no single expected size can be derived from the MINT script root alone so that strict equality checks are avoided for that note kind.

Update: Resolved in pull request #3810.

Management Notes Built For Network Execution Are Not Identifiable As Network Notes

PauseActionNote and RbacActionNote are public, assetless management notes whose documentation states that they are always public for network execution and tagged for the account whose configuration they modify. Their conversions into protocol notes (From<PauseActionNote> for Note and From<RbacActionNote> for Note) derive a tag from that account through NoteTag::with_account_target and forward caller-supplied attachments unchanged, and NoteAttachments::new validates those attachments only against the protocol limits on attachment count and total word count. Neither note derives a NetworkAccountTarget attachment from the managed account, and neither rejects one that names a different account.

A note tag retains only the 14 most significant bits of the account ID prefix and acts as a best-effort discovery filter, so it identifies a bucket of accounts rather than a single consumer, while the account that should consume a note through network execution is expected to be carried by the standardized NetworkAccountTarget attachment. Whether a note counts as network-targeted depends solely on that attachment (see is_network_note), so a management note built with the default attachments is not reported as a network note and carries nothing that a consumer routing on attachments can key on, which contradicts the documentation of both types. A note whose tag and NetworkAccountTarget attachment name different accounts can also be constructed. Both encodings are fixed at note creation and only the note author can make them disagree, and a management note that reaches an unintended account aborts on the authorization check performed by the invoked procedure, so the consequences are limited to off-chain discovery and routing.

Consider deriving the NetworkAccountTarget attachment from the managed account whenever one of these notes is built, and rejecting any caller-supplied NetworkAccountTarget that names a different account, so that each note carries a single canonical target encoding. The same tag-only targeting and the same documentation appear in MintNote, BurnNote, OwnerActionNote and FaucetPolicyActionNote, which would benefit from the same treatment.

Update: Resolved in pull request #3664.

Account Interface Construction Panics on Accounts With Custom Authentication

AccountInterface::new asserts that exactly one of the supplied component interfaces is an authentication component, and auth_component restates that invariant with an expect. The classifier that produces those components, from_procedures, recognizes a standard component only when every procedure MAST root of that component is present in the account code, as required by extract_component, and collects all remaining procedures into Custom, a variant that is_auth_component excludes. An account whose authentication procedures were not compiled from one of the standard authentication modules therefore classifies to zero authentication components and panics.

Such accounts are a supported configuration rather than malformed input. with_auth_component accepts an arbitrary component, and AccountCode deserialization validates only the procedure count and MAST forest membership, so the account code that triggers the panic can be obtained from the network rather than supplied by the caller. AccountInterface is an off-chain classification helper with no on-chain role, but it is public API of a published no_std crate, where a panic aborts rather than unwinds, so a consumer that classifies third-party accounts can be brought down by a single account using custom authentication. The # Panics documentation on the constructor attributes zero authentication components to a malformed account, which does not describe this input.

Consider making classification fallible, so that account code whose authentication component cannot be identified yields an error instead of a panic, or extending the interface model so that non-standard authentication is represented as an authentication component rather than falling into the custom bucket. The authentication procedure is always stored at index 0 of the account procedure list, which the classifier could use to locate the authentication component without matching MAST roots.

Update: Acknowledged, will resolve. The team is tracking this in https://github.com/0xMiden/protocol/issues/3829 and will triage the outstanding findings after the partner mainnet is live. Some items may ultimately be discarded, with the rationale noted on the tracking issue.

Conclusion

The audited diff extends the Miden standards library with a substantial amount of new functionality: a non-fungible faucet, the consolidation of all faucet gating behind a single token policy manager covering mint, burn, send, and receive, the centralization of administrative authorization in a shared Authority component, a family of configuration notes that deliver administrative operations to deployed accounts, and the migration of every account component to the miden-project packaging system. The library retains its existing separation between the reusable procedure implementations in asm/standards/ and the installable component wrappers in asm/components/, with each component now packaged independently.

Two critical and four high severity issues were identified during this engagement, alongside several medium, low, note severity findings. The volume and severity of these findings reflect the breadth of newly introduced functionality under review: much of the audited surface is being exercised for the first time, and several of the reworked abstractions concentrate authorization decisions that were previously distributed across individual components.

The development team was collaborative and responsive throughout the engagement.

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.