- August 26, 2026
OpenZeppelin Security
OpenZeppelin Security
Security Audits
Summary
Type: Library
Timeline: 2026-05-18 → 2026-06-30
Languages: Rust and MASM
Findings
Total issues: 60 (50 resolved, 4 partially resolved)
Critical: 0 (0 resolved) · High: 0 (0 resolved) · Medium: 11 (7 resolved, 2 partially resolved) · Low: 17 (14 resolved, 2 partially resolved)
Notes & Additional Information
32 notes raised (29 resolved)
Client Reported Issues
0 reported issues (0 resolved)
Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- Security Model and Trust Assumptions
- Medium Severity
- Transfer Policies Registered as Reserved Can Never Be Activated
- Policy Getters Violate the 16-Felt call ABI
- Ownership Transfers and Role Assignments Are Permanently Restricted to Version-One Account IDs
- Authority-Gated Setters Are Permissionless When AuthControlled Is Paired with AuthSingleSigAcl
- Account Authentication Does Not Bound the Deducted Transaction Fee
- ECDSA Authentication Discloses Signer Public Key and Signature via Precompile Calldata
- Multisig Getter Procedures Violate the 16-Felt call ABI
- Signed Transaction Summary Does Not Bind Expiration or Reference Block Commitment
- Foreign Procedure Invocation Reads Reflect Prover-Chosen Reference Blocks Rather Than Current State
- Per-Procedure Threshold Overrides Can Reduce Required Signatures for Untracked Transaction Effects
- Repeated Unauthorized Input Note Consumption Can Drain an Account Through Transaction Fees
- Low Severity
- Authorization Guards Defined as call ABI but Used Only as Internal exec Building Blocks
- Missing Upper Bound Validation in set_max_supply
- Misleading Documentation in Faucet and Transfer Policy Procedures
- Authority Component Prevents Per-Operation Role Differentiation in RBAC Mode
- Sender-Based Access Control Can Be Bypassed When the Privileged Sender Is a Permissionless Account
- Note Script Allowlist Authentication Reads Live Storage Instead of Initial Transaction State
- Untrusted Scripts Can Force the Transaction Host to Sign Outside the Authentication Boundary
- Misconfigured ACL Storage Can Permanently Brick AuthSingleSigAcl Authentication
- Multisig Signer Query Helpers Return Begin-of-Transaction State and get_signer_at Silently Returns Empty Data Out of Range
- AuthMultisig Panics on Duplicate Procedure Thresholds
- AuthNetworkAccount Allowlist Incompatible With Standardized Note Script Versioning
- Duplicate Signer Public Keys Allow One Signature To Satisfy Multiple Multisig Slots
- Per-Procedure Threshold Overrides Above the Default Are Bypassable in Two Transactions
- RBAC Owner Has Unconditional Super-Admin Authority Over All Roles
- Duplicate Procedure Roots in AccountCode Can Brick Accounts and Weaken Authentication Policies
- cleanup_pubkey_and_scheme_id_mapping Trusts Unvalidated Approver Counts and Omits Configuration Reconciliation
- AuthSingleSigAcl Nonce Can Be Advanced Without a Signature, Invalidating Pre-Signed Transactions
- Notes & Additional Information
- Mutability Config Read Directly Instead of Through the Exported Getter
- Uninitialized Active Mint or Burn Policy Root Bricks Minting and Burning
- transfer_ownership Contains Redundant Stack Operations in the Cancellation Path
- RBAC Membership Writes Use Three movup.6 Stack Operations Where a Single swapw Suffices
- renounce_ownership Cannot Be Called While an Ownership Transfer Is Pending
- Inconsistent Procedure Ordering Convention Across Access Control Standards
- Factory-Level NoAuth Composition Guardrails Can Be Bypassed Through AccountBuilder
- Storage Slot Name AUTHORITY_SLOT Omits the Variable Segment Used by Every Other Standards Slot
- accept_ownership Performs a Redundant Nomination Check and Post-Validation Stack Reordering
- Authority Discriminant Defined as Untyped Constants Instead of an Enum
- Authority::try_from Does Not Reject Non-Canonical Storage Words
- Redundant and Unused Code in signature.masm
- Multiple Cycle-Cost Inefficiencies in auth_tx_acl
- accept_ownership Can Promote a Nominee When owner Is Zero
- Redundant Per-Iteration Memory and Storage Traffic in verify_signatures
- Inaccurate Comments in Authentication Component Procedures
- AuthSingleSig::new Accepts an Inconsistent Public Key Commitment and Signature Scheme
- Unconditional Nonce Increment in Signature Authentication Lets Empty Transactions Incur Fees
- Multiple Cycle and Code-Size Optimizations in the RBAC Membership Write and Read Paths
- Misleading PUBLIC_KEY_SLOT Naming in singlesig_acl.masm Refers to a Public Key Commitment
- Multisig Authentication Failures Are Unattributable Under Delegated Proving
- Minor Cycle-Cost Inefficiencies in the Multisig Auth Component
- Inaccurate Stack-Layout and Advice-Map Comments in multisig.masm
- Per-Procedure Threshold Overrides Are Not Re-Evaluated When the Signer Set Grows
- set_procedure_threshold Does Not Verify That the Procedure Root Belongs to the Account
- Misleading assert_new_tx Procedure Name Hides Storage Mutation
- allow_unauthorized_output_notes Cannot Authorize Otherwise-Empty Note-Creating Transactions
- Missing Advice Hash Verification in update_signers_and_threshold Is Safe but Undocumented
- Redundant Word Duplication in assert_only_one_non_auth_procedure_called
- Unused Local Reservation in auth_tx
- Multisig auth_tx Documentation Misattributes Replay Protection to SALT
- RoleSymbol Ordering Does Not Match Encoded Felt Ordering
- Conclusion
- Appendix
Scope
OpenZeppelin performed an audit of the 0xMiden/protocol repository at commit 2ef8056.
In scope were the following files:
crates/miden-standards/
├── asm/
│ ├── account_components/
│ │ ├── access/
│ │ │ ├── ownable2step.masm
│ │ │ ├── rbac.masm
│ │ │ └── authority.masm
│ │ ├── auth/
│ │ │ ├── guarded_multisig.masm
│ │ │ ├── multisig.masm
│ │ │ ├── no_auth.masm
│ │ │ ├── singlesig.masm
│ │ │ └── singlesig_acl.masm
│ │ └── faucets/
│ │ ├── fungible_faucet.masm
│ │ └── policies/
│ │ ├── burn/
│ │ │ ├── allow_all.masm
│ │ │ └── owner_controlled/owner_only.masm
│ │ ├── mint/
│ │ │ ├── allow_all.masm
│ │ │ └── owner_controlled/owner_only.masm
│ │ ├── policy_manager.masm
│ │ └── transfer/
│ │ ├── allow_all.masm
│ │ ├── basic_blocklist.masm
│ │ └── blocklist/owner_controlled.masm
│ └── standards/
│ ├── access/
│ │ ├── ownable2step.masm
│ │ ├── rbac.masm
│ │ └── authority.masm
│ ├── auth/
│ │ ├── guardian.masm
│ │ ├── mod.masm
│ │ ├── multisig.masm
│ │ ├── signature.masm
│ │ └── tx_policy.masm
│ └── faucets/
│ ├── fungible.masm
│ └── mod.masm
└── src/
└── account/
├── access/
│ ├── ownable2step.rs
│ ├── rbac.rs
│ └── authority.rs
├── auth/
│ ├── mod.rs
│ ├── no_auth.rs
│ ├── singlesig.rs
│ ├── singlesig_acl.rs
│ ├── multisig.rs
│ └── guarded_multisig.rs
├── policies/
│ └── transfer/
│ ├── allowlist/
│ │ ├── mod.rs
│ │ └── owner_controlled.rs
│ └── basic_allowlist.rs
└── faucets/
├── mod.rs
├── token_metadata.rs
└── fungible/mod.rs
crates/miden-protocol/src/account/access.rs
System Overview
miden-standards is a library of reusable Miden Assembly components for building accounts and fungible-token faucets on Miden, so that integrators can assemble accounts from prebuilt modules instead of reimplementing authentication, access control, and policy enforcement. It is organized in two layers: the asm/standards/ modules hold the core logic, and the asm/account_components/ modules package that logic as installable components exposed through the Rust builder API. Most account-component procedures are thin re-exports, though some (notably the authentication entry points and the authority gate) add their own composition logic.
A transaction executes against a single account, consuming zero or more input notes and anchored to a reference block, and produces the updated account plus zero or more output notes. An optional transaction script drives top-level execution. The kernel runs four stages: a prologue that prepares the execution context, execution of each input note's script, execution of the transaction script and any account procedures called, and an epilogue that computes the account delta, note commitments, and fee before the proof is produced.
All data supplied outside the operand stack (signatures, policy roots, signer public keys) arrives through the advice provider, an unverified input channel, and must be authenticated against a known commitment before use.
Storage is read in two modes: initial-state reads (as at transaction start) and current-state reads (reflecting writes made earlier in the same transaction). Authentication and policy paths deliberately use initial-state reads so that a signer or policy update made during a transaction cannot retroactively authorize that same transaction; mixing the modes in security-critical paths risks time-of-check to time-of-use hazards.
Two features that bear on the security model were not final during the review: the fee mechanism was under revision, and deployed accounts have no code-upgrade path.
Authentication
The authentication component is the account-level gate: it decides whether an entire transaction is authorized at all, independent of which procedures it calls. Five components are in scope.
- NoAuth: permits every transaction without a signature check; intended for accounts controlled entirely at the note-script or caller level.
- SingleSig: requires a single signature using either ECDSA over secp256k1 (signing a Keccak-256 hash of the transaction summary) or a Poseidon2-based variant of Falcon-512. The signed transaction summary commits to the account delta, the input and output note commitments, the reference block number, and the final nonce.
- SingleSigAcl: extends
SingleSigwith an access-control list. Procedure roots can be registered as triggers that require a signature, and separate flags control whether creating output notes or consuming input notes requires one. When none of these conditions holds, the transaction only increments the nonce and completes unsigned. - Multisig: k-of-n approval, where each signer is a public key commitment stored in a storage map, with per-procedure threshold overrides.
- GuardedMultisig: extends
Multisigwith a guardian key that must co-sign every transaction, in addition to the multisig threshold. The sole exception is guardian-key rotation: whenupdate_guardian_public_keyis the only non-auth procedure called and the transaction has no notes, the guardian check is skipped so the key can rotate without the outgoing guardian.
Access Control
Access control operates at the procedure level, inside a transaction the auth component has already permitted, restricting which callers may invoke which operations.
- Ownable2Step: two-step ownership transfer in which the nominee must explicitly accept, avoiding transfers to unreachable addresses. Ownership gates procedure-level execution, not the right to transact.
- Rbac: role-based access control backed by a storage map from role identifiers to membership bitmaps.
- Authority: an account-wide gating mode for authority-gated setters such as policy and metadata management, with three variants.
OwnerControlledrequires the owner,RbacControlleda role holder, andAuthControlleddelegates the check to the authentication component.
Faucet Standards
The fungible faucet mints and burns under a policy framework managed by TokenPolicyManager. Mint and burn policies run internally at operation time, with the active policy root held in a value slot. Send and receive policy roots live in the protocol-reserved kernel callback slots: a faucet configured with a transfer policy installs those slots and stamps a callback flag on every asset it mints, so the kernel invokes the active send or receive policy whenever the asset moves (a faucet with no transfer policy installs no slots and mints unflagged assets). Policies can be registered as immediately active or reserved for later promotion. In-scope implementations include allow-all, owner-only mint and burn, and basic blocklist transfer policies with owner-controlled membership.
Security Model and Trust Assumptions
Authorization Layers
The library defines authorization at two levels that must not be conflated. The authentication layer decides who may transact against an account at all, meaning who can produce a valid proof that changes its state, consumes its input notes, or creates output notes on its behalf. The access-control layer operates inside an already-permitted transaction, restricting which callers may invoke specific procedures.
The layers are complementary, not interchangeable. Protecting a procedure with Ownable2Step or Rbac does nothing to stop an unauthorized party from transacting against the account if the authentication component allows it, nor from creating notes on the account's behalf. Those notes carry the account's identity as sender, so a weak authentication model lets any party emit notes bearing the account as sender and bypass sender-based checks on other accounts. An account's posture depends on both layers.
Signing Model
The transaction model is ZK-provable: a transaction is valid if and only if a valid proof exists, so every MASM check is a constraint on the proofs the verifier accepts, and the prover always knows all witness data.
Authentication components sign transaction effects, not actions. A signature covers what the transaction does to account and note state (the account delta, input note, and output note commitments), not which procedures ran or with what arguments. Effects absent from the signed summary are unconstrained, so any protocol expansion must keep what a component signs aligned with the effects it must authorize. During the review, the transaction fee and the reference block commitment were found to be outside the signed summary of certain components. Relatedly, Miden supports delegated proving, where the owner signs the summary and a separate prover generates the proof: the transaction script, cycle count, and fee are unsigned, so a delegated prover can substitute or pad the script within the summary's constraints.
Trust Assumptions
The MASM and kernel layers do not validate initial storage values at account creation. Policy roots, authority discriminators, role symbols, mutability flags, and signer configurations are trusted as set at initialization and assumed consistent with component invariants. For faucets specifically, the allowed-policies maps, initial active policy roots, authority discriminator, and mutability flags are assumed configured consistently at deployment; the runtime checks a policy root against the allowed map at set time but does not constrain which policies are initially permitted.
Under Authority::AuthControlled, every authority-gated setter is gated solely by the authentication component, so if that component has any permissionless path the setters are reachable with no key material. Similarly, SingleSigAcl completes a note-free transaction that calls an unregistered state-changing procedure without a signature, so every procedure meant to require authorization must be registered as a trigger.
Rust-layer validation is not an on-chain trust boundary. Rbac role symbols are the clearest case: on-chain the only check is that the symbol is non-zero, so any non-zero field element is a valid role key. The Rust RoleSymbol type is stricter, rejecting values that are out of range or do not decode cleanly. A role granted on-chain with a felt the Rust type rejects, reached through a crafted note or a direct storage write, works as an access key on-chain but is unrepresentable off-chain: Authority::try_from and the storage display path both call RoleSymbol::try_from and error on it, leaving the account valid on-chain yet unreadable to the Rust SDK and any indexer built on it. Integrators are assumed to construct role symbols through the builder API, which the on-chain layer does not enforce.
Ownable2Step adds limited assurance for signature-based accounts, since the owner already controls which transactions are signed and can decline to sign an ownership transfer, making the second step largely redundant with the auth layer. For network accounts the constraint is different: the Miden node operator set is currently centralized, and operators decide which notes are applied to network accounts, so an operator can censor an ownership-transfer note regardless of its validity.
SingleSigAcl requires that every state-changing procedure intended to require authorization is registered in the trigger procedure map. A transaction with no input or output notes that calls an unregistered state-changing procedure will increment the nonce and complete without a signature.
Medium Severity
Transfer Policies Registered as Reserved Can Never Be Activated
A fungible faucet enforces its send and receive policies through asset callbacks that the kernel dispatches only when an asset carries the callbacks flag stamped at mint from has_callbacks. The active root for a send or receive policy lives directly in a protocol-reserved callback storage slot, and the TokenPolicyManager builder seeds those slots from the active roots while omitting any slot whose root is empty. The builder also exposes a Reserved registration intended to register a policy now and promote it later.
When a transfer policy is registered as Reserved with no active policy of that kind, the registration is accepted unconditionally and the root is recorded as an allowed root, but the active root stays empty so the callback slot is never written to storage. Promotion is then impossible:
- The faucet is created with a transfer policy registered as
Reserved. The root appears in the allowed-roots map, so the configuration looks valid, but no callback slot exists. - The owner later calls
set_send_policyorset_receive_policyto promote the reserved root, which writes the callback slot throughset_item. set_itemaborts withERR_ACCOUNT_UNKNOWN_STORAGE_SLOT_NAMEbecause the slot does not exist, and account storage slots cannot be created after initialization (tracked as a future feature in issue #2183).
The reserved transfer policy is therefore stranded permanently. Nothing fails at build or mint time, so the faucet appears configured while the policy can never be enforced, and if no transfer policy is active the faucet mints callback-exempt tokens for its entire life. This is specific to transfer policies: mint and burn keep their active root in a dedicated value slot that is always created, so their reserved-then-promote flow works. A related risk is latent should runtime storage upgrades land: if the callback slot can be created after the fact, has_callbacks would flip from false to true mid-life, and since the callbacks flag is encoded in the fungible asset's vault key, tokens minted before and after activation would not aggregate and the earlier tokens would stay exempt.
Consider rejecting the configuration at build time by requiring an active send or receive policy whenever any policy of that kind is registered, or alternatively seeding the callback slot with a non-empty default transfer root (such as an allow-all root) whenever any transfer policy is registered, so the slot always exists, has_callbacks is fixed at creation, and promotion has a slot to write.
Update: Resolved in pull request #3047.
Policy Getters Violate the 16-Felt call ABI
The TokenPolicyManager standard exposes a set of public procedures that read the active policy roots and are intended to be invoked via call. Procedures invoked this way operate on a fixed 16-felt operand stack: they receive [pad(16)] and must return exactly 16 felts. Accordingly, get_mint_policy, get_burn_policy, get_send_policy, and get_receive_policy are each documented as accepting [pad(16)] and returning the policy root followed by pad(12).
These getters do not honor that ABI. Each one reads a storage slot through active_account::get_item and returns immediately, without removing the extra word of padding. Because get_item consumes the two slot-identifier felts and pushes back a full word, it grows the stack by two felts.
As a result, every get_*_policy procedure is effectively unusable through call, and any composed script that relies on the documented return frame breaks. The companion setters are unaffected, since they end with a dropw that restores the 16-felt frame.
Consider normalizing the return frame of each get_*_policy procedure by dropping one word of padding after the policy root is loaded. Additionally, consider adding conformance tests that invoke each call procedure and assert the return stack depth is exactly 16, so future regressions of the call ABI are caught automatically.
Update: Resolved in pull request #3114 at commit 6f765dd.
Ownership Transfers and Role Assignments Are Permanently Restricted to Version-One Account IDs
The account_id::validate procedure accepts only version-one account IDs, asserting eq.VERSION_1. Both transfer_ownership in the Ownable2Step component and the RBAC component's grant_role and revoke_role paths statically link this procedure through exec, so the version-one restriction is compiled into each component's code commitment. For an immutable account carrying these components, the code commitment cannot change, so the account can never nominate a new owner or grant a role to an account whose ID uses a future version, even after the protocol introduces support for such versions.
The current owner retains full control and every other component operation continues to function, so the components are not rendered unusable. However, ownership transfers and role assignments cannot cross a version boundary. If version-one accounts are eventually deprecated, ownership and roles held through such immutable accounts become stranded, recoverable only by deploying a fresh account or, for mutable accounts, upgrading the component code.
Consider validating only the structural requirements of an account ID in these components, without constraining the version.
Update: Resolved in pull request #3188 at commit 792f143 and in pull request #3216 at commit 7f97d2b.
Authority-Gated Setters Are Permissionless When AuthControlled Is Paired with AuthSingleSigAcl
The Authority component selects a single account-wide gating mode that every authority-gated setter consults, including the TokenPolicyManager procedures set_mint_policy, set_burn_policy, set_send_policy, and set_receive_policy, as well as the fungible token metadata setters and the pause controls. Each such setter calls authority::assert_authorized before writing to storage. Under Authority::AuthControlled, assert_authorized is a no-op, so the account's auth component becomes the sole gate for every authority-gated setter. The Authority::AuthControlled documentation states this invariant explicitly: the auth component must authenticate every setter root, otherwise the setters become permissionless.
When the chosen auth component is AuthSingleSigAcl, this invariant does not hold under the component's default configuration. The auth_tx_acl procedure requires a signature only when a registered trigger procedure was called, when output notes were created and allow_unauthorized_output_notes is false, or when input notes were consumed and allow_unauthorized_input_notes is false. When none of these conditions hold, control reaches the else branch, which increments the nonce and finalizes the transaction without verifying any signature. The default configuration produced by AuthSingleSigAclConfig::new registers an empty trigger list, so no setter root is tracked, and a transaction that consumes no input notes and creates no output notes satisfies none of the signature conditions regardless of the allow_unauthorized_* flags. The transaction-is-empty check in the epilogue does not prevent this, because it rejects a transaction only when the account delta is empty and there are no input notes, and a policy write produces a non-empty delta.
As a result, an unauthorized party holding no key can rewrite the policy of any account that installs Authority::AuthControlled, AuthSingleSigAcl, and an authority-gated component such as TokenPolicyManager:
- The attacker constructs a transaction with no input notes and no output notes whose script calls
set_mint_policy. assert_authorizedis a no-op underAuthControlled.auth_tx_aclfinds no triggered procedure and no note usage, takes theelsebranch, and increments the nonce without a signature.- The policy write produces a non-empty delta, the epilogue accepts the transaction, and the attacker's policy persists.
This permits any party to overwrite the mint, burn, send, and receive policies, the maximum supply, the metadata, and the pause state of an affected account. Because the unsafe behavior is present in the default AuthSingleSigAcl configuration rather than being gated behind a relaxed flag, an integrator that pairs these standard components without registering every gated setter as a trigger procedure ships an account whose policies are publicly writable. The remaining authority modes do not share this exposure, since under OwnerControlled and RbacControlled the call to assert_authorized reverts for an unauthorized sender and aborts the transaction before any write occurs. The purpose of AuthSingleSigAcl, which is to permit selected operations without a signature, is in direct tension with the AuthControlled premise that the auth component gates every setter.
Consider enforcing this invariant at account construction rather than relying on documentation. When AuthSingleSigAcl is installed alongside Authority::AuthControlled and an authority-gated component, account construction could require that every authority-gated setter root is present in auth_trigger_procedures and fail otherwise. At a minimum, consider documenting on Authority::AuthControlled that pairing it with a permissive auth component leaves authority-gated setters reachable without a signature even under the most restrictive configuration, and enumerating the setter roots an integrator must register as trigger procedures.
Update: Partially Resolved in pull request #3180 at commit b690f1a. A tracking issue was created to address the remaining items.
Account Authentication Does Not Bound the Deducted Transaction Fee
SingleSig authentication signs the commitment of a transaction summary built by create_tx_summary, covering ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, and a SALT of [0, 0, ref_block_num, final_nonce]. The reference block number is deliberately included so that the signer commits to the fee parameters of its intended reference block, which the documentation in authenticate_transaction describes as determining "the fee amount that is deducted."
However, the deducted fee is not fully determined by those parameters. The kernel computes it as verification_base_fee multiplied by ilog2(num_tx_cycles) + 1 in compute_fee, where the cycle count is an input that the signed summary does not constrain. The transaction script that drives the cycle count is supplied by the prover from the advice stack in process_tx_script_data and is absent from the summary. The fee is computed from clk only after the account delta commitment has been finalized, and compute_and_remove_fee removes the fee asset from the vault without affecting the delta, since the code treats modifications at that point as "essentially ignored." As a result, a party that re-proves the transaction in a delegated-proving or relaying setting can reuse the existing signature while substituting a script that preserves the delta and note commitments but executes additional cycles, increasing the fee charged to the signer's account.
This belongs to a broader class of issues in which the native account is charged a transaction fee that its owner never authorized or bounded, because the fee is applied unconditionally once the authentication procedure completes and is never part of the signed message. The same exposure appears more directly in components that expose a permissionless completion path, such as auth_tx_acl, where a transaction that consumes a note or makes a non-trigger state change can complete with no signature as long as note usage stays within the allow_unauthorized configuration. In that case an attacker needs neither a reused signature nor cycle padding to make the account pay a fee. Across these cases the practical effect is the same: slow balance erosion through repeated unauthorized fee payments, rather than direct theft, since the fee accrues to the block producer and not to the attacker.
The amplification available to a re-prover is constrained by the fee's logarithmic dependence on the cycle count. The prover's compute grows linearly with the number of padded cycles, while the resulting fee grows only as ilog2 of that count, so each additional unit of fee charged to the victim requires roughly doubling the attacker's proving work. This holds even when the attacker is the block producer that collects the fee, since the proving cost of the padded transaction is borne by the attacker, making amplification beyond the baseline fee economically self-defeating.
Consider binding the fee to what the account authorizes, for example by including an explicit maximum fee amount (or fee faucet and amount) in the transaction summary and enforcing it during fee computation, or by committing to the transaction script root. For permissionless completion paths, consider drawing the fee against a value the account can constrain rather than allowing any completing transaction to deduct it unconditionally.
Update: Acknowledged, will resolve. The fee deduction mechanism was removed from the kernel to be re-implemented in the future.
ECDSA Authentication Discloses Signer Public Key and Signature via Precompile Calldata
Miden's precompile framework currently relies on native re-verification: the proof verifier recomputes each precompile commitment from the raw calldata that is carried inside the transaction proof, so that calldata must travel with the transaction in order for it to verify. The signature authentication components store only a single-word Poseidon2 commitment to the public key on-chain, in PUBLIC_KEY_SLOT, and the raw public key is supplied non-deterministically at authentication time and checked against that commitment.
When an account authenticates with the ecdsa_k256_keccak scheme, exec.ecdsa_k256_keccak::verify emits an event whose handler records a precompile request whose calldata is the concatenation of the 33-byte compressed public key, the 32-byte message digest, and the 65-byte signature. This calldata is folded into ExecutionProof.pc_requests, serialized into the ProvenTransaction, submitted over the public SubmitProvenTx RPC, and consumed by the node verifier. It cannot be stripped or withheld, because the recomputed precompile transcript is bound into the proof's public inputs and verification fails without the exact calldata. As a result, the raw secp256k1 public key and signature are disclosed to the node operator and to any party on the transaction submission or gossip path, even though the account commits on-chain only to Poseidon2(pk). This de-anonymizes the signer's public key that the commitment-based storage otherwise keeps private and constitutes a privacy asymmetry relative to the falcon512_poseidon2 scheme, which is verified entirely in-circuit and emits no public-key or signature calldata. The keccak256 hashing precompile similarly ships its full preimage, though in this authentication path that preimage is the 32-byte signed message commitment rather than transaction contents.
Consider documenting that ecdsa_k256_keccak authentication exposes the signer public key and signature at proving time and therefore does not provide the public-key privacy implied by commitment-based storage, so that integrators requiring signer-key privacy can select falcon512_poseidon2 instead. Consider, as a longer-term measure, supporting the deferred precompile-proof verification path so that precompile calldata can remain part of the prover's witness rather than being transmitted with the transaction.
Update: Resolved in pull request #3178 at commit aff1ed5.
Multisig Getter Procedures Violate the 16-Felt call ABI
The multisig component exposes three public getter procedures annotated Invocation: call across all three variants (standard, guarded, and smart): get_threshold_and_num_approvers, get_signer_at, and is_signer. Procedures invoked via call must return to an operand stack depth of exactly 16; restore_context returns InvalidStackDepthOnReturn and aborts otherwise.
None of the three getters honor this convention. get_threshold_and_num_approvers receives no input and returns two felts (depth 18). get_signer_at consumes one element and returns five (depth 20). is_signer consumes a four-element key and returns one element; because the stack cannot shrink below 16, the procedure returns at depth 17. Every external call or FPI dispatch to any of these procedures therefore aborts at runtime.
The internal auth flow is unaffected because set_procedure_threshold reaches get_threshold_and_num_approvers via exec, which inlines the callee and bypasses the depth check. The other two getters have no internal callers, so their only reachable path is external, which always fails.
Consider making each getter conform to the 16-felt call ABI by padding or truncating the operand stack before returning. Alternatively, if external invocation is not intended, annotate them Invocation: exec and remove them from the component's public re-exports.
Update: Resolved in pull request #3211 at commit 7eae9cc.
Signed Transaction Summary Does Not Bind Expiration or Reference Block Commitment
Signature-based authorization components build the message that the owner signs from the transaction summary produced by auth::create_tx_summary together with a SALT. Assembled in authenticate_transaction, the signed message is [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT], where SALT is [0, 0, ref_block_num, final_nonce]. The signature therefore commits to the account delta, the input and output notes, the final nonce, and the reference block number, but to no other transaction parameter. In a delegated-proving model, the party that executes and proves the transaction is untrusted and controls every field that the signature does not bind. For multisig accounts, multisig::auth_tx accepts a caller-supplied SALT and never calls tx::get_block_number, so ref_block_num does not enter the signed message at all.
Two such fields influence transaction semantics. The first is the transaction expiration. The expiration block number is initialized to the non-expiring default MAX_BLOCK_NUM by the prologue and can only be lowered by the transaction script through tx::update_expiration_block_delta. The authorization component never reads it, and it never enters the signed summary. A relayer or prover holding a valid signature can omit or alter the expiration update and re-prove the transaction with an arbitrary validity window without invalidating the signature, because the delta, notes, and nonce remain unchanged. A transaction that the owner intended to expire shortly can thus be left with the default window and remain includable far beyond the owner's intent. Because expiration is a consensus-relevant validity constraint enforced during batch and block construction, this defeats the primary staleness control rather than being cosmetic.
The second field is the reference block commitment. For singlesig accounts, only ref_block_num is bound; for multisig accounts, ref_block_num is absent from the signed message entirely. For all components, the corresponding BLOCK_COMMITMENT, which the prologue recomputes and asserts against the global inputs in process_block_data, is not. A block number maps one-to-one to a block commitment only on the canonical chain. Following a reorganization, height N can carry a different commitment and therefore expose different reference-block-derived state, and the same signed summary can be re-proven against that alternative block at the same height. The signed message also carries no chain or genesis identifier, so a summary that is valid on one network can be replayed on any network that shares history up to N where the same account state and notes exist. Reference-block fee parameters such as verification_base_fee are part of the block header and feed compute_fee. Under the current model these parameters are effectively constant across blocks, so the fee impact would materialize only under a future floating-fee model, but the reorganization and cross-network ambiguity exist independently of fees.
Consider extending the signed transaction summary so that it binds every prover-controllable parameter that affects transaction validity or semantics.
Update: Acknowledged, will resolve. The Miden team opened a pull request to work on this issue.
Foreign Procedure Invocation Reads Reflect Prover-Chosen Reference Blocks Rather Than Current State
Foreign procedure invocation allows an account to read another account's state during a transaction, but that read is a snapshot anchored to the transaction's reference block rather than the current chain state. The foreign account commitment is validated inside the proof against the reference block's account tree root in validate_active_foreign_account, and it is never reconciled against the live chain when the transaction is included in a block. The foreign account commitment is not part of the transaction's public inputs, and there is no revalidation of the specific storage slots read during the invocation against their current values at inclusion. Combined with the fact that executors may choose arbitrary reference blocks, the value returned by a foreign procedure invocation is effectively selected by whoever proves the transaction and may not reflect the foreign account's present state.
This affects any logic whose correctness depends on the current value of foreign state. Cross-account authorization is one instance: if a component authorizes a change to its own state based on a role, permission, or allowlist entry stored in another account A, an actor able to author the transaction of the relying account B can anchor that transaction to a canonical reference block from before the privilege was revoked in A, so the check passes despite the revocation, provided B's own state remains unchanged so that its initial state commitment still matches the chain at inclusion. The same property affects oracle and price-dependent interactions, where an account can act on a stale value by anchoring to a past block in which that value was favorable, and more generally any flow that assumes foreign values are current at execution time. In each case the staleness window is controlled by the party proving the transaction rather than by the account that owns the data.
The access-control standards shipped in the library are not affected, since they read and write roles within the same account that governs them. The exposure arises for components that read another account's mutable state through foreign procedure invocation, which downstream integrations building on these standards may reasonably do.
Consider documenting that values read through foreign procedure invocation reflect a prover-chosen reference block and are never revalidated against current foreign state, so that integrators do not rely on them for decisions that require present values.
Update: Resolved in pull request #3208 at commit e29fedf and at commit 9ddd0ff.
Per-Procedure Threshold Overrides Can Reduce Required Signatures for Untracked Transaction Effects
The multisig authentication flow in multisig::auth_tx derives the required signature threshold by calling compute_transaction_threshold. This function iterates over native account procedures and, for each one that was called, takes the maximum of its configured per-procedure override and the running threshold. The determination of whether a procedure was called relies on native_account::was_procedure_called, which returns 1 only when the procedure invoked account-restricted kernel APIs that trigger authenticate_and_track_procedure. Procedures that execute only local MASM instructions return 0 even if executed, and kernel APIs accessible from a transaction script, such as output note creation via output_note::create and foreign procedure invocation via tx::execute_foreign_procedure, do not trigger this tracking at all.
As a result, a transaction script can call one native procedure configured with a low per-procedure threshold override, reducing transaction_threshold to that low value, and then freely perform additional effectful operations, such as creating output notes, executing FPI calls, or calling untracked local procedures, all authorized under the reduced threshold. The intended default_threshold is only enforced when no tracked native procedure was called; once any tracked procedure fires its override, non-procedure effects escape the higher threshold entirely.
Consider whether output note creation, FPI calls, and transaction script effects should be included in the threshold computation (for example, by assigning them a threshold and taking their maximum alongside per-procedure overrides) so that a low per-procedure override cannot reduce the effective threshold below what those effects would otherwise require. Alternatively, consider documenting that per-procedure threshold overrides apply to the full transaction, including any untracked effects that accompany the overriding procedure call, so that threshold values are set with this in mind.
Update: Resolved in pull request #3204 at commit 5431254 by adding output_note::create to the list of tracked procedures invoked during transaction execution. Integrators must designate output_note::create as a procedure that requires the reasonable authorization threshold. This ensures that procedures requiring only a low authorization threshold cannot be used to create associated output notes, and that output note creation always requires the reasonable authorization threshold.
Repeated Unauthorized Input Note Consumption Can Drain an Account Through Transaction Fees
The AuthSingleSigAcl component authenticates a transaction conditionally. Signature verification is performed only when a configured trigger procedure was called, when output notes were created while allow_unauthorized_output_notes is false, or when input notes were consumed while allow_unauthorized_input_notes is false. When allow_unauthorized_input_notes is set, the input note check does not contribute to auth_required, so a transaction may consume input notes without any signature from the account owner. This is intended to let the account receive notes permissionlessly.
In Miden, a transaction can be proved by any party on behalf of any account, and the consuming account pays the transaction fee unconditionally during the epilogue. A transaction that consumes at least one input note is valid even when it leaves the account vault and storage unchanged: the note consumption satisfies the non-empty transaction requirement, and because the account state does not change, no nonce increment is required, so the signature-free branch completes without incrementing the nonce. When allow_unauthorized_input_notes is true, none of this requires the owner's signature.
An attacker can exploit this by crafting asset-free notes that the victim's account is able to consume, and then, for each note, proving a transaction in which the victim's account consumes it. Every such transaction is valid, changes no account state, and charges a fee to the victim's account, so repeated executions gradually deplete the victim's balance. The attack is bounded by an economic asymmetry, since the attacker bears the full proving cost of every transaction while the victim pays only the per-transaction fee. It nonetheless allows a determined attacker to drain a victim's balance without the victim's authorization.
This vector is not unique to allow_unauthorized_input_notes. The root cause is that the consuming account is charged the transaction fee even for consumption it did not authorize, so any configuration that permits signature-free note activity is affected. The output note check with allow_unauthorized_output_notes enables the same fee-charging behavior, and permissionless authentication components such as NoAuth exhibit it as well. Any mitigation should therefore address the general case rather than this single flag.
Consider requiring authorization, directly or indirectly, before an account consumes input notes, so that a third party cannot force fee-incurring consumption on the account. Because allow_unauthorized_input_notes and the related flags exist to support permissionless interactions such as deposits, consider preserving those use cases through an alternative that does not let an unauthorized party impose fees on the account, such as decoupling the fee obligation from an account that did not authorize the activity.
Update: Partially Resolved in pull request #3065 at commit 4a71974 by flipping AuthSingleSigAcl semantics from trigger list to exempt list. However, the core underlying issues remain unaddressed. Because the protocol's fee mechanism is currently disabled, the threat of an account being drained for unauthorized note activity is merely masked rather than fixed. The new access control logic only mandates authentication for kernel-tracked account procedures; since consuming an asset-free input note bypasses this tracking, a keyless third party can still force a victim's account to consume notes without a signature.
Furthermore, the update introduces a regression by entirely removing the allow_unauthorized_input_notes toggle. This change makes the signature-free consumption of asset-free notes unconditional for every AuthSingleSigAcl account, whereas it was previously an opt-in feature. The NoAuth implementation also retains this exact same vulnerability. Once automatic fees are reintroduced, this attack vector will return in full unless fee obligations are decoupled from unauthorized note activities. The Miden team opened a GitHub issue to follow up with it.
Low Severity
Authorization Guards Defined as call ABI but Used Only as Internal exec Building Blocks
The standards package documents procedures that form an account's external interface (reached by notes, scripts, or FPI across a context boundary, and re-exported under account_components/) as Invocation: call with [pad(16)] inputs and outputs, and procedures meant for in-context composition as Invocation: exec, conventionally suffixed _internal. The right invocation thus depends on a procedure's audience. In ownable2step the ownership-lifecycle procedures are external-only and correctly call-only, and the read accessors serve both audiences and correctly pair a call accessor with an _internal exec variant. The owner guard is the exception: asserting that the sender is the owner returns nothing useful across an isolated context, so it is meaningful only as an exec guard composed inside other owner-gated procedures, yet it is defined as a thin call wrapper over its _internal variant.
Seven intra-package callers consequently invoke the call wrapper through exec: the owner-controlled burn and mint policies, the allowlist (allow, disallow) and blocklist (block, unblock) owner-controlled policies, and the OwnerControlled branch of assert_authorized. This has no functional or security impact, since MASM does not enforce invocation kind; it is a consistency matter. The sites contradict the procedure's documented call invocation and [pad(16)] signature, and diverge from the precedent where a call-documented procedure correctly execs the _internal variant. The wrapper is effectively dead: it is not re-exported by the ownable2step component, is never invoked via call anywhere, and the module's analogous predicate is already exec-only with no wrapper.
An eighth site repeats the pattern with a twist. The RbacControlled branch of assert_authorized execs rbac's role guard, also documented call. Unlike ownable2step, the rbac component does re-export that guard as external ABI and provides no _internal variant. The two analogous guards are therefore treated inconsistently, one internal and unexposed, the other published as ABI, leaving open whether a bare authorization assertion belongs in a component's external interface at all.
Consider treating the guards as internal exec procedures rather than call ABI. For the owner guard, collapse the two variants into a single exec procedure without the _internal suffix, which also resolves the seven call sites. For the role guard, settle its audience first: either keep it as ABI and add an _internal exec variant for assert_authorized to use, or make it exec-only and drop it from the rbac re-exports.
Update: Resolved in pull request #3088 and pull request #3116.
Missing Upper Bound Validation in set_max_supply
The fungible faucet records its outstanding supply and its supply cap in the token config slot, and lets an authorized caller adjust the cap through set_max_supply. This stored cap is the authoritative limit on issuance: the transaction kernel keeps no persistent issuance counter for fungible faucets (it enforces only a per-transaction vault merge bound), so the component's token_supply and max_supply values are the sole supply ledger.
The setter validates only that the new cap is not below the current token_supply. It does not check the new cap against FUNGIBLE_ASSET_MAX_AMOUNT (the protocol maximum representable amount, 2^63 - 2^31). An authorized owner can therefore store a max_supply larger than any asset the faucet can ever mint. The condition is not exploitable for over-minting because mint_and_send independently re-asserts that max_supply does not exceed FUNGIBLE_ASSET_MAX_AMOUNT before minting, and the asset constructor re-validates the amount, so any mint that would rely on the oversized cap reverts. The practical effect is an inconsistent stored configuration and a denial of service on minting until the cap is lowered again.
Consider asserting that the new value does not exceed FUNGIBLE_ASSET_MAX_AMOUNT inside set_max_supply, so the stored cap stays consistent with the bound enforced at mint time and the getter never reports an unusable value.
Update: Resolved in pull request #3118 at commit bfdf1da.
Misleading Documentation in Faucet and Transfer Policy Procedures
The faucet standard and its transfer policies contain several documentation inaccuracies across docstrings and inline stack annotations, which can mislead integrators and reviewers who rely on these comments to reason about the procedures. The instances are:
-
Wrong hash function in the metadata setters. The doc comments for
set_description,set_logo_uri, andset_external_linkstate that the caller passes the "Poseidon hash" of the new value, verified against the advice-map preimage during loading. The Miden VM currently uses Poseidon2, which is not output-compatible with Poseidon, so the named hash function is incorrect in all three procedures. -
Inconsistent
new_prefix after the mint policy runs. Inmint_and_send, the stack comment followingexecute_mint_policyprefixes every item withnew_, but the subsequent comments drop the prefix even though they refer to the same values. The prefix signals that the policy may have modified the values, so dropping it inconsistently obscures that distinction. -
Missing
custom_dataelement in the transfer policy inputs. TheInputsdocumentation of the transfer policycheck_policyprocedures omits thecustom_dataelement that the kernel places on the stack after the asset key and value.basic_allowlistandbasic_blocklistdocument[ASSET_KEY, ASSET_VALUE, pad(8)], andallow_alldocuments[ASSET_KEY, ASSET_VALUE](missing the padding elements), but per the callback signature the element followingASSET_VALUEiscustom_data, set to0for the account callback and tonote_idxfor the note callback. -
Incorrect residual stack depth after the final operation. The inline comment after the final operation reports a residual stack below 16 elements in several
call-invoked procedures:[pad(14)]inblock_accountandunblock_accountof the blocklist transfer policy, inallow_accountanddisallow_accountof the allowlist transfer policy, and intransfer_ownershipof theownable2stepmodule (which under-counts the same way at an intermediate step). These procedures arecall-invoked, so the VM enforces a minimum stack depth of 16 and backfills the consumed elements with zeros on return, leaving[pad(16)]as the documentedOutputsalready state. The comments should read[pad(16)], as stated by the masm-padding convention. -
Incorrect claim that Pausable is an optional dependency. The doc comment on
assert_not_pausedstates that when the pause slot is not installed,active_account::get_itemreturns the zero word and the assertion becomes a no-op, presentingPausableas a dependency consumers can gate on "without making Pausable a hard dependency". This is incorrect:get_itemresolves the slot through the kernel, which panics with an unknown-storage-slot error when the slot is absent. The behavior fails closed and grants no authorization bypass, but the comment contradicts the correct notes in the policy manager and the allow-all transfer policy, and a developer who follows it would deploy a non-functional faucet. -
Undocumented
Ownable2Stepdependency in the owner-only mint and burn policies. The owner-only mint and burn policies gate on the account owner by reading theOwnable2Stepowner slot, yet neither their MASM modules nor their Rust components document that they require theOwnable2Stepcomponent. The sibling transfer policies do: theowner_controlledMASM modules and Rust components open with a "Companion components required" note namingOwnable2Step, while the mint and burnowner_onlyfiles only mention the owner "as recorded by theOwnable2Stepcomponent" in passing. Because nothing installs or validates the slot, a faucet assembled withoutOwnable2Stepbuilds successfully and then reverts on every mint or burn.
Consider correcting each comment to match actual behavior: name Poseidon2 as the hash function in the three metadata setters, keep a consistent prefix for the post-policy stack items in mint_and_send, include custom_data in the documented inputs of the transfer policies, update the residual stack comments in the owner-controlled transfer policies and transfer_ownership to [pad(16)], and state in the assert_not_paused docstring that Pausable is a hard dependency. Additionally, document the Ownable2Step dependency required by the owner-only mint and burn policies (ideally contributing it as a companion component so it cannot be silently omitted) as the transfer owner-controlled policies already do.
Update: Resolved in pull request #3119 at commit b145d26 and pull request #3047 at commit 070b598.
Authority Component Prevents Per-Operation Role Differentiation in RBAC Mode
The Authority component provides a unified access control gate used by all authority-protected procedures across the standard library, including pause, unpause, set_mint_policy, set_burn_policy, set_send_policy, set_receive_policy, set_max_supply, and the metadata setters. Each calls assert_authorized, which reads a single AUTHORITY_SLOT word to determine both the authority mode and, in RBAC_CONTROLLED mode, the role to enforce.
Because all authority-gated procedures share the same slot, and the RbacControlled { role } integration exposes only a single RoleSymbol even though RoleBasedAccessControl itself supports many roles, RBAC_CONTROLLED mode enforces the same role for every operation on the account. An account that installs both PausableManager and PolicyManager with Authority::RbacControlled { role } cannot assign a PAUSER_ROLE separately from a POLICY_ADMIN_ROLE: any account holding the configured role can exercise all authority-gated operations simultaneously. This collapses the fine-grained access control that RBAC is designed to provide into a single-role gate, offering no practical benefit over Authority::OwnerControlled for multi-subsystem accounts. The RBAC naming may further lead integrators to expect per-procedure granularity that this path does not provide. Developers who need per-operation role differentiation must bypass assert_authorized entirely and call rbac::assert_sender_has_role directly with hardcoded role constants in each procedure, abandoning the standard component authority abstraction.
The risk is amplified because the authority role is an ordinary RoleSymbol. If it aliases a role the account also uses for application-level logic, granting that role unintentionally confers control over every protected setter.
Consider either redesigning the authority slot to support per-operation or per-subsystem role configuration (for example, allowing each component that uses assert_authorized to supply its own role symbol rather than reading from a shared account-wide slot), or, if the single-role design is intentional, documenting at the RbacControlled definition that one role gates the entire authority-protected surface.
Update: Resolved in pull request #3072 at commit 2f63c43, 36b480e and at commit 71c8fd3.
Sender-Based Access Control Can Be Bypassed When the Privileged Sender Is a Permissionless Account
In Miden, the sender of a note is the account ID of the account that created it, set unconditionally by the kernel's build_metadata procedure, which derives the sender from account::get_id on the native account. The ownable2step and rbac components gate privileged procedures on the active note's sender, read via active_note::get_sender, asserting it matches a registered owner or role member. This check authenticates which account created the note, but not the code that executed when the note was created.
Note creation is not restricted to account procedures. The kernel procedure output_note_create is guarded only by assert_native_account and does not call authenticate_account_origin. The storage mutators account_set_item and account_set_map_item, by contrast, require both guards, where authenticate_account_origin asserts that the caller is an account procedure. As a result, a bare transaction script that invokes none of the account's procedures can create output notes whose sender is the native account ID. Such a script cannot write storage and cannot move value, since vault operations are gated and the epilogue enforces asset conservation, but it can freely choose the new note's script root and recipient.
The no_auth component performs no key verification and never reverts; it only increments the nonce when the account state changed or the account is new. Any party can therefore run a transaction against a no_auth account A with a transaction script that emits a note carrying A as sender and an arbitrary script root. When a contract B restricts privileged procedures to notes sent by A, an attacker mints such a note and consumes it against B, which is also permissionless, defeating B's access control. The note's script executes with the trust B grants to A.
Transaction validity does not prevent this. The epilogue rejects only fully empty transactions, aborting with ERR_EPILOGUE_EXECUTED_TRANSACTION_IS_EMPTY when the account delta is empty and no input notes were consumed. Creating an output note does not affect the account delta, but two paths are always available: against a new account whose nonce is zero, no_auth increments the nonce and produces a non-empty delta; against an existing account, the attacker has A consume an asset-less input note minted from another account they control, making the input-notes commitment non-empty. Either path yields a valid transaction that emits the forged-sender note.
Consider documenting that sender-based access control in ownable2step and rbac is meaningful only when every registered owner or role member account enforces strong authentication, and that registering a permissionless account as owner or role member provides no access restriction.
Update: Resolved in pull request #3205 at commit 1be12ae.
Note Script Allowlist Authentication Reads Live Storage Instead of Initial Transaction State
The note script allowlist primitives in note_script_allowlist.masm are documented as reusable building blocks intended to back multiple authentication components, each with its own allowlist storage map. The assert_all_input_notes_allowed procedure validates each consumed input note's script root against the allowlist map using active_account::get_map_item, which reads the live storage state. Every other authentication component instead reads its authorization data from the initial transaction state via get_initial_map_item. The signature.masm component documents the rationale: the previous authority must authorize a change to the new authority, rather than the new authority authorizing itself.
Because the authentication procedure executes in the epilogue, after all input note scripts have run, any storage write performed earlier in the same transaction is already reflected in the live read. The kernel does not scope storage writes to the component that declared a slot: set_map_item resolves the target slot solely by its global slot identifier and only asserts that the slot exists, is a map, and that the call originates from the native account. Any procedure in an account's code can therefore write any storage slot present in the account, including a slot declared by another component.
The shipped AuthNetworkAccount component is not affected, because its allowlist is fixed at creation and it ships no mutator. However, the primitive is unsafe by default for the reuse it advertises. An integrator that pairs this check with a procedure able to write the allowlist slot, whether in a custom component or alongside AuthNetworkAccount, whose immutability is not enforced by the kernel, enables a single transaction to add a note script root to the allowlist and consume a note carrying that root in the same transaction. The live read observes the just-added entry and the note is accepted, even though the root was not present in the allowlist at the start of the transaction. This is the self-authorizing transition that the initial-state read prevents elsewhere.
Consider reading the allowlist via get_initial_map_item so that the check reflects the pre-transaction allowlist and same-transaction updates cannot authorize the notes they enable. Consider also documenting in the procedure header that this check must not be paired with an allowlist that is mutable within the same transaction.
Update: Resolved in pull request #3182 at commit eac470c and at commit 17a8773.
Untrusted Scripts Can Force the Transaction Host to Sign Outside the Authentication Boundary
During transaction execution, the kernel runs input note scripts and the transaction script via dyncall before the epilogue authentication procedure executes. These scripts run in a non-root context and are untrusted, since input notes may be authored by an adversary and consumed by any account. Signature production is driven by the AuthRequest event: the standard authentication procedure emits it to obtain a signature for the transaction summary from the host's authenticator. Because TransactionEventId::is_privileged treats AuthRequest as unprivileged, note and transaction scripts can emit it as well.
The host's AuthRequest handler honors the event unconditionally, with no check on the execution phase or context. It invokes on_auth_requested, which asks the authenticator to sign and pushes the resulting signature onto the advice stack, where the currently executing script can read it. The reference get_signature signs without any user interaction. Consequently, an untrusted note consumed by a victim can repeatedly force the victim's executor to produce signatures under the victim's key, with no per-transaction limit and outside the intended epilogue authentication phase.
The practical impact is contained by invariants elsewhere. The signed message is constrained to a TransactionSummary commitment over the transaction's actual account delta and notes, with only the salt under script control, so this is not arbitrary-message signing. Any signature obtained during script execution commits to an account delta with a nonce increment of zero, because the nonce can only be incremented from the authentication procedure; the only way for a script to reach a nonce increment is to invoke the authentication procedure itself, which marks it as called and causes the epilogue to abort the transaction with ERR_EPILOGUE_AUTH_PROCEDURE_CALLED_FROM_WRONG_CONTEXT. The signature-verifying authentication components all compute their message after incrementing the nonce, so a signature produced through this path is never accepted by them. The residual concerns are therefore the absence of any bound on forced signature generation, which allows an adversarial note to impose repeated signing work on the consuming party's executor, and the lack of an enforced boundary on signature production, which leaves the safety of the signer dependent on these emergent kernel and library invariants rather than on the host restricting when signatures are produced.
Consider gating signature production in the host to the authentication phase, for example by honoring AuthRequest only while the kernel is executing the registered authentication procedure, which the host already tracks through the epilogue authentication progress events.
Update: Resolved in pull request #3233 at commit 3730c15 and pull request #3251 at commit cabd79c.
Misconfigured ACL Storage Can Permanently Brick AuthSingleSigAcl Authentication
The AuthSingleSigAcl component authenticates every transaction through auth_tx_acl, which reads its configuration (num_auth_trigger_procs and the allow_unauthorized_output_notes / allow_unauthorized_input_notes flags) and a map of trigger procedure roots from initial storage. It then loops over the stored roots, calling was_procedure_called for each, and applies the not operation to each allow flag to decide whether signature verification is required.
Three stored-configuration states cause auth_tx_acl to abort on every transaction, permanently bricking the account because no transaction can ever authenticate:
- A trigger procedure root that is not part of the account's procedure code.
was_procedure_calledasserts that the supplied root exists in the account code and aborts withERR_ACCOUNT_PROC_NOT_PART_OF_ACCOUNT_CODEotherwise. TheAuthSingleSigAcl::newconstructor validates only that the number of trigger procedures does not exceedAccountCode::MAX_NUM_PROCEDURES, and never that each root belongs to the account being assembled. Because the component is constructed before the account's full procedure set is fixed, listing a root that is absent from the final code is easy to do and results in a total brick. - A
num_auth_trigger_procsvalue greater than the number of entries actually present in the trigger map. The loop reads the map at key[i-1, 0, 0, 0]foricounting down fromnum_auth_trigger_procs; a missing key returnsEMPTY_WORD, which is not a valid account procedure root, sowas_procedure_calledaborts as above. - An
allow_unauthorized_output_notesorallow_unauthorized_input_notesvalue that is not binary. Each flag is consumed by thenotoperation, which requires a binary operand and aborts when the value is anything other than0or1.
The typed Rust API derives num_auth_trigger_procs from the length of the procedure list and stores the flags as bool, so the second and third states are only reachable when storage is constructed outside that API, while the first is reachable through the documented constructor. In every case the consequence is identical: each subsequent transaction aborts during authentication, the account can never increment its nonce or move assets, and any held funds become permanently inaccessible. These states are reachable only by the party configuring the account, not by an external caller.
Consider validating the configuration at construction time, in particular that every trigger procedure root is part of the assembled account's code, and rejecting or documenting the cases where it cannot be checked. Consider additionally hardening auth_tx_acl so that malformed storage degrades safely rather than bricking the account, for example by treating an absent trigger entry as a non-match instead of querying was_procedure_called on EMPTY_WORD, and by constraining the allow flags to binary values before applying not.
Update: Resolved in pull request #3206 at commit c8455ae and in pull request #3065.
Multisig Signer Query Helpers Return Begin-of-Transaction State and get_signer_at Silently Returns Empty Data Out of Range
The multisig authentication component exposes the read helpers get_signer_at and is_signer, re-exported by each account-component wrapper (multisig.masm, guarded_multisig.masm, and multisig_smart.masm) so external consumers and tooling can query the configured signer set. Neither procedure is used inside the authentication path itself, so their contracts matter primarily to third-party integrators.
The get_signer_at procedure asserts only that index is a u32 value, and its documentation lists this as the sole panic condition. It performs no bounds check against num_approvers, which is the range populated at configuration time. For any index >= num_approvers, both storage-map lookups miss and return EMPTY_WORD, so the procedure returns a public key of [0, 0, 0, 0] and a scheme_id of 0 without any error. A consumer that indexes or iterates without independently fetching num_approvers via get_threshold_and_num_approvers will treat a non-existent signer as a valid one. The only reliable discriminant for an absent signer is scheme_id == 0, since the supported scheme identifiers are 1 and 2 only, and this sentinel is not documented.
Both helpers additionally read begin-of-transaction state rather than live state. get_signer_at performs both of its storage-map lookups with active_account::get_initial_map_item, and is_signer determines the signer count via get_initial_threshold_and_num_approvers and looks up each approver with active_account::get_initial_map_item. In both cases the returned data reflects the signer set as it was at the beginning of the transaction rather than the live value. This is particularly misleading for is_signer, whose documentation states that it returns 1 if PUB_KEY is a "current signer", directly contradicting the begin-of-transaction read. If a consumer updates the signer set earlier in the same transaction and then calls either helper, the stale pre-update signer set is returned silently, and neither the procedure names nor their documentation surface this begin-of-transaction semantic.
Consider adding an explicit bounds check to get_signer_at that fails closed when index >= num_approvers, consistent with the other u32 guards in the component. If the current return-empty behavior is intentional, consider instead documenting the out-of-range return value and naming scheme_id == 0 as the sentinel for an absent signer. In either case, consider documenting for both helpers that the returned data reflects the signer set as of the beginning of the transaction and is not read-your-writes, and correcting the is_signer documentation so it no longer claims to report the current signer set.
Update: Resolved in pull request #3246 at commit b4bbd29.
AuthMultisig Panics on Duplicate Procedure Thresholds
The with_proc_thresholds method on AuthMultisigConfig accepts a Vec<(AccountProcedureRoot, u32)> and validates only that each threshold is at least 1 and at most the number of approvers. It does not check that the procedure roots are unique, and it stores the vector verbatim. When the configuration is later converted into an AccountComponent, the From<AuthMultisig> implementation builds a StorageMap keyed on StorageMapKey::from_raw(proc_root.as_word()) and calls unwrap on the result. Because StorageMap::with_entries returns DuplicateKey when two entries share a key, a configuration containing the same procedure root twice causes the unwrap to panic during the infallible From conversion, rather than surfacing a recoverable error at configuration time.
This is inconsistent with the handling of approvers, where AuthMultisigConfig::new explicitly rejects duplicate public keys with an error. The duplicate-procedure-root case passes all existing validation and then aborts in a location far removed from the offending input, making the failure difficult to diagnose. The procedure thresholds are chosen by the account builder, so the impact is limited to a panic during account construction rather than a runtime or consensus concern.
Consider rejecting duplicate procedure roots in with_proc_thresholds and returning an AccountError, mirroring the duplicate-approver check in new.
Update: Resolved in pull request #3246 at commit 6ac2574.
AuthNetworkAccount Allowlist Incompatible With Standardized Note Script Versioning
The AuthNetworkAccount component enforces that every consumed input note has a script root present in the allowlist stored at ALLOWED_NOTE_SCRIPTS_SLOT. The allowlist entries are fixed hashes committed at account setup time, and there is currently no mechanism to update them after deployment.
The practical failure mode arises with standardized note types such as P2ID. The script root of a note is determined by the tooling and compiler version used to generate it, meaning two different accounts sending a "P2ID note" may produce notes with different script roots if built with different compiler versions or updated standards. An AuthNetworkAccount deployed with P2ID v1 in its allowlist will silently reject notes created by accounts that output P2ID v2, even though both are semantically equivalent P2ID notes. The sender has no mechanism to produce the old script root if their tooling only generates the new version, and the receiving account has no mechanism to update its allowlist to accept the new root. This is a silent, permanent failure: the receiving account cannot consume the asset, with no in-protocol indication of the cause.
This failure mode is most acute for restricted accounts that cannot produce arbitrary output notes via a transaction script, as those accounts cannot route around the stale allowlist by having a counterparty re-issue the note via an unrestricted path.
Consider documenting that the allowlist must enumerate every script root variant the account is expected to encounter, including across compiler versions and standards updates, and that the lack of an on-chain update mechanism makes this a permanent compatibility risk that should be evaluated at deployment time.
Update: Resolved in pull request #3226 at commit a4db1c6.
Duplicate Signer Public Keys Allow One Signature To Satisfy Multiple Multisig Slots
The multisig authentication component stores its signer set in per-index storage maps and updates it through multisig::update_signers_and_threshold. During transaction authentication, signature::verify_signatures iterates over each signer index, computes SIG_KEY as poseidon2::merge(PUB_KEY, MSG), looks up the corresponding signature in the advice map, and increments a counter at SUCCESSFUL_VERIFICATIONS_LOC on each successful verification. On the host side, TransactionArgs::add_signature inserts signatures into the advice map keyed only by hash(pub_key, message), independent of the signer index. The Rust configuration builder AuthMultisigConfig::new rejects duplicate approver public keys.
The MASM update path does not enforce the same uniqueness invariant. update_signers_and_threshold writes each PUB_KEY to APPROVER_PUBLIC_KEYS_SLOT per index without comparing it against previously written entries, so a signer set containing duplicate public keys is accepted. Because verify_signatures derives the advice-map key solely from PUB_KEY and MSG, and the advice map entry is not consumed on lookup, each duplicated index recomputes the same SIG_KEY, verifies the same signature, and increments the success counter again. A single signature therefore satisfies multiple approver slots. For example, with a signer set of [PK_A, PK_A, PK_B] and a threshold of 2, providing only the signature of PK_A yields two successful verifications and satisfies the threshold without any second distinct approver. This weakens the intended distinct-approvers policy and reduces the effective number of independent signers required to authorize a transaction.
Consider enforcing public-key uniqueness in update_signers_and_threshold by comparing each newly loaded PUB_KEY against the previously written indices and reverting on a duplicate, mirroring the check already performed in AuthMultisigConfig::new.
Update: Resolved in pull request #3246 at commit 9e60f28.
Per-Procedure Threshold Overrides Above the Default Are Bypassable in Two Transactions
The AuthMultisig component allows a deployer to raise the signature requirement for individual account procedures above default_threshold by storing per-procedure overrides, so that sensitive operations such as asset transfers can demand more approvers than routine calls. The effective requirement for a transaction is derived in compute_transaction_threshold, which reads each called procedure's override from the account map via get_initial_map_item, a begin-of-transaction snapshot.
The override values are themselves mutable through set_procedure_threshold, which enforces only that the new value does not exceed num_approvers. It does not require the new value to be greater than or equal to the current override, default_threshold, or the value being changed, so any existing override can be rewritten downward. Unless the deployer has explicitly configured an override for set_procedure_threshold itself, calling it contributes only default_threshold to the computed transaction threshold, meaning a coalition of exactly default_threshold approvers is authorized to call it.
This enables a two-transaction bypass of any override set above default_threshold. In the first transaction, the coalition calls set_procedure_threshold to lower the target procedure's override down to default_threshold. Because the threshold computation reads the pre-transaction snapshot, the lowered value cannot take effect until a subsequent transaction. In the second transaction, the coalition calls the now-weakened target procedure. As a result, per-procedure overrides above default_threshold protect only against coalitions smaller than default_threshold; a default_threshold-sized coalition can defeat any higher override in two transactions. The impact is most pronounced under the documented default_threshold of 1, where a single approver can dismantle every stricter override. The update_signers_and_threshold procedure belongs to the same class, as it rewrites default_threshold and the approver set while also being gated at its own override or, absent one, default_threshold.
Consider documenting explicitly that to make an override sticky the deployer must also set an equal or greater override on both set_procedure_threshold and update_signers_and_threshold.
Update: Resolved in pull request #3246 at commit b5d0d89 and at commit 67c21ec.
RBAC Owner Has Unconditional Super-Admin Authority Over All Roles
The rbac component builds on top of ownable2step and treats the Ownable2Step owner as the top-level authority over the entire role graph. The owner is the only account that can set delegated role admins via set_role_admin, and the owner unconditionally passes the authorization gate in assert_sender_is_owner_or_role_admin, so the owner can grant and revoke every role regardless of any delegation. This couples ownership to unlimited authority over all roles, whereas ownership is often intended to be a scoped capability like any other, and it conflicts with the separation-of-duties model that role-based access control exists to provide.
There is no per-role mechanism to opt the owner out of a given role's administration. Even after a role's admin is delegated to another role via set_role_admin, the owner retains full grant and revoke rights over it, so a role such as a token issuer that mints supply cannot be placed exclusively under a dedicated financial-admin role and kept out of reach of a more general owner key. The only way to remove the owner's authority is to renounce ownership entirely, which then permanently disables set_role_admin and bricks any role that has no delegated admin. As a consequence, the compromise of a single owner key confers control over every role at once, defeating the isolation that motivates deploying RBAC in the first place.
Consider modeling the root authority as a role itself, in the manner of the OpenZeppelin AccessControl DEFAULT_ADMIN_ROLE, with per-role admins that can be repointed independently, rather than hard-wiring an external owner above the role graph. This would allow each role's administration to be scoped to a specific trusted role and would remove the single unconditional super-admin key from the design.
Update: Resolved in pull request #3215 at commit 55238ef and at commit b37277e.
Duplicate Procedure Roots in AccountCode Can Brick Accounts and Weaken Authentication Policies
The uniqueness of an account's procedure roots is enforced in only one place: AccountProcedureBuilder::add_procedure, which is reachable exclusively through the from-components construction path. The lower-level constructors do not enforce it. AccountCode::from_parts validates only the procedure count, and Deserializable::read_from validates only the count and that each root exists in the MAST forest. On the kernel side, save_account_procedure_data hashes the procedure table against the account code commitment but likewise performs no uniqueness check. As a result, a client that does not use the standard builder can construct, serialize, and commit an AccountCode whose procedure list repeats a root, including the authentication procedure root appearing both at index 0 and at some other index N.
Procedure call tracking is per index. assert_auth_procedure sets the tracking flag at the hardcoded index 0, while was_procedure_called reads the flag at an index supplied by the host through the advice provider, asserting only that the stored root at that index equals the queried root. When a root is duplicated, both indices satisfy this assertion, so the host is free to resolve the query to either one. The tx_policy procedure assert_only_one_non_auth_procedure_called iterates over every procedure index, counts those for which was_procedure_called returns true and the index is not 0, and asserts the count equals 1. For an account whose auth root is duplicated at index 0 and index N, this loop queries the auth root twice. If the host resolves the index-N query to index 0, where the flag is set, the duplicate is counted as a called non-auth procedure. A transaction that legitimately calls exactly one procedure then produces a count of 2 and reverts, leaving the account unable to execute any state-changing transaction. Because the resolved index is host-controlled, the same freedom allows the count to be held at 1 when no genuine procedure ran, weakening the intended "exactly one non-auth procedure" guarantee. More generally, duplicating any procedure's root allows its tracking flag to be read from an unset duplicate index, concealing whether that procedure was called and defeating any authentication gate of the form "if this procedure was called, require a signature".
The same index freedom also defeats the kernel's own out-of-context guard. The epilogue's execute_auth_procedure asserts that the authentication procedure was not already called during execution, which prevents a caller from invoking the authentication procedure out of context. When the authentication root is duplicated at a non-auth index, a prover can invoke the authentication procedure early under the duplicate index and have the epilogue read index 0, bypassing ERR_EPILOGUE_AUTH_PROCEDURE_CALLED_FROM_WRONG_CONTEXT.
These effects are reachable only for accounts whose committed procedure table actually contains duplicate roots. Because the table is bound by the code commitment, a prover cannot inject duplicates into an honestly built account, so the exposure is an account deliberately or accidentally constructed off the canonical path: for example, a co-owned, faucet, or network account whose constructor embeds a duplicated privileged or authentication root as a backdoor against the parties relying on its policy, or custom account-building tooling that emits duplicate roots and silently disables call tracking. A counterparty that recomputes the code commitment from the agreed component set, rather than trusting a handed-over serialized AccountCode, will observe a mismatched account identifier and can detect such a backdoor.
Consider enforcing procedure root uniqueness wherever an account procedure table is built from untrusted input or committed, in particular in AccountCode::from_parts, Deserializable::read_from, and the kernel's save_account_procedure_data, rejecting any code whose procedure list contains a repeated root. This extends the invariant currently guaranteed only by AccountProcedureBuilder to every committed AccountCode, so the epilogue guard, tx_policy, and the kernel call-tracking logic can rely on it.
Update: Partially Resolved in pull request #3246 at commit c322f73. The uniqueness check was added only in the Rust library. The Miden team created a GitHub issue to track and potentially introduce the uniqueness check also in the kernel. While the current fix alleviates accidental duplicate procedure roots, the uniqueness check in the kernel would completely eliminate it.
cleanup_pubkey_and_scheme_id_mapping Trusts Unvalidated Approver Counts and Omits Configuration Reconciliation
The cleanup_pubkey_and_scheme_id_mapping procedure in multisig.masm is declared pub, making it a reusable entry point for any module that links against the miden::standards::auth::multisig library. It clears the approver entries in the range [new_num_of_approvers, init_num_of_approvers) from the APPROVER_PUBLIC_KEYS_SLOT and APPROVER_SCHEME_ID_SLOT maps, but it performs no validation beyond asserting that its two arguments are u32 values, and it does not modify THRESHOLD_CONFIG_SLOT or PROC_THRESHOLD_ROOTS_SLOT.
Its correctness therefore depends entirely on an undocumented caller contract: init_num_of_approvers must equal the previously committed approver count, and the threshold configuration must have already been updated to a value that remains reachable with the reduced signer set. Within the library these guarantees are supplied only by update_signers_and_threshold, which reads the previous count from storage, asserts that the threshold does not exceed the new approver count and that existing procedure thresholds remain reachable, and only then invokes the cleanup routine. None of these constraints are enforced or documented by cleanup_pubkey_and_scheme_id_mapping itself; its documentation describes only the meaning of the two arguments, not the trust placed in them.
As a result, a downstream component that reuses this procedure without correspondingly updating the threshold configuration can leave the multisig in an inconsistent state where the stored configuration still claims more approvers than remain in the maps and the required threshold exceeds the number of available signers. In that state no set of signatures can satisfy the authentication check, rendering the account permanently unusable. Because the procedure belongs to a shared library intended for reuse, this footgun is exposed to any consumer that does not replicate the invariant checks currently implemented only in update_signers_and_threshold.
Consider validating the inputs directly within cleanup_pubkey_and_scheme_id_mapping. Alternatively, if the procedure is intended solely as an internal helper, consider restricting its visibility and documenting the caller contract along with the storage invariants it assumes.
Update: Partially Resolved in pull request #3211 at commit 690e3d4 and at commit 220e61f by extending the documentation of the process.
AuthSingleSigAcl Nonce Can Be Advanced Without a Signature, Invalidating Pre-Signed Transactions
The AuthSingleSigAcl component allows certain transactions to execute without a signature. When none of the auth-triggering conditions are met, meaning no trigger procedure was called, no output notes were created while allow_unauthorized_output_notes is false, and no input notes were consumed while allow_unauthorized_input_notes is false, auth_tx_acl falls back to a no_auth-style path that only increments the account nonce, doing so whenever the account state has changed.
The nonce is bound into the message the owner signs on the authenticated path. In authenticate_transaction, incr_nonce is invoked before the transaction summary is computed, and the resulting final_nonce is folded into the SALT word that is hashed into the signed message. A signature is therefore valid only for the specific final_nonce in effect when the transaction executes. Any transaction that reaches the unauthenticated path and changes the account state advances the nonce without a signature, which invalidates any transaction the owner pre-signed against the prior expected final_nonce.
For a public AuthSingleSigAcl account configured with allow_unauthorized_input_notes set to true, any party can create a note whose script modifies the account vault and consume it against the account without owner participation, advancing the nonce. For a note to modify the vault it must invoke a native account procedure that accepts funds, such as receive_asset, and that procedure must not be listed as a trigger procedure, since a trigger procedure would force authentication. An account that exposes a permissionless asset-receiving procedure alongside signature-gated administrative procedures is a legitimate and intended configuration: the owner wants assets to arrive freely while restricting policy changes and other operations to signed transactions. In this configuration, any attacker able to create and prove a note against the account can repeatedly bump the nonce, invalidating pre-signed transactions on demand and preventing the owner from relying on offline signing.
Consider requiring authentication for any input note consumption that modifies account state.
Update: Acknowledged, will resolve. Miden team has added a GitHub issue to track it.
Notes & Additional Information
Mutability Config Read Directly Instead of Through the Exported Getter
The is_max_supply_mutable_internal procedure reads the mutability config slot directly (push.MUTABILITY_CONFIG_SLOT[0..2] then get_item), duplicating the exported get_mutability_config_word getter, which performs the identical read. This couples fungible.masm to the slot constant and storage layout that mod.masm already encapsulates.
Consider invoking get_mutability_config_word instead of accessing the storage slot directly.
Update: Resolved in pull request #3120 at commit 995568d.
Uninitialized Active Mint or Burn Policy Root Bricks Minting and Burning
The execute_mint_policy and execute_burn_policy helpers dispatch the active policy root with dynexec, trusting it was validated when configured. That validation (non-zero, a procedure of the account, present in the allowed-roots map) only runs in set_mint_policy / set_burn_policy, not on the initial value, and the Rust builder does not fill the gap: TokenPolicyManager defaults every active root to the zero word, with_mint_policy / with_burn_policy only reject a second active policy per kind and never require one, and create_fungible_faucet forwards the manager unchecked. A faucet built with no active mint or burn policy therefore assembles with the active_*_policy_proc_root slot at the zero word, and the first mint or burn hands dynexec that zero word and aborts with a procedure-not-found error.
How recoverable this is depends on the Reserved registration. A reserved policy is installed and added to the allowed-roots map without being made active, so a kind that has a reserved but no active policy can still be enabled after deployment by calling set_*_policy to promote it: it is disabled until activated, not bricked. Only a kind with neither an active nor a reserved policy is permanently unusable, since the allowed map is empty and no valid root can ever be set. Separately, the execute helpers' doc comments assert the active root is validated "at config time (in set_mint_policy and on initial storage construction)"; the second clause is inaccurate, since construction writes the unvalidated, possibly zero, root.
Consider rejecting, when the manager is converted into components (or in create_fungible_faucet), any kind that has neither an active nor a reserved policy, since the active_*_policy() accessors and the allowed-roots map already expose this and reserved-only configurations can be left to set_*_policy. Alternatively, consider adding a zero-root guard in the execute helpers with a descriptive error. At minimum, consider correcting the doc comments so they do not claim the seeded active root is validated at construction time.
Update: Resolved in pull request #3121 at commit 1a16a9d. A zero-root guard with a descriptive error was added in the execute helpers.
transfer_ownership Contains Redundant Stack Operations in the Cancellation Path
In ownable2step.masm, the transfer_ownership procedure handles ownership cancellation when the caller passes the zero address (0, 0) by dropping both zero felts from the operand stack and then reconstructing equivalent zeros via push.0.0 movup.3 movup.3 before invoking save_ownership_info. Because the cancellation branch is entered only when both input felts are already confirmed to be zero, discarding and re-pushing them serves no purpose. In addition, each branch separately executes get_owner_internal before the shared save_ownership_info call, duplicating the owner load across both paths.
Consider simplifying the branch so that if.false guards only the account_id::validate call, with get_owner_internal and save_ownership_info invoked unconditionally afterward to eliminate the redundant stack operations and duplicated owner load.
Update: Resolved in pull request #3088 at commit b07bbd4.
RBAC Membership Writes Use Three movup.6 Stack Operations Where a Single swapw Suffices
Both grant_role_internal and revoke_role_internal push the membership value word onto the stack and then reorder it with three consecutive movup.6 operations, in order to position the membership map key tail above the value word before calling set_map_item. The membership value word and the four felts that must precede it occupy adjacent words on the stack, so the same arrangement can be produced by pushing the leading key felt before the value word and swapping the two words with a single swapw.
Replacing the sequence push.SET_MEMBERSHIP, movup.6 movup.6 movup.6, push.0 with push.0, push.SET_MEMBERSHIP, swapw (and the analogous change using CLEAR_MEMBERSHIP in revoke_role_internal) yields an identical stack for set_map_item while replacing three stack-movement operations with one, saving two VM cycles per call in each procedure.
Consider pushing the leading key felt before the membership value word and using a single swapw to align the two words, removing the three movup.6 operations in both grant_role_internal and revoke_role_internal.
Update: Resolved in pull request #3090 at commit 2266fec and at commit 9259abe.
renounce_ownership Cannot Be Called While an Ownership Transfer Is Pending
In the renounce_ownership procedure, after confirming the caller is the current owner, the loaded nominated owner is required to be zero, and the call reverts with ERR_OWNERSHIP_TRANSFER_IN_PROGRESS if a nomination is pending. This forces the owner to first cancel an in-progress transfer before renouncing. A nominated owner gains no privileges until it accepts the transfer through accept_ownership, so renouncing while a nomination is pending only discards a not-yet-effective nomination and leaves the component ownerless as intended. This also diverges from the OpenZeppelin Ownable2Step pattern, in which renounceOwnership clears any pending owner and proceeds without restriction.
Consider removing the nominated owner check so that renounce_ownership can proceed regardless of a pending nomination, clearing the nomination as part of the operation, consistent with the OpenZeppelin Ownable2Step pattern.
Update: Resolved in pull request #3170 at commit 8c2524d.
Inconsistent Procedure Ordering Convention Across Access Control Standards
In ownable2step.masm, private procedures are declared before public ones, whereas rbac.masm uses the reverse order, with public procedures appearing first. A related inconsistency exists within rbac.masm itself: the private setter set_role_config is placed between the getters get_role_config and has_role_internal, while the other internal setters grant_role_internal and revoke_role_internal are grouped at the end of the file.
Consider adopting and documenting a single procedure ordering convention across all standards modules, so that the public interface of each component is presented uniformly.
Update: Resolved in pull request #3205 at commit 42d50d6.
Factory-Level NoAuth Composition Guardrails Can Be Bypassed Through AccountBuilder
The standards crate exposes factory functions that assemble vetted account configurations. The create_basic_wallet factory rejects the NoAuth authentication method outright with UnsupportedAuthMethod, and build_auth_component, used by create_fungible_faucet, rejects the AccessControl::AuthControlled plus NoAuth pairing with IncompatibleAuthControlledAuth. The latter is rejected because under AuthControlled the account-level auth component is the sole gate for authority-gated setters, so pairing it with NoAuth would leave every setter permissionless.
These guardrails live only inside the factory functions. NoAuth is a public type and its From<NoAuth> conversion into an AccountComponent is unconditional, while AccountBuilder::with_auth_component accepts any component that satisfies the purely structural check in add_auth_component, namely exactly one procedure marked @auth_script placed at index 0. As a result, an integrator can hand-compose the exact configuration the factories reject, such as with_auth_component(NoAuth).with_component(FungibleFaucet) together with AccessControl::AuthControlled, or with_auth_component(NoAuth).with_component(BasicWallet), and obtain a permissionless asset-custodian account. The repository test public_account_without_allowlist_is_not_a_network_account confirms that with_auth_component(NoAuth).with_component(BasicWallet) builds successfully. This is not a silent default, since the unsafe configuration requires an explicit NoAuth token, and it grants no privilege escalation, but the safe-composition contract enforced by the factories does not hold at the builder layer.
Consider enforcing the semantic auth-component compatibility checks at the builder or AccountCode layer rather than only inside the factory functions, so that unsafe combinations such as NoAuth under AuthControlled are rejected regardless of construction path. If enforcement at that layer is not desirable because the builder is intended as an unopinionated low-level primitive, consider documenting the hazard explicitly on with_auth_component.
Update: Resolved in pull request #3246 at commit 379e419.
Storage Slot Name AUTHORITY_SLOT Omits the Variable Segment Used by Every Other Standards Slot
Every storage slot name in the standards library follows the convention <module_path>::<variable_name>, including single-slot components such as pausable (...::pausable::is_paused). The AUTHORITY_SLOT constant is the sole exception, naming its slot miden::standards::access::authority with no variable segment. This has no security impact, since collision detection keys on the exact unique string, but it deviates from the library-wide naming scheme.
Consider renaming the slot to follow the convention, for example miden::standards::access::authority::authority_config.
Update: Resolved in pull request #3209 at commit 1320e09.
accept_ownership Performs a Redundant Nomination Check and Post-Validation Stack Reordering
In the accept_ownership procedure, the loaded nominated owner is first asserted to be non-zero to confirm that a transfer is in progress, using a dup.1 eq.0 dup.1 eq.0 and and assertz.err=ERR_NO_NOMINATED_OWNER sequence, and the cleared nominated owner value is only appended after the sender validation completes, requiring a push.0.0 followed by two movup.3 operations to reorder the stack into the ownership word expected by save_ownership_info. Both steps are avoidable. Because every valid account ID carries version one and therefore has a non-zero prefix, as enforced by account_id::validate, the note sender can never equal an unset, zero-valued nominated owner, so the explicit zero-check is redundant: an absent nomination is already rejected by the subsequent ERR_SENDER_NOT_NOMINATED_OWNER comparison. Furthermore, because the cleared nominated owner is always the constant zero pair, it can be placed on the stack at the start of the procedure so that the final ownership word is assembled in the correct order as the nominated owner is loaded and validated.
Consider pushing the cleared nominated owner value before loading the nominated owner, duplicating the nominated owner felts where needed for the sender comparison, and removing both the redundant ERR_NO_NOMINATED_OWNER zero-check and the trailing push.0.0 and movup.3 reordering operations. Note that removing the zero-check changes the error returned when no transfer is nominated from ERR_NO_NOMINATED_OWNER to ERR_SENDER_NOT_NOMINATED_OWNER; consider retaining the distinct error if differentiating these two failure cases is preferred.
Update: Resolved in pull request #3170 at commit 1b096ae and in pull request #3416.
Authority Discriminant Defined as Untyped Constants Instead of an Enum
The authority kind that assert_authorized branches on is declared in authority.masm as three independent const values, even though it mirrors the #[repr(u8)] Rust enum Authority and the Miden Assembly version in use supports a first-class enum construct.
Consider defining the authority kind as a MASM enum with a u8 representation whose variants mirror Authority, so the valid set is expressed once as a named type rather than as loose constants.
Update: Resolved in pull request #3209 at commit 733a00d.
Authority::try_from Does Not Reject Non-Canonical Storage Words
The From<Authority> for Word conversion always produces a canonical word: [authority, 0, 0, 0] for AuthControlled and OwnerControlled, and [authority, role_symbol, 0, 0] for RbacControlled. The reverse parser, TryFrom<Word> for Authority, does not enforce this canonical form. For the AuthControlled and OwnerControlled discriminants it ignores word[1], word[2], and word[3], and for RbacControlled it validates word[1] as a RoleSymbol but ignores word[2] and word[3]. As a result, a non-canonical authority word constructed outside the From path, for example by assembling the storage slot directly with StorageSlot::with_value, is parsed without surfacing the unexpected trailing data, and the invariant documented in assert_authorized (that the role symbol felt is 0 unless the authority is RbacControlled) is enforced only on the write path rather than on read.
The practical impact is limited because the MASM consumer likewise reads only word[0], and word[1] for the RBAC branch, and discards the remaining felts, so the lax parsing does not currently create a divergence between the Rust and MASM views of the authority configuration.
Consider validating the full word in TryFrom<Word> for Authority by requiring the unused felts to be zero (word[1] through word[3] for AuthControlled and OwnerControlled, and word[2] and word[3] for RbacControlled) and returning an error otherwise, so that the canonical encoding invariant is enforced on read as well as on write.
Update: Resolved in pull request #3209 at commit 4c6cd1f and in pull request #3415.
Redundant and Unused Code in signature.masm
The signature.masm auth standard contains several fragments of redundant or dead code. The following observations were identified:
- The
authenticate_transactionprocedure validates the signature scheme twice. It callsassert_supported_schemeonscheme_idand then immediately callsverify_signature_by_scheme, whose dispatch already traps withERR_INVALID_SCHEME_IDon any value other than1or2. Both checks accept exactly the set{1, 2}and abort with the identical error, and thedup.0 exec.assert_supported_schemecall is stack-neutral, so it is fully redundant. The multisig path inverify_signaturesalready relies solely on the internal trap, so maintaining two independent definitions of the supported-scheme set introduces a risk that they diverge. - Inside
verify_signature_by_scheme, the first branch duplicatesscheme_idso the original value survives into theelsepath, but within that pathscheme_idis no longer needed by any subsequent branch. The second scheme check still duplicatesscheme_idbefore its equality test, leaving a redundant copy that must be immediately dropped in the true branch, while the false branch panics and never uses the value. - The
assert_supported_scheme_wordprocedure validates that the three trailing felts of a scheme-ID word are zero by prefixing eachassertzwithneq.0. Sinceassertzalready asserts that the top stack element equals zero, theneq.0resolves to a double negation that produces the identical boolean, leaving it functionally redundant while adding two extra virtual machine cycles per element, six in total, without changing the pass or fail condition or the emittedERR_INVALID_SCHEME_ID_WORDerror. - The
verify_signaturesprocedure declares@locals(18), but theNUM_OF_APPROVERS_LOCconstant mapped to local offset0is never read or written anywhere in the crate, unlike the surrounding local-memory constants, leaving the first local slot permanently unused. The number of approvers is instead kept on the operand stack as the loop counter, so the constant appears to be a remnant of an earlier implementation.
Consider fixing the mentioned instances.
Update: Resolved in pull request #3230 at commit d5731b4 and at commit 162247e.
Multiple Cycle-Cost Inefficiencies in auth_tx_acl
The auth_tx_acl procedure determines whether signature verification is required by combining a trigger-procedure ACL check with output- and input-note checks. Several portions perform work that does not affect the result. None of the items below alters the behavior of the procedure; each only reduces VM cycles.
The following inefficiencies are present:
- The configuration setup discards the zero at position 3 of the word returned by
get_initial_itemwithmovup.3 drop, then recreates it withpush.0to initializerequire_acl_auth. Usingmovdn.3to sinknum_auth_trigger_procsbelow the existing zero would allow the two configuration values to be stored directly withloc_storeand would reuse the existing zero as the initialrequire_acl_auth, removing bothmovup.3 dropand the laterpush.0. - The
allow_unauthorized_output_notesandallow_unauthorized_input_notesflags are written to local memory and read back vialoc_load. Both values originate on the stack, and no control flow between the store and the load makes retaining them on the stack impractical, so the local-memory round trip can be avoided. - Within the ACL loop, the value
i-1is computed twice per iteration: once viadup.4 sub.1to build the map key and once viaswap sub.1 swapto decrement the counter. Decrementingionce at the top of the loop body and reusing the result for both purposes would remove the secondsub.1and the two surroundingswapoperations. - The same loop accumulates
require_acl_authviaoracross all configured trigger procedures, but its condition tests onlyi != 0and therefore always runs every entry. Oncerequire_acl_authbecomes1, the result is fixed, so extending the condition to also requirerequire_acl_auth == 0would allow the loop to exit as soon as a match is found. - The ACL loop, the output-note check, and the input-note check all execute unconditionally and accumulate into
auth_requiredviaor. Once any one of them determines that authentication is required, the remaining checks are wasted work. Ordering the checks from cheapest to most expensive and nesting each in a conditional gated onauth_requiredstill being false, with the ACL loop deferred to last, would allow later checks to be skipped once authentication is known to be required. - The
get_num_output_notesandget_num_input_notessyscalls execute on every transaction, but their results are discarded when the correspondingallow_unauthorized_*flag is set, since the flag is consulted only afterward. As the flags are available without a syscall, each call could be gated on its flag so that the syscall is performed only when it can change the outcome.
Consider applying these optimizations since the note-check items share the same region and the placement of the allow_unauthorized_* flags affects several of them. None of the changes alters the authentication outcome.
Update: Resolved in pull request #3065 at commit 4a71974 and pull request #3206 at commit c8455ae.
accept_ownership Can Promote a Nominee When owner Is Zero
The accept_ownership procedure promotes the nominated owner to owner after asserting only that a nomination exists, through the non-zero nominated_owner check guarded by ERR_NO_NOMINATED_OWNER, and that the note sender equals the nominee, guarded by ERR_SENDER_NOT_NOMINATED_OWNER. It never reads or asserts on the current owner field. As a consequence, if storage ever held owner equal to (0, 0) while nominated_owner was non-zero, the nominee could call accept_ownership and become the owner of an otherwise ownerless component.
This combination of storage values is not reachable through the on-chain transition functions. The save_ownership_info procedure is the sole writer of the ownership state and is invoked only by the sender-gated transfer_ownership, accept_ownership, and renounce_ownership procedures, and renounce_ownership zeroes the owner field only after asserting that no nomination is pending. The owner = (0, 0) and nominated_owner non-zero state is therefore reachable only through a direct raw-storage write at account construction or through a future storage-migration or bulk-import path. The issue is consequently raised as a defense-in-depth hardening measure rather than an exploitable condition.
Consider adding an assertion in accept_ownership that the current owner is non-zero before promotion, mirroring the existing nomination guard.
Update: Resolved in pull request #3170 at commit 373a398.
Redundant Per-Iteration Memory and Storage Traffic in verify_signatures
The verify_signatures procedure loops once per approver to validate multisig-style authentication. Within each iteration it repeats work whose result is either constant for the entire transaction or already available on the operand stack, so the work does not contribute to the result. The combined overhead scales linearly with the number of approvers and increases the proving cost of authentication.
First, each iteration issues two separate active_account::get_initial_map_item calls, one for the approver public key and one for its scheme identifier. Each call is a kernel syscall that re-resolves the storage slot by name, validates the slot type, hashes the key, and walks the storage map tree. Both read the initial storage state, which is fixed for the duration of the transaction, so the roots of the two storage maps are constant across every iteration of the loop yet are re-resolved for each signer.
Second, the fetched public key is written to local memory at CURRENT_PK_LOC and reloaded later within the same iteration, even though the value is still available on the operand stack. Similarly, the current signer index i-1 is written to SIGNER_INDEX_LOC at the top of each iteration and read back to rebuild the approver map key. At that load site the stack holds [PUB_KEY, MSG, MSG, i-1], so the same i-1 value is already present at stack depth 12 and could be obtained with dup.12. The index is additionally retained on the stack as the loop counter throughout the iteration, so the dedicated local is not required at all. Both values therefore incur a store and a load per iteration that produce no result not already on the stack.
Consider reading the two storage map roots once before the loop and resolving each signer's public key and scheme identifier by querying the storage map tree directly against those cached roots, so that the per-iteration kernel syscalls are eliminated. To preserve the current fail-closed behavior, where a non-map slot causes the transaction to abort, consider validating that each slot is a map exactly once before the loop rather than relying on the per-lookup type check. Consider also reusing the public key already present on the operand stack instead of round-tripping it through CURRENT_PK_LOC, and eliminating the SIGNER_INDEX_LOC store and load by duplicating the signer index from the stack where it is needed, which frees the local slot.
Update: Resolved in pull request #3230 at commit 9416eb0. OpenZeppelin team stated:
The finding's two halves: (1) memory round-trips + unused locals, and (2) two
get_initial_map_itemsyscalls re-resolving constant storage-map roots every loop iteration. Only half (1) was done. The core recommendation — cache the two roots once before the loop — was not implemented; both per-iteration syscalls remain, and that was the larger cost.
The following clarification has been provided:
We’ve intentionally left the second part as not implemented. Working into the second half, the per-iteration syscalls repeat work "that does not contribute to the result" is only partly accurate.
get_initial_map_itemdoes more than resolve the slot and walk the tree. Before thesmt::get, the kernel emitsACCOUNT_STORAGE_BEFORE_GET_MAP_ITEM_EVENT, and the host handles it by lazily injecting the Merkle witness for that specific(slot, key)into the advice provider (for the native account, against the initial root). Those witnesses aren't present up front, they're provisioned on demand, per(slot, key), and the handler needs the kernelslot_ptr, which account/standards code can't obtain.That's why the suggested approach can't work from this layer: caching the root is fine, but the tree data under it isn't in the advice provider unless the syscall's event puts it there, so a direct
smt::gethas nothing to read. The bulk of each syscall's cost, witness provisioning plus the lookup, is essential. The only redundant per-iteration work is the slot-ID resolution and the map-type assert, and both live inside the syscall.So a real optimization has to be kernel-side: a proc that resolves the slot once and batch-provisions the witnesses for all signer keys against the initial root, letting the loop do cheap
smt::getcalls with no per-iteration syscall. That's a change tomiden-protocol(new kernel proc + host event handling + offset wiring), a separate, larger work outside of this issue.
Inaccurate Comments in Authentication Component Procedures
Several authentication component procedures carry inline comments and procedure-level documentation that do not match the executable code. None of the discrepancies below affects execution; each only risks misleading a future maintainer who relies on the comments to understand the code.
The following inaccuracies are present:
- In
auth_tx_acl, the comment following the call toget_num_input_notesannotates the return value asINPUT_NOTES_COMMITMENT, which is a word. Theget_num_input_notesprocedure returns a single field element holding the input-note count, so the comment should read[num_input_notes, auth_required, pad(16)]. - In the same procedure, the comment following
push.AUTH_TRIGGER_PROCS_MAP_SLOT[0..2]documents the slot identifier as[trigger_proc_slot_prefix, trigger_proc_slot_suffix, ...], butpush.SLOT[0..2]leaves the suffix felt on top, so the real layout is suffix-first. This matches the input ordering expected byget_initial_map_itemand the suffix-first convention used elsewhere. The same prefix-first reversal appears in the slot-identifier comments ofguardian.masm. - The
auth_no_authandauth_network_transactionprocedures document theirInputsandOutputsas[pad(16)], but the transaction kernel invokes authentication procedures viadyncallwith the stack laid out as[AUTH_ARGS, pad(12)], where the top word is the caller-suppliedAUTH_ARGS. Theauth_no_authprocedure passes that word through unconsumed, so its true output is[AUTH_ARGS, pad(12)], whileauth_network_transactionconsumes it withdropw. Both diverge from the correctly documentedauth_txidiom insinglesig.masm. - The file header and a procedure comment of
singlesig_acl.masmdescribe the component as ECDSA-only, stating "The MASM code of the ECDSA K256 Keccak authentication Account Component with ACL" and "standard EcdsaK256Keccak signature verification is performed". TheAuthSingleSigAclcomponent is signature-scheme agnostic: it reads the scheme identifier fromSCHEME_ID_SLOTand passes it tosignature::authenticate_transaction, which dispatches to eitherecdsa_k256_keccakorfalcon512_poseidon2, as the same procedure's own documentation ("1 => ecdsa_k256_keccak", "2 => falcon512_poseidon2") and the Rust type both state. The two comments omit the Falcon-512 scheme the component fully supports. - The signature authentication procedures label the operand-stack input to
verify_signature_by_schemeandauthenticate_transactionasPUB_KEY, and theAuthSingleSigcomponents carry the samePUB_KEYlabel when reading the value from storage. In every case the value supplied is the Poseidon2 commitment to the public key rather than the raw public key: it originates from aPublicKeyCommitmentstored in the public key slot or the approver storage map, and bothecdsa_k256_keccak::verifyandfalcon512_poseidon2::verifyexpect the commitment on the operand stack while the raw key material is provided separately on the advice stack. The behavior is correct, but thePUB_KEYnaming diverges from the underlyingecdsa_k256_keccak::verifycontract, which names the same inputPK_COMM, and may lead readers to assume the raw key is passed on the operand stack. - In
assert_only_one_non_auth_procedure_calledoftx_policy.masm, two stack-layout comments inside the loop body omit theproc_indexelement that persists at the bottom of the stack throughout each iteration. The comment afterget_procedure_root dupwreads[PROC_ROOT, PROC_ROOT]when the stack is[PROC_ROOT, PROC_ROOT, proc_index], and the comment afterwas_procedure_calledreads[was_called, PROC_ROOT]when the stack is[was_called, PROC_ROOT, proc_index].proc_indexis placed on the stack by the precedingdupand consumed only after the loop exits, so both comments understate the stack depth by one element.
Consider correcting these comments and procedure-level documentation to match the executable code.
Update: Resolved in pull request #3211 at commit e8f3a20 and in pull request #3246 at commit a0f7d39.
AuthSingleSig::new Accepts an Inconsistent Public Key Commitment and Signature Scheme
The AuthSingleSig::new constructor stores the pub_key commitment and the auth_scheme as independent arguments without verifying that the commitment was derived under the chosen scheme. The typed constructors falcon512_poseidon2, ecdsa_k256_keccak, and from_public_key always derive both fields from a single key and therefore remain consistent, but new allows a caller to pair, for example, a Falcon512 Poseidon2 commitment with EcdsaK256Keccak. During authentication, verify_signature_by_scheme dispatches on the stored scheme identifier alone and requires the advice-provided public key to hash to the stored commitment under that scheme's own hash function (Keccak for ECDSA, Poseidon2 for Falcon). A commitment produced under one hash can never be reproduced by a key under the other, so no signature can satisfy authentication and the account becomes permanently unauthenticatable. This is a self-inflicted misconfiguration available only to the deploying party at construction time, not an externally exploitable condition.
Consider validating in new that the supplied commitment is consistent with the selected auth_scheme, or, if accepting a raw commitment without the originating key is intended, documenting the invariant that the commitment must be derived under the chosen scheme.
Update: Resolved in pull request #3246 at commit 3fe829f.
Unconditional Nonce Increment in Signature Authentication Lets Empty Transactions Incur Fees
The transaction kernel rejects any transaction that neither changes the account state nor consumes an input note, through the empty-transaction guard in finalize_transaction, which asserts on ERR_EPILOGUE_EXECUTED_TRANSACTION_IS_EMPTY when the account delta commitment is empty and no input notes were present. However, authenticate_transaction and the multisig authentication procedures (multisig, multisig_smart) increment the account nonce unconditionally. The nonce increment makes the account delta commitment non-empty, so the guard can never trigger for accounts using these procedures. As a result, a transaction that consumes no input notes, creates no output notes, and makes no vault or storage change still succeeds and causes the native account to pay a transaction fee, whereas the same transaction submitted by an auth_no_auth account, which increments the nonce only when the account state changes, would be rejected.
The impact is limited. A valid signature is required, so only the account owner can trigger this against their own account, and the owner commits to the resulting nonce through the signed transaction summary. The condition is therefore reachable only through a faulty client that assembles an otherwise-empty transaction, in which case the fee is charged silently rather than the transaction failing.
Consider incrementing the nonce only when the transaction performs an observable action, namely when the account state has changed, the account is being created, or the transaction consumes or produces at least one note. Alternatively, if the unconditional increment is intended as a simplification, consider documenting that signature and multisig accounts cannot rely on the kernel's empty-transaction guard.
Update: Acknowledged, will resolve. The team stated:
For the
AuthMultisigandAuthMultisigSmartcomponents the conditional-increment recommendation doesn't apply directly: every multisig transaction is finalized byrecord_and_assert_new_tx, which writes a replay-protection record to account storage after the auth procedure's nonce decision. That storage write means a multisig transaction is never actually empty and the kernel already requires the nonce to be incremented for it, so a conditional increment computed insideauth_tx(before the record is written) would just break otherwise-valid transactions withERR_ACCOUNT_PATCH_NONCE_MUST_BE_INCREMENTED_IF_VAULT_OR_STORAGE_CHANGED.Other than that, we’ve opened an issue related to this item, and will be tracking with Miden team: https://github.com/0xMiden/protocol/issues/3261
Multiple Cycle and Code-Size Optimizations in the RBAC Membership Write and Read Paths
The grant_role_internal and revoke_role_internal procedures, together with the read helpers they share, admit several independent but mutually interacting optimization opportunities.
First, both procedures perform a get_map_item read through has_role_internal before the unconditional set_map_item write. Because set_map_item returns the previous value as OLD_MEMBERSHIP_WORD, which is currently discarded, the prior membership state is available without the upfront read. In revoke_role_internal, the ERR_ACCOUNT_NOT_IN_ROLE assertion can be performed on OLD_MEMBERSHIP_WORD[0] after the write, where 1 indicates prior membership and 0 fails the assertion. In grant_role_internal, the already-granted check that gates the member-count increment is likewise recoverable from OLD_MEMBERSHIP_WORD[0], eliminating the get_map_item call entirely on the common path of granting a not-previously-held role.
Second, once the redundant read is removed, the two bodies remain near-identical, differing only in the value written (SET_MEMBERSHIP versus CLEAR_MEMBERSHIP) and whether the member count is incremented or decremented. This duplication enlarges the assembled MAST tree and requires the two membership-value constants to be maintained separately. The shared logic can be factored into a single helper parameterized by a binary flag, with the membership value built as [flag, 0, 0, 0] and the count adjustment selected branchlessly via cdrop to keep the procedure free of data-dependent control flow. This removes the SET_MEMBERSHIP and CLEAR_MEMBERSHIP constants and reduces the assembled standards library by two MAST nodes (816 to 814), while preserving the member-count overflow assertion on grant and the membership assertion on revoke.
Third, revoke_role_internal validates the account ID via account_id::validate before asserting membership, but this validation is unreachable as a failure path. The only writer of the membership map is grant_role_internal, which validates the account ID before writing, so every stored membership key necessarily carries an already-validated (account_suffix, account_prefix) pair. Any invalid account ID passed to revoke_role_internal therefore cannot match a stored key and fails the subsequent membership assertion with ERR_ACCOUNT_NOT_IN_ROLE regardless, so the validation never changes the revert outcome and only adds cycles. Even if an invalid account ID was granted a role during account creation, it is still safe to revoke that role.
Consider applying the optimizations described above to reduce execution cycles and assembled code size.
Update: Resolved in pull request #3215 at commit d300faa.
Misleading PUBLIC_KEY_SLOT Naming in singlesig_acl.masm Refers to a Public Key Commitment
In the AuthSingleSigAcl account component, the storage slot used during signature verification is declared in MASM as PUBLIC_KEY_SLOT, with a preceding comment stating that it is the slot "where the public key is stored", and the value read from it is annotated as PUB_KEY on the stack at the verification site. The value held in this slot is not a raw public key but a commitment to it: the Rust definition types the field as PublicKeyCommitment and the storage schema labels the slot "Public key commitment". This naming discrepancy may mislead readers into assuming the full public key is stored on-chain, which is impossible for the supported ECDSA-k256 and Falcon-512 schemes whose keys exceed a single word.
Consider renaming the constant and the associated stack annotations to reflect that the slot stores a public key commitment, and updating the accompanying comment to match the Rust PublicKeyCommitment terminology.
Update: Resolved in pull request #3246 at commit 2546618.
Multisig Authentication Failures Are Unattributable Under Delegated Proving
The multisig authentication component decides whether a given signer participated by consulting the advice provider through adv.has_mapkey, and only increments the verified-signature counter after an in-circuit signature check succeeds. Because the advice provider is host-controlled and its responses are not bound by any in-circuit commitment, the presence of a signature can be proven but its absence cannot. A 0 returned by adv.has_mapkey is therefore indistinguishable from a signer who genuinely did not sign, even when a valid signature for that signer was supplied to the host.
Under delegated proving, where the account owner hands the full witness including signatures to an external proving server that acts as the host, this allows a malicious or faulty prover to suppress signatures it actually holds, driving the verified count below the threshold and forcing the transaction to fail with insufficient number of signatures. Today this is harmless, because the failing assertion aborts the entire transaction, the epilogue never runs, and no fee is charged, so the worst outcome is a denial of service equivalent to the prover declining to prove at all. The concern is forward-looking. If a provable-failure mechanism is introduced that allows a failed transaction to be proven and charged a fee, this same path would let a delegated prover deduct fees from the account for a transaction that would have succeeded honestly, repeatedly and without detection, since the network cannot attribute the failure to either party. The AUTH_UNAUTHORIZED_EVENT already emitted on this path suggests instrumentation in this direction.
Consider ensuring that any future provable-failure or fee-on-failure mechanism excludes failures whose trigger depends on host-controlled non-determinism, in particular authentication failures gated on adv.has_mapkey. Fees should only be charged for failures that are deterministic in committed public inputs, so that a delegated prover cannot fabricate a chargeable failure from inputs the account owner provided correctly.
Update: Acknowledged, will resolve.
Minor Cycle-Cost Inefficiencies in the Multisig Auth Component
Three small inefficiencies were identified in the multisig authentication component. None of the items below alters the authentication outcome; each only reduces VM cycles.
The following inefficiencies are present:
assert_proc_thresholds_lte_num_approvers,compute_transaction_threshold, andupdate_signers_and_thresholdeach guard theirwhile.trueloop with an initialdup neq.0check on a value that is always non-zero. The first two iterate over the result ofget_num_procedures, which is always at leastMIN_NUM_PROCEDURES = 2. The third checksnum_approversafter it has already been asserted non-zero byERR_ZERO_IN_MULTISIG_CONFIG. Entering these loops unconditionally would remove cycles that never affect control flow.- In the scheme identifier extraction sequence of
get_signer_at, the procedure usesmovdn.3 drop drop drop movdn.4to isolatescheme_idand place it belowPUB_KEY. The trailingmovdn.4is unnecessary: replacing the initialmovdn.3withmovdn.7movesscheme_idpast the entirePUB_KEYword in one step, producing the same result in one fewer instruction. - In
update_signers_and_threshold,loc_load.NEW_NUM_OF_APPROVERS_LOCreloadsnew_num_approversfrom local memory at a point whereMULTISIG_CONFIG, laid out as[threshold, num_approvers, 0, 0], is still intact on the stack withnum_approversat index1. Replacing theloc_loadwithdup.1would read the value directly from the stack. The local remains necessary at its later use afterset_itemhas consumedMULTISIG_CONFIG.
Consider applying these optimizations, which reduce cycle costs without changing the authentication outcome.
Update: Resolved in pull request #3211 at commit f863f77.
Inaccurate Stack-Layout and Advice-Map Comments in multisig.masm
The multisig authentication component documents each procedure's stack transitions with inline # => comments and, for entry points, doc-comment descriptions of the operand stack and advice-map layout. These comments are the primary specification a reader or integrator relies on, and several of them misrepresent the data layout that the executable code actually depends on.
In assert_new_tx, the comment after push.IS_EXECUTED_FLAG describes the resulting word as [0, 0, 0, is_executed], and the comment on the value returned by set_map_item describes it the same way. Both place the flag felt in the last position of the word. However, IS_EXECUTED_FLAG is defined as [1, 0, 0, 0], so the flag occupies the first position. The code depends on this: movdn.3 drop drop drop keeps the first felt of the word and discards the other three, which is correct only because the flag sits at position 0. If the layout matched the comments, that sequence would discard the flag and retain a zero, and the ERR_TX_ALREADY_EXECUTED check would always pass, silently disabling replay protection. The code is correct and the comments invert the layout, but a future edit made to conform to the comments would break the check.
The doc comment for update_signers_and_threshold describes the advice-map value as the configuration followed by all public keys and then all scheme identifiers, grouped by type. The loop that consumes the value reads one public key and then its scheme identifier on each iteration, so the value must be laid out interleaved per signer. An integrator populating the advice map from the documented grouped layout would supply data that the procedure reads incorrectly.
In get_signer_at, the comments after push.APPROVER_PUBLIC_KEYS_SLOT[0..2] and push.APPROVER_SCHEME_ID_SLOT[0..2] label the pushed data with a single token, as [APPROVER_PUBLIC_KEYS_SLOT, APPROVER_MAP_KEY, index] and [APPROVER_SCHEME_ID_SLOT, APPROVER_MAP_KEY, PUB_KEY] respectively. Each push.SLOT[0..2] places two felts on the stack, the slot identifier's suffix and prefix, so the actual layout is [approver_scheme_id_slot_suffix, approver_scheme_id_slot_prefix, APPROVER_MAP_KEY, PUB_KEY] and the analogous four-element form for the public-keys slot. The same operation is documented correctly elsewhere in the file, for example in update_signers_and_threshold, where the comment expands the pushed felts into scheme_id_slot_suffix and scheme_id_slot_prefix. The get_signer_at comments collapse the two felts into one label and understate the stack depth by one element at each subsequent step of the procedure.
More broadly, the inline # => comments throughout the file routinely show fewer than sixteen stack elements, such as [pad(12)] or a bare [is_signer]. The Miden operand stack never holds fewer than sixteen elements: when the meaningful values fall below that count, the virtual machine keeps the depth at sixteen by padding the far end with zeros, and a call-invoked procedure always begins with a sixteen-element frame. The comments show only the meaningful top-of-stack values and omit this padding, so they understate the true stack depth at most steps and are unreliable as a precise stack specification.
Consider fixing the inaccurate comments described above so that they accurately describe the operand stack and advice-map layout the code operates on.
Update: Resolved in pull request #3211 at commit 273f8d4.
Per-Procedure Threshold Overrides Are Not Re-Evaluated When the Signer Set Grows
The update_signers_and_threshold procedure rewrites THRESHOLD_CONFIG_SLOT and the approver public-key and scheme-id maps, but it never modifies PROC_THRESHOLD_ROOTS_SLOT, where per-procedure threshold overrides are stored. Its only cross-check against those overrides is assert_proc_thresholds_lte_num_approvers, which enforces a single direction: that each override remains less than or equal to the new num_approvers, so it stays reachable. When the signer set shrinks, this correctly reverts any configuration that would leave an override unreachable. When the signer set grows, the check always passes and every existing override is preserved as an absolute count.
Because thresholds are absolute counts rather than ratios, growing the signer set silently lowers the effective signing ratio of every per-procedure policy. A procedure previously configured to require two of two signers becomes reachable by two of the new, larger set, without any signal to the caller and without re-evaluating whether the override still reflects the intended policy. The overrides can only be adjusted through separate set_procedure_threshold calls, so preserving the prior security level after a membership increase requires additional, explicit action that the update procedure neither performs nor prompts. The multisig_smart variant exhibits the same behavior through assert_proc_policies_lte_num_approvers.
Consider documenting that increasing the number of approvers does not re-scale existing per-procedure threshold overrides, and that operators should re-evaluate and, where appropriate, raise those overrides in the same transaction that grows the signer set.
Update: Resolved in pull request #3211 at commit 1a77fe5.
set_procedure_threshold Does Not Verify That the Procedure Root Belongs to the Account
The multisig auth component's set_procedure_threshold writes a per-procedure threshold override into the PROC_THRESHOLD_ROOTS_SLOT map keyed by PROC_ROOT, validating only the threshold value against num_approvers and never checking that PROC_ROOT is one of the account's procedures. Since both consumers of the map iterate over the account's real procedure set, an override stored under a foreign root is never read and cannot weaken the threshold policy, but the missing check lets misconfiguration pass silently.
Consider validating PROC_ROOT with active_account::has_procedure and asserting the result before writing the override.
Update: Resolved in pull request #3211 at commit 3207826 and in pull request #3246 at commit bb6f39c
Misleading assert_new_tx Procedure Name Hides Storage Mutation
The assert_new_tx procedure does more than its name implies. In addition to asserting that the transaction has not been executed, it writes the transaction summary commitment into the EXECUTED_TXS_SLOT map via exec.native_account::set_map_item before performing the assertion. The name reflects only the assertion and hides the storage mutation that actually provides replay protection.
Consider renaming the procedure to reflect both responsibilities, for example record_and_assert_new_tx, and documenting the storage write in its comment.
Update: Resolved in pull request #3211 at commit 196cc11.
allow_unauthorized_output_notes Cannot Authorize Otherwise-Empty Note-Creating Transactions
The auth_tx_acl procedure in singlesig_acl.masm allows an account to be configured to permit output-note creation without a signature through the allow_unauthorized_output_notes flag. When no authentication is required, the no-authentication branch increments the account nonce only if the account state changed or the account is new.
An output note that removes assets from the account vault changes the account state, so such a transaction increments the nonce and is accepted. An output note that carries no assets, for example a data-only note, does not change the account state. In that case the branch does not increment the nonce, the account delta is empty, and because no input notes were consumed the transaction is rejected by the kernel epilogue with ERR_EPILOGUE_EXECUTED_TRANSACTION_IS_EMPTY. The kernel deliberately does not treat output-note creation as a chain-state change. As a result, allow_unauthorized_output_notes does not cover the full range of transactions it appears to authorize: an unsigned transaction whose only effect is the creation of an asset-less note cannot be executed, and consuming an input note to make the transaction non-empty is unavailable when allow_unauthorized_input_notes is false. The transaction fails closed, so this is a functionality limitation rather than a security issue, and the same limitation applies to any authentication component that increments the nonce conditionally.
Consider including output-note creation in the nonce-increment condition of the no-authentication branch, so that a permitted unsigned transaction that creates one or more output notes increments the nonce and is accepted. To avoid weakening the kernel's rejection of empty transactions, the nonce should be incremented only when output notes were actually created, in addition to the existing account-state-change and new-account conditions, and never for a transaction that creates no notes and changes no state.
Update: Acknowledged, will resolve. The team stated:
This issue is opened against the repo: https://github.com/0xMiden/protocol/issues/3262
This issue is reported to the Miden team, and a similar issue is assigned to one of the members in the Miden team as well: https://github.com/0xMiden/protocol/issues/2964
Missing Advice Hash Verification in update_signers_and_threshold Is Safe but Undocumented
In several authentication components, update_signers_and_threshold loads signer configuration from the advice provider using adv.push_mapval and adv_loadw without verifying that the retrieved data matches an expected hash. The same pattern appears in multisig.masm and multisig_smart/mod.masm. At first glance, a malicious or faulty prover could supply an arbitrary configuration, substituting signer keys or thresholds, without being detected.
This concern does not materialize because every value written through set_item or set_map_item during the transaction is captured by update_storage_delta, which iterates all native account storage slots and folds changed values into ACCOUNT_DELTA_COMMITMENT. That commitment is included in the transaction summary that the current signers must sign. Any substitution of advice-supplied configuration produces a different storage delta, a different ACCOUNT_DELTA_COMMITMENT, and thus a different TX_SUMMARY_COMMITMENT, one that the current signers would have had to sign in advance. The absence of an inline hash check is therefore not a security gap; the entire account delta provides the integrity guarantee.
Consider adding an inline comment at each adv.push_mapval and adv_loadw call site explaining that hash verification is unnecessary because the written values are covered by ACCOUNT_DELTA_COMMITMENT, which is part of the signed transaction summary. This would help prevent future reviewers from flagging the same pattern as a missing security check.
Update: Resolved in pull request #3211 at commit 23f78ae.
Redundant Word Duplication in assert_only_one_non_auth_procedure_called
The assert_only_one_non_auth_procedure_called procedure iterates over the active account procedures and, for each one, fetches its root and checks whether it was called. Immediately after get_procedure_root, the procedure root is duplicated with dupw, leaving two copies of PROC_ROOT on the stack. The subsequent was_procedure_called call consumes only the top copy, and the surviving copy is never read: it is unconditionally discarded by dropw in both the if branch and the else branch.
Consider removing the dupw together with the paired dropw instructions in both branches, so that was_procedure_called consumes the single PROC_ROOT directly.
Update: Resolved in pull request #3211 at commit f92d4d4.
Unused Local Reservation in auth_tx
The auth_tx procedure in multisig.masm is annotated with @locals(1), reserving one word of procedure-local memory, but its body never issues any loc_store, loc_load, or locaddr operation. The reserved local is therefore never used, and the procedures invoked through exec maintain their own isolated local frames rather than relying on the caller's reservation.
Consider removing the @locals(1) annotation from auth_tx to avoid reserving local memory the procedure does not use.
Update: Resolved in pull request #3211 at commit 0b22e7a.
Multisig auth_tx Documentation Misattributes Replay Protection to SALT
The multisig authentication logic is split into two public procedures. auth_tx increments the nonce, computes the transaction summary message, verifies that the approver threshold is met, and returns the TX_SUMMARY_COMMITMENT without recording it. Replay protection is provided separately by assert_new_tx, which records the commitment in EXECUTED_TXS_SLOT and reverts if it has already been seen. The deployed component wrapper auth_tx_multisig invokes both in sequence, so the shipped configuration is protected. The caller-supplied SALT word is never checked for uniqueness; it only contributes to the signed message, computed as TX_SUMMARY_COMMITMENT = hash(ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT).
The auth_tx documentation states that SALT enables concurrent transactions "while maintaining replay protection" and that "each transaction must use a unique SALT value to ensure transaction uniqueness", and it does not mention that assert_new_tx must be called. This overstates the role of SALT and understates the role of assert_new_tx. Because the account delta commitment encodes the nonce change as a boolean flag rather than the concrete nonce value, two transactions with an identical empty delta, identical input and output notes, and the same reused SALT produce an identical message. Since auth_tx is pub and deliberately returns the commitment for a wrapper to finalize, a third party implementing a custom wrapper that calls auth_tx but omits assert_new_tx, reasonably concluding from the documentation that a unique SALT already provides replay protection, would leave the account open to same-account replay of the signed effect. The absence of independent SALT tracking also means a signer cannot invalidate a pre-signed transaction by consuming its SALT: were SALTs tracked, a signer could burn a pending signed transaction by submitting any other transaction that reuses the same SALT before the original is applied.
Consider correcting the auth_tx documentation to state explicitly that the procedure does not provide replay protection on its own and that a caller must invoke assert_new_tx, or an equivalent finalization step, to obtain it. Consider additionally tracking used SALTs independently in account storage so that a SALT cannot appear in more than one transaction regardless of the surrounding message content, which would both enable cancellation of a pre-signed transaction and allow the existing TX_SUMMARY_COMMITMENT deduplication in assert_new_tx to be removed, since a unique SALT feeds directly into the commitment and would guarantee its uniqueness on its own.
Update: Resolved in pull request #3211 at commit 4ce5509.
RoleSymbol Ordering Does Not Match Encoded Felt Ordering
The RoleSymbol type derives PartialOrd and Ord from its inner string, so instances are ordered lexicographically by their textual representation rather than by the encoded Felt value that is actually used as the on-chain role key. These two orderings diverge: for example, "AB" encodes to 29 and "B" encodes to 28, so the lexicographic ordering places "AB" before "B" while the encoded ordering places it after. No current consumer relies on RoleSymbol ordering, so there is no present impact, but code that later sorts roles or uses them as keys in an ordered collection while assuming the order matches the on-chain Felt keys would behave incorrectly.
Consider documenting that RoleSymbol ordering is lexicographic and does not correspond to the encoded Felt ordering, or implementing Ord and PartialOrd in terms of the encoded value if consistency with the on-chain key ordering is intended.
Update: Resolved in pull request #3215 at commit 49db60a.
Conclusion
The audited codebase provides standard authentication, access control, and faucet policy components for accounts deployed on Miden. The library is well-structured and the MASM implementations are of high quality, with clear separation between the core logic in standards/ and the installable component wrappers in account_components/.
The components are being developed alongside a protocol that is not yet final. The transaction kernel, which defines the execution environment these components depend on, is still evolving. Fee computation, asset callback dispatch, and other kernel behaviors that directly affect the security properties of these components may change before Miden reaches production. The security guarantees of any account built from these components should therefore be re-evaluated against the final kernel specification.
The Miden team was collaborative and responsive throughout the engagement, providing timely clarifications on protocol internals and addressing findings constructively.
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.
Looking for a security partner?