Miden USDCx Audit
OpenZeppelin Security
Findings
Total issues: 19 (13 resolved, 2 partially resolved)
Critical: 0 (0 resolved) · High: 1 (1 resolved) · Medium: 2 (2 resolved) · Low: 9 (3 resolved, 2 partially resolved)
Notes & Additional Information
7 notes raised (7 resolved)
Client Reported Issues
0 reported issues (0 resolved)
Scope
OpenZeppelin performed an audit of the 0xMiden/miden-usdcx repository at the c269baa63415327036c58bfac0f5679cb4907e6b commit.
The audit was limited to the Miden Assembly and Rust sources of the crates/xusdc-encoding crate. In scope were the following files:
crates/xusdc-encoding
├── asm
│ ├── components
│ │ └── faucet_extension
│ │ └── faucet_extension.masm
│ ├── notes
│ │ └── set_attester
│ │ └── set_attester.masm
│ └── xreserve
│ ├── attestation_verify.masm
│ ├── attester_admin.masm
│ ├── deposit_intent.masm
│ ├── mint_intent.masm
│ ├── mint_policy.masm
│ ├── mod.masm
│ └── packed_mem.masm
└── src
├── account
│ ├── mod.rs
│ └── xreserve
│ ├── admin_authority.rs
│ ├── builder
│ │ ├── construction.rs
│ │ ├── error.rs
│ │ ├── mod.rs
│ │ └── network_auth.rs
│ └── mod.rs
├── bin
│ └── gen_vectors.rs
├── errors.rs
├── lib.rs
├── note
│ ├── costs.rs
│ ├── mod.rs
│ ├── xreserve_admin
│ │ ├── min_burn_amount.rs
│ │ ├── mod.rs
│ │ └── set_attester.rs
│ ├── xreserve_burn.rs
│ └── xreserve_mint.rs
├── vectors.rs
├── xreserve
│ ├── encoding
│ │ ├── account_id.rs
│ │ ├── amount.rs
│ │ ├── attestation.rs
│ │ ├── burn_note.rs
│ │ ├── bytes32.rs
│ │ ├── deposit_intent.rs
│ │ ├── error.rs
│ │ ├── mint_intent.rs
│ │ └── mod.rs
│ └── mod.rs
└── xreserve_lib.rsSystem Overview
Miden is a zero-knowledge rollup built around client-side proving. Users execute and prove transactions locally and publish the resulting proof together with a state commitment, rather than submitting transactions for the network to execute. The two state abstractions are accounts and notes. An account bundles code, storage, and an asset vault, and it is assembled at creation from components, each of which contributes procedures and the storage slots those procedures own; a slot holds either a single word or a map. A note is a self-contained package that carries assets, a storage payload, and a script that runs when the note is consumed. Because one account cannot synchronously call into another, notes are the only way value and instructions move between accounts. Consuming a note publishes its nullifier, and the protocol admits each nullifier once.
Three further mechanisms carry most of the weight in this system. A note may carry attachments, which are word payloads committed to by the note identifier and retrievable on-chain by scheme; the protocol verifies an attachment's content against that commitment when a script reads it. A note may also carry a tag, a 32-bit value the node indexes so off-chain services can discover notes addressed to an account or to a use case. And a transaction may pull unauthenticated data from an advice provider, which is host-supplied and therefore attacker-controlled unless the executing code binds it to something already committed.
A network account is a public account whose transactions are executed by the network operator rather than by a user, so anyone may submit a note against it. Submission to this faucet is consequently permissionless, and every authorization decision has to be made by the code that runs inside the account.
USDCx is Circle's xReserve representation of USDC on Miden. It is not standard USDC and it is not CCTP. Native USDC remains locked in Circle's xReserve contract on a source chain, and the audited code implements the Miden side of that arrangement. A deposit on the source chain is attested by a Circle-held key, and that attestation authorizes a mint on Miden. In the opposite direction, a holder burns USDCx on Miden, an off-chain attester observes the burn and signs a withdrawal request, and Circle's source-chain contract releases the reserved USDC against that signature.
The audited crate has two halves. The Miden Assembly under asm/ is the on-chain code: the attestation mint policy and the message reconstruction, packed-memory, and signature-verification modules it drives, the attester allowlist setter, and one administrative note script. The Rust code under src/ is off-chain: it composes the faucet account, builds the notes the faucet consumes, and mirrors every wire codec the Miden Assembly implements so that the off-chain services and the on-chain code agree on the encoding. The Rust half is not the security boundary, because an arbitrary submitter can construct transport bytes without it.
The Faucet Account
The USDCx faucet is a public network account with no signing key. It carries the token name USDCx, the on-chain symbol USDCX, and six decimals. Every part of it except one comes from the miden-standards library: the fungible faucet itself, the pause flag and its manager, the token policy manager together with the minimum-burn-amount burn policy and the two transfer blocklist policies, the blocklist manager, the role-based access control component and the authority component that resolves a role per gated procedure, the keyless network-account authentication component, and a constant fee manager. Exactly one local component, xreserve, is added on top. That component contributes two account procedures, the attestation mint policy and the attester allowlist setter, and six storage slots: the attester commitment map, the used-nonce map, and four configuration words holding the destination domain, the source domain, and the two halves of the xReserve contract address. Only the destination domain is read on-chain, by the message reconstruction; the source domain and the contract address are recorded at deployment and no procedure reads them.
Because the account has no key, its outer authorization surface is the note-script allowlist fixed at creation. Ten note-script roots are admitted: the standard mint and burn notes, the local attester setter, the standard minimum-burn, faucet-metadata, pause, blocklist, and role configuration notes, and the two fee notes. Only the attester setter is a locally written script; the minimum-burn note is the standard one, wrapped in a Rust factory that refuses to build a zero floor. One transaction-script root is admitted, namely the standard expiration script. The general network-account configuration note is deliberately excluded, so neither allowlist can be modified through an accepted note once the account is deployed. A root pins complete code, but admitting one branching root admits every action that script implements. The standard configuration mutators that the installed components expose but the allowlist omits, such as the metadata setters, have no admitted entry path, and the metadata setters additionally trap on their immutable-mutability flags.
Account state divides into three groups. Decimals, symbol, token metadata, the four configuration words, the active policy roots, both allowlists, the authority mode, and the fee sponsorship policy are fixed by construction. The pause flag, the enabled attester commitments, the maximum supply, the minimum burn amount, the blocked accounts, the role membership and role-administration graph, and the per-note-root fee schedule are mutable at runtime through admitted authorized notes. The used-nonce map is append-only on accepted mints.
The Mint Path
The mint policy is the whole of the faucet's mint-side security. Everything else on the mint path is standard code: the standard mint note script calls the faucet's mint_and_send, which calls the standard policy manager, which asserts the account is not paused and then dyncalls the procedure root recorded as the active mint policy. That root is check_policy, and the faucet's supply cannot increase by any path that does not run it.
The policy is a predicate over the output-note recipe the mint note proposed: it receives the asset value, the output tag, the output note type, and the output recipient, and it either traps or returns them unchanged. Everything it receives comes from note storage, which the note creator committed to but Circle did not sign. The policy therefore treats all four as claims to be re-derived and compared, never as instructions.
Dispatch, inputs, and shape
The policy requires exactly two attachments on the note: one scheme-4 transport attachment and one scheme-2 network-account routing attachment. It finds both by scheme, then asserts the total attachment count is two, so nothing extra travels. It reads the transport into its own memory through the protocol interface that verifies the attachment's advice-provider content against the commitment the note identifier covers, which is what makes the transport authenticated rather than host-supplied. The routing attachment is required to be present but its target is not compared against the consuming account; binding to this particular faucet is instead cryptographic, and arrives with the message reconstruction below.
The transport is a fixed prefix followed by a variable tail: nine words of attestation material (the sixteen-element public key, the seventeen-element signature, three padding elements), then seven words of compressed mint intent, then packed hookData. The attestation leads precisely because it is fixed-width, which keeps every sub-region at a constant offset. The policy asserts the committed word count is at least that fixed prefix before it reads at any of those offsets, and the hookData length the intent declares is u32-asserted and required to reconcile exactly with the committed word count before anything uses it. That ordering is the length-confusion guard: the declared length later selects both the hashed extent and the copy length, so it may not be read until the note has committed to a region large enough to hold what it claims.
The amount is taken from the note's own asset value word, not from the transport. The remaining three elements of that word are asserted zero, the amount is asserted non-zero, and it is asserted to be within the fungible asset range.
Reconstructing the signed message
The faucet does not parse a Circle-signed preimage supplied by the submitter. It writes one. rebuild zeroes the 240-byte header region and then writes every field of the wire form at its fixed offset, from three sources: compile-time constants, on-chain state, and the compressed intent the note carries.
DepositIntent field (byte offset)
Written from
magic (0), version (4)
compile-time constants
amount (8)
the note's own asset value, right-aligned in the uint256 field
remoteDomain (40)
the faucet's configured destination domain, read from storage
remoteToken (44)
the faucet's own account identifier, read from the kernel
remoteRecipient (76)
carried; validated as a structurally valid account identifier
localToken (108), localDepositor (140)
carried, copied limb for limb with u32 guards
maxFee (172)
carried; required to be at most amount
nonce (204)
carried, copied limb for limb; hashed into the replay key
hookDataLen (236)
carried; reconciled against the committed word count
hookData (240…)
carried, copied verbatim and unguarded
The consequence is that no field the faucet supplies can be misrepresented. A note claiming another faucet, another domain, another amount, or another scheme version does not produce a message Circle signed, so the signature check rejects it. This is also why the amount is safe to take from the note: whatever the note claims is what gets hashed, and only the true amount verifies. The wire is big-endian while the staging region holds four wire bytes per field element in little-endian order, so each written value is byte-swapped and right-aligned as it lands; the header is written in full, pads included, so no prior memory content can reach the digest.
Two properties of this region are worth stating. It lives at a fixed global address and rebuild writes only the fields the message carries, so the region must read as zero beforehand; the dyncall dispatch gives the policy a fresh context, which supplies that. If it ever stopped holding, stale content would produce a message Circle never signed and the signature would refuse it, so the failure mode is liveness rather than forgery. Second, the reduction from Circle's uint256 amounts to Miden asset amounts is fixed at scale 10^0, the identity: the reconstruction zero-extends the carried amount back into its uint256 field, which is lossless only at that scale. A different scale would silently produce digests that can never verify.
Verifying the attestation
The policy computes a Poseidon2 commitment over the sixteen packed public-key elements, requires that commitment to map to the enabled marker in the attester allowlist, and then ECDSA-verifies the signature over keccak256 of exactly the extent it just rebuilt. The hash is raw Keccak-256 with no EIP-191 or EIP-712 wrapping. The same public-key pointer feeds both the allowlist lookup and the signature check, so an allowlisted commitment cannot be paired with a different key.
The verifier reads the key and the signature scalars from the advice provider, and binds only the key. The policy closes that gap by staging its own hash-verified memory regions into the advice map, each keyed by a Poseidon2 commitment of its own contents: the advice provider refuses a key already present with different values, so the verifier consumes the attested bytes or the transaction aborts. Each signature limb is u32-asserted before being rewritten from wire order into the verifier's native limb order.
Binding the output note, and replay
Having established what Circle signed, the policy derives the output note it should produce and asserts the note's proposal matches: the recipient is the pay-to-identifier recipe over the attested recipient account with the hashed nonce as its serial number, the tag is that recipient's account-target tag, and the note type is public. Each is compared against the value the note supplied. A submitter holding a valid attestation therefore cannot redirect the mint, retag it, or make it private; the recipe is a function of the signed message alone.
On acceptance the policy performs its only state write, marking the hashed nonce used, and returns its inputs unchanged to the standard path, which enforces the supply cap, adds the asset, and asserts the asset belongs to this faucet. Two replay layers apply. The note nullifier prevents reuse of a given note. The nonce registry prevents a single signed deposit from being minted through more than one independently constructed note, which the nullifier alone would not catch. Because the write happens on the accept path only, and because a failing assertion rolls the whole transaction back, a rejected mint never consumes a nonce.
The Burn Path
A burn note carries the standard burn script, exactly one USDCx asset in its storage, a fixed use-case tag spelling BURN, the standard routing attachment, and the Circle withdrawal payload — amount, destination domain, destination recipient, salt — as a second committed attachment. When the note is consumed against the faucet, the standard script asserts the carried asset equals the stored one and calls receive_and_burn, which runs the burn and transfer policies, destroys the asset, and reduces the token supply by that amount.
No on-chain code reads the withdrawal payload. The mint path is chain-verified; the burn path is only chain-evidenced. The payload is covered by the note identifier, so it is tamper-evident to any off-chain reader holding the note, but the chain proves only that an accepted transaction burned a given asset amount and says nothing about where the withdrawal should go.
Administration
The faucet has no signing key, so every administrative action arrives as an admitted note and is authorized against the sender account identifier that the kernel records in the note metadata, checked against the on-chain role map. Roles bind to account identifiers rather than to public keys, so any role can be held by a multisig account without a change to the faucet. The standard configuration notes assert that the consuming account is the one their routing attachment names, and the local attester note performs the same check.
Four roles are seeded at construction. Pause and unpause resolve to DOM_PAUSER, whose administration is delegated to DOM_MANAGER. Blocking and unblocking transfer participants resolve to BLK_MANAGER. Every other authority-gated procedure falls back to the built-in ADMIN role, which covers the attester allowlist, the maximum supply, the minimum burn amount, the note fee schedule, and role administration itself. The project documentation refers to the blocklist administration role as BLOCK_LISTER; the code still uses the earlier name BLK_MANAGER, and the rename is planned.
The Rust Library
The Rust half of the crate composes and configures the account, and it builds every note the faucet consumes. XReserveStablecoinBuilder assembles the components, seeds the role graph, writes the configuration words, derives the constant fee schedule from the network fee parameters and a measured note-cost table, and validates at build time that the attestation policy is the active mint policy, so that the attestation-gated path is the only surface that can increase supply. It also refuses compositions that collapse the role separation, rejecting a blocklist administrator equal to any other seeded role holder. The note factories build the mint note, the burn note, and the two administrative notes, and they refuse inputs that the standard builders would accept, such as a zero minimum-burn floor.
The encoding modules mirror the on-chain wire forms: the deposit-intent layout and its parser, the compressed mint intent, the burn-note payload, the attestation material, the bytes32 hashing used for nonce keys, the reduction of Circle's uint256 amounts into the Miden asset-amount range, and the account-identifier codec. The mint-intent module also owns the inverse of the on-chain reconstruction, so the two are held together by a round trip rather than by a field-by-field comparison. A single golden-vector artifact drives both the Rust unit tests and the Miden Assembly execution tests, and the Miden Assembly error messages are extracted into Rust constants at build time so that the two halves cannot drift.
Security Model and Trust Assumptions
The following assumptions were taken as given for this audit. They are properties of the surrounding protocol, of Circle's xReserve design, or deliberate choices of the implementation, rather than findings.
- The Miden protocol, virtual machine, transaction kernel, and standards library are trusted to provide proof verification, state authentication, transaction ordering, nullifier uniqueness, and atomicity. An assertion failure rejects a transaction and rolls back all of its writes, and the whole safety argument of the mint and burn paths depends on that. A single node response is not an independent finality proof.
- Circle's attestation keys are trusted. A mint is authorized by an ECDSA signature over the reconstructed deposit intent, checked against a commitment enabled in the on-chain attester map. Compromise of an enabled key allows minting of USDCx that no reserve backs. The setter stores whatever commitment the caller supplies and does not recompute it from a key, the map may hold any number of enabled entries at once, and a disabled entry is indistinguishable from an absent one.
- One-to-one external backing is outside this code. The audited account guarantees only local conservation, namely that supply increases exclusively through the attestation-gated mint accounting and that an accepted burn destroys the asset and reduces the supply by the same amount. Reserve custody, source-chain release, destination support, and finality policy are outside the audited surface, and the constructor accepts an initial token supply independently of any distribution of initial assets.
- The deposit relayer is untrusted for safety. Submission is permissionless, and the account independently enforces script admission, attachment verification, reconstruction, signature verification, replay state, and every policy check. A hostile submitter can waste work but cannot bypass those checks. Absence of the relayer affects mint liveness only.
- The advice provider is host-controlled. The mint path takes its signature witness from it, and the only thing that makes that safe is the policy staging its own hash-verified regions under commitments to their own contents. That argument depends on the advice map rejecting a re-insertion of a present key with different values, and on the verifier consuming exactly the elements the policy staged.
- The withdrawal attester is trusted with funds. Because the burn script does not read the withdrawal attachment, the chain proves only that an accepted state transition burned a given asset amount. The attester must independently establish canonical burn verification, block finality, equality between the declared payload amount and the burned asset, and the meaning of the destination fields before Circle releases reserves.
- Admitted script roots pin code, not inputs. Both allowlists match exact roots and are immutable after deployment. A note script therefore receives caller-controlled note arguments and advice inputs, so a root is only safe to admit when the script's effect is acceptable for every possible input. Conversely, a root that was not admitted at creation makes the corresponding administrative action permanently unavailable.
- Source-chain deposits are not reserved against the destination supply cap. The faucet enforces a runtime-mutable maximum supply, and the mint policy marks the deposit nonce as used before the standard path enforces that cap. Because a rejected transaction rolls back, the nonce remains reusable, but a deposit whose mint exceeds the remaining headroom cannot be minted until an administrator raises the cap or an existing burn frees headroom. Since the destination implementation offers no reservation, cancellation, or refund path, the system assumes that administrators maintain sufficient headroom for deposits already in custody, and that cap reductions are coordinated with outstanding deposits.
- The relation
maxFee <= amountis enforced only at mint time. The Rust decoder narrowsamountandmaxFeeindependently and copiesmaxFeewithout requiring it to be at mostamount, while the on-chain mint intent validation requires that relation. Both values are part of the signed payload, so neither can be adjusted after attestation. The system therefore assumes that the source domain rejects such deposits before custody, or that Circle refunds them, since otherwise the deposited funds have no destination mint and no burn-backed withdrawal path. - The deposit amount scale is fixed at the identity. The transport carries the amount as a Miden asset amount and the faucet zero-extends it back into a
uint256field, which reproduces Circle's bytes only while the two token scales agree at six decimals. The exact cap and scale remain Circle's to decide, and a scale change would require a new transport rather than a parameter change. - The withdrawal payload fields are opaque to the chain. No on-chain code reads the encoded destination domain, recipient, or salt of a burn note, and the
localToken,localDepositor, andhookDatafields of a deposit intent carry no local semantics. Their validity, interpretation, and the availability of the data behind them are the responsibility of the off-chain services and of Circle. - Note discovery is not an event log. A public note is not automatically a durable record, since a note can be created and consumed within the same block. Any external process that reacts to a burn must rely on authenticated inclusion or state paths and an explicit finality rule rather than on the presence of the note.
Privileged Roles
The audited crate builds account instances rather than deploying them, so no on-chain identifiers are applicable. The roles below are the ones a faucet composed by XReserveStablecoinBuilder exposes. All of them are held by account identifiers, and all of them act by sending admitted notes to the faucet.
ADMIN: The root authority, seeded on the administrator's account and administered by itself. It may add and disable attester commitments, change the maximum supply, change the minimum burn amount, change the per-note-root fee schedule, and grant, revoke, delegate, or renounce every role whose administration the builder leaves unset —DOM_MANAGER,BLK_MANAGER, andADMINitself.DOM_PAUSERis the exception: its administration is delegated toDOM_MANAGERat construction, and the stock role-based access control treats a delegation as exclusive, soADMINholds no direct authority over that role and reaches it only by first granting itselfDOM_MANAGER.ADMINis therefore owner-equivalent in reach, but its reach over pause is indirect and depends onDOM_PAUSER's recorded administrator continuing to name a roleADMINcan join. Administrative paths remain available while the faucet is paused, so a compromised attester commitment can be disabled during a pause. The single exception is the maximum-supply setter, which asserts that the faucet is not paused.DOM_PAUSER: May set and clear the account-wide pause flag, which halts both minting and burning as well as the asset-transfer callbacks. A single flag covers both directions, pause and unpause share this role and the same admitted note root, and an unpause requires exactly one authorized note.DOM_MANAGER: AdministersDOM_PAUSERmembership and holds no direct capability of its own on the token surface.BLK_MANAGER: May block and unblock transfer participants. The standard blocklist policy runs on both the send and the receive callback, checks the transaction's native account, and exempts the issuing faucet. Consequently, a blocklist entry naming the faucet itself has no effect, minting to a blocked recipient still succeeds and leaves the output note unspendable until the recipient is unblocked, and blocking freezes assets without providing any clawback.- Submitters: Anyone may construct and submit a transaction that consumes an admitted note against the faucet. Submission is therefore permissionless, and the effective privilege boundary for unprivileged parties is the pair of allowlists fixed at creation together with the checks each admitted script performs.
Known Findings and Deviations
Before this audit began, the development team documented a set of deliberate deviations from Circle's xReserve specification, together with a set of known open items on the audited surface. These are recorded in docs/DEVIATIONS.md, which covers the governance and role model, the differences from Circle's EVM reference token, and the items the team already intends to address. Findings from an earlier internal review round are filed in the implementation repository as open issues labeled audit-finding.
Both sources were treated as prior knowledge rather than as discoveries of this report. The intentional deviations were taken as design decisions, so the audit checked whether the property each deviation claims actually holds. Where an issue in this report overlaps material that is already documented in either source, it is included because this audit reached a different conclusion about its cause, reach, impact or mitigation.
High Severity
Withdrawal Idempotency Keyed on the Consuming Transaction Can Strand Burned Funds
A withdrawal begins on chain with an XReserveBurnNote. The note carries the xUSDC being withdrawn as its only asset, addresses the faucet through the stock BurnNote script, and holds the withdrawal payload of amount, destination domain, destination recipient, and salt in a scheme-tagged attachment. No on-chain code reads that payload. Consuming the note destroys the tokens, and everything that turns them back into native USDC happens off chain, where the withdrawal listener decodes the payload and asks Circle to release the funds on the source chain.
The identity that withdrawal is given is not taken from the note. The only burn evidence the submitted batch carries is burnTxId, the identifier of the Miden transaction that consumed the note, and the listener reuses that same value as its local idempotency key. BurnKey wraps it and nothing else, the submitted-burn table makes it the sole primary key, and the row it keys holds only a status, a timestamp, and a withdrawal identifier: no note identifier, no amount, no recipient, and so nothing that could expose a collision. A per-burn identity does exist while the evidence is assembled, because EvidencePackage carries the note identifier and the nullifier next to the transaction identifier, but neither value crosses into the submitted batch or into the ledger. Withholding those two values from the wire is deliberate, because burnTxId is the only evidence field the endpoint documents and what more Circle would accept is Circle's to answer. Reusing that same value as the local ledger key is a separate decision. The module that makes it states the property it depends on, that the key is the stable per-burn identity, derived from the burn itself rather than from the process's state. A transaction identifier is derived from the transaction, and is per-burn only while a transaction consumes at most one burn note. The deposit relayer this module says it mirrors keys instead on a value derived from the deposit itself.
Nothing constrains a consuming transaction to a single burn note. Every burn note routes the same stock script to the faucet, the production faucet allowlist accepts that script, and each note is consumed on its own terms, so one faucet transaction can retire two burn nullifiers and reduce the token supply by the sum of the two amounts. In the node release this project targets, v0.16.0-alpha.2, the network transaction builder defaults to twenty notes per transaction, selects up to that limit from the allowed notes queued for one account, and passes every note that survives consumability filtering to one transaction execution. That selection groups on the target account and applies no predicate on note tag or script root, and the allowlist check tests each note's script root for membership without deduplicating by root, so two notes carrying the same burn script root both survive it. Two burners whose notes are queued in the same window therefore land in one transaction, though that grouping has not been observed against a live node. The listener also does not yet ship its Miden read adapter, so the current code does not expose this path end to end, and the finding applies once that adapter supplies the transaction records evidence assembly works from.
Where one transaction consumes two burn notes, the two burns become indistinguishable to the attester.
assemble_evidenceresolves both nullifiers to the same transaction and produces both evidence packages without objection. Its ambiguity check refuses several transactions claiming one nullifier, but it does not refuse several burn nullifiers claimed by one transaction.- The first burn claims the shared key, and its withdrawal request goes to Circle.
- The second burn finds the key already claimed.
submit_withdrawreturnsAlreadySubmittedbefore any withdrawal request reaches Circle, and the listener passes that outcome through unchanged, which is neither a reconciliation refusal nor a completed withdrawal.
The tokens the second note carried are already destroyed on chain, and no withdrawal was ever requested for them. Circle holds a prepared withdrawal for that burn nonetheless, and the attesters have signed its digest, because both steps run before the ledger claim is taken. The result is durable. The ledger has closed the second burn against the first burner's withdrawal identifier, so an operator who reconciles it is shown a healthy withdrawal belonging to someone else. Circle cannot break the tie either, because its 409 conflict response is keyed on the same transaction identifier, so the ordinary path leaves the second burner's value stranded. The one branch that reopens the key is no better. A deterministic HTTP 400 means Circle rejected the request and created nothing, so the shared key becomes re-claimable, either burn can then acquire it, and a later request can bind a different payload to the same transaction identifier.
Consider deriving the withdrawal's identity from the individual burn rather than from the transaction that consumed it, and preserving that identity at every idempotency boundary. The nullifier is unique to one note, is already carried on EvidencePackage, and can key the local ledger without adding a field to the on-chain payload. Retaining burnTxId on the row as an ordinary column rather than as the key would then make two rows sharing one transaction identifier the collision signal that no component raises today. Refusing any transaction that carries more than one note is not a workable substitute. The faucet prices every note script it accepts, so a burn whose fee is sponsored legitimately consumes a fee-sponsorship note alongside the burn note, and a transaction record exposes only its input nullifiers, with no note header or script root to say which note each nullifier came from. Such a rule would reject every sponsored withdrawal. The burner-chosen salt should not become the key either, because its contract carries no per-burn uniqueness requirement and the preparation path deliberately carries it through so that rebuilding the same burn stays byte-identical. Changing the local key alone is also insufficient while Circle deduplicates on burnTxId, so either the Circle-facing contract accepts a per-burn identity, or the transaction builder guarantees one burn note per consuming transaction. Whether a Miden transaction identifier is acceptable as burnTxId is already open with Circle, and because Circle's deduplication is keyed on that value the question is one of correctness and not only of format, so it should be raised alongside the fix. Neither remedy recovers value that has already been burned.
Update: Resolved in pull requests #204, #217, and #219, part of the replacement attester stack based on pull request #194, reviewed at commit 862389a.
The fix replaces the withdrawal listener with a new attester that gives each burn note its own identity at every idempotency boundary. The recommendation suggested the nullifier as the local ledger key, but the implementation uses the burn note ID, which works just as well. A note ID identifies exactly one note, and the nullifier is still declared UNIQUE next to it. As a result, two notes consumed by the same transaction produce two ledger rows, and a repeated note ID or nullifier is refused on insert. The consuming transaction ID stays on each row as a plain burn_tx_id column rather than as a key, as the recommendation proposed. Saved submissions are keyed by the note ID too.
For the Circle side, the recommendation required either a per-burn identity in the Circle-facing contract or a guarantee of one burn note per consuming transaction. The fix takes the first option: the request sets burnTxId to the burn note ID and carries one batch with one burn intent, and verification rejects any prepared response that has more than one batch or intent. Circle's 409 deduplication is keyed on burnTxId, so it is now per burn as well. With that, neither the local ledger nor Circle's deduplication can merge two burns from one transaction, and the stranding scenario no longer arises. The team stated that Circle confirmed the burn note ID is the expected value for burnTxId.
Medium Severity
Input-Less Transactions Can Spend The Faucet's Retained Fee Balance And Advance Its Nonce
The production faucet is deployed as a public account with no signing key, and its only authorization surface is the stock AuthNetworkAccount composed by the builder and installed by build_account. That component checks the transaction script root against a one-entry allowlist and every input note's script root against the ten-root note allowlist, but it requires neither to be present: a transaction that executed no transaction script is always allowed, and the input-note check iterates over an empty set. Authorization therefore passes for a transaction carrying no input notes and no transaction script, after which the component pays the transaction fee out of the account vault and increments the nonce.
On a fee-charging chain this turns an input-less, script-less transaction into a complete rather than an empty transaction: fee payment moves native fee assets from the faucet vault into a public TX_FEE note, so the initial and final account state commitments differ and the protocol's empty-transaction rejection no longer applies. Any party can build and submit such a transaction against the public account state, and repeat it while the vault holds enough of the fee asset. The balance it spends arises from ordinary bridge operation rather than from operator error, because the scheduled mint price is derived from the maximum hookData consumption cost in XUSDC_MINT_CONSUMPTION_CYCLES while the fee actually charged is lower, leaving sponsor-paid surplus behind after every sponsored mint. A reproduction on the repository's own fee-charging fixture, with no seeded funding, retains 9000 native units after a single sponsored mint and then spends 7500 of them through one input-less transaction that advances the nonce. Since a proven transaction binds to the account state commitment it was built against, each such advance can also invalidate an in-flight relayer or attester transaction and force it to be re-proven, at no asset cost to the submitter, who gains nothing directly because the fee note accrues to the block producer.
Consider requiring, before fee payment, that a transaction against the faucet consume at least one allowlisted non-sponsorship input note or carry an allowlisted transaction script, either by wrapping AuthNetworkAccount with that precondition or by adopting an authorization component that enforces it. Consider also whether the retained surplus should accumulate at all, since none of the allowlisted note scripts can move a non-xUSDC asset out of the account, so sponsor-paid overcollection has no exit other than fee payment, and add regression coverage that exercises the note-less transaction against a funded faucet on a non-zero base fee.
Update: Resolved in pull request #249. The faucet's only authorization component, the stock AuthNetworkAccount, previously allowed a transaction with no input notes and no transaction script, then paid the fee from the faucet's vault and incremented its nonce. Pull request #249 raised the protocol-family pin to 0.17.0-rc.6, which includes protocol#3729. The workspace now pins miden-standards =0.17.1. With this guard, AuthNetworkAccount requires, before any fee is paid, that the transaction consume an input note, create an output note, or change account state.
The Pauser Rotation Role Can Irreversibly Capture Pause and Halt Redemption
The DOM_MANAGER role has exactly one intended capability: granting and revoking DOM_PAUSER. The account builder confers it by delegating DOM_PAUSER administration to DOM_MANAGER at deployment. The stock role-based access control treats such a delegation as exclusive, so ADMIN retains no direct authority over a role once that role's administration is delegated. ADMIN reaches DOM_PAUSER only indirectly, by first granting itself DOM_MANAGER, which is the recovery path the project documents. That path rests on a single stored field, the administrator symbol recorded for DOM_PAUSER, and the holder of DOM_MANAGER is the party authorized to rewrite it.
Rewriting that field yields a capability outside the scope DOM_MANAGER was granted, and two properties of the stock implementation make the acquisition permanent. set_role_admin accepts an administrator symbol equal to the role being administered, so DOM_PAUSER may be made its own administrator. get_effective_role_admin resolves that configuration by symbol alone, falling back to ADMIN only when the symbol is zero and never when the named administrator role has no members. A holder of DOM_MANAGER can therefore complete the capture with three RbacConfigNote submissions, a note type the faucet admits on its allowlist:
- Revoke
DOM_PAUSERfrom the legitimate operator. - Grant
DOM_PAUSERto an account under the holder's control. - Set the administrator of
DOM_PAUSERtoDOM_PAUSERitself.
Every submission is authorized, because DOM_MANAGER remains the effective administrator of DOM_PAUSER at the moment each note is consumed. After the third, only a member of DOM_PAUSER may grant, revoke, or re-point the role, and ADMIN is excluded with no path back. The holder may afterwards renounce the role, leaving a role that has no members, administers itself, and cannot be recovered even by the holder who emptied it.
The capability acquired reaches further than the mint and burn halt the project records. The paused check precedes the policy dispatch in both execute_mint_policy and execute_burn_policy, and the transfer policy dispatch applies the same check whenever a policy is configured, which the builder does for both the send and the receive direction. A pause consequently stops minting, stops redemption, and causes every holder-to-holder transfer to fail inside the holder's own transaction. Because the native USDC backing the supply stays locked in Circle's xReserve contract and a burn is the only withdrawal signal, a pause that cannot be lifted leaves the backing unrecoverable. No override remains: the procedure-to-role map is fixed at construction and exposes no setter, the note allowlist admits no policy configuration note, and no upgrade component is installed.
Consider removing the delegation and seeding DOM_PAUSER with no configured administrator, which places the role under ADMIN and confines DOM_MANAGER to the membership changes it was scoped to make. The project already contemplates retiring DOM_MANAGER on governance grounds, and administering DOM_PAUSER is its only capability on the token side. If the delegation is retained deliberately, DOM_MANAGER should be custodied and documented as a role equal in trust to ADMIN, and the reachability claim should be withdrawn. Reporting the two upstream sharp edges is also worthwhile, particularly the resolution of an effective administrator by symbol alone, which sits awkwardly beside the same module's definition of role existence by member count. Neither upstream change substitutes for the local fix: rejecting self-administration alone still permits re-pointing to any unseeded symbol, and a member-count fallback alone still permits a holder who retains membership to exclude ADMIN.
Update: Resolved in pull request #216. The team stated:
“We removedDOM_MANAGERand the initial pauser delegation in PR #216, following the revised role model in #196.
We now seed every role directly underADMIN. We movedset_attestertoATTEST_ADMINand split pause and unpause betweenDOM_PAUSERandDOM_UNPAUSER. This removes the initial delegation that allowed the pauser-rotation role to take exclusive control of pause.
We retainset_role_admin, soADMINcan still deliberately delegate role administration later. This change is part of the v2 faucet deployment.”
Low Severity
Same-Block Consumption Can Erase Burn Withdrawal Evidence
The public burn note carries its (amount, destDomain, destRecipient, salt) withdrawal record only as a committed attachment, and consumption runs the stock BurnNote script that the faucet admits by script root (XReserveBurnNote::new, allowed_note_scripts). Nothing in the audited code requires the note to have been committed earlier, and the project's test production_burn_note_same_block_consume_is_erased creates and consumes the production note in one block, leaving no note, commitment, or nullifier while token_supply falls (test reference).
Such a burn destroys the holder's tokens with no discoverable record, so the attester never sees the payload and the reserve is never released. The path is currently foreclosed by the validated node stack, which rejects user-submitted network transactions and consumes only committed notes, behavior this repository neither enforces nor pins.
Consider requiring the consumed burn note to be authenticated against a committed block in a dedicated burn policy, and pinning the assumed node behavior as a deployment prerequisite.
Update: Acknowledged, not resolved. The Miden team stated:
“Currently not-an-issue given the node behavior.
This will eventually be solved by emitting events, which would change how the offchain services record burns: instead of watching for creates notes, the service would parse events emitted within a block.
So I'd mark this as acknowledged, but mitigated further down the stack at the node implementation.
See also https://github.com/0xMiden/protocol/issues/3269#issuecomment-4935082114”
OpenZeppelin agrees that the path is not reachable on miden-node v0.16.0-alpha.2, v0.16.0 and v0.16.1, and the gate is still present in v0.17.0-rc.3. On these versions, the RPC rejects user-submitted transactions and batches for an already deployed network account unless they carry the network-transaction shared secret, and the network transaction builder consumes only notes from committed blocks. Both rules live in the node, not the faucet, and the error message ("may not be submitted by users yet") presents the first as temporary. If either is dropped, a same-block burn can erase its withdrawal record. The repository does not pin the node version, and the validation harness is excluded from the workspace and CI and does not build. Until burns are recorded through emitted events, the following should be deployment prerequisites, re-checked on every node upgrade:
- the node keeps both rules;
- the faucet is deployed as a network account;
- only the network transaction builder holds the shared secret (
--rpc.network-tx-auth-header-value, sent as thex-miden-network-tx-authheader); - the sequencer's internal API (
--internal.listen), which bypasses the RPC gate, stays on the node's private network.
Minting To The Faucet's Own Account Permanently Strands The Deposit
On the mint path the faucet checks only that the attested remoteRecipient decodes to a structurally valid account ID (account_id::validate_structure), then derives a P2ID output note for that ID (XUsdcMintNoteStorage) and marks the deposit nonce used (USED_NONCES_SLOT).
If that ID is the faucet itself, the mint succeeds and the note is unconsumable: P2ID is absent from the faucet's deployment-fixed note allowlist (allowed_note_scripts), and the account installs no wallet component. The spent nonce blocks any corrected re-mint, so the depositor's deposit is permanently lost.
Consider rejecting a remoteRecipient equal to the faucet's own ID, a value already on the stack where the structural check runs, and more generally rejecting faucet-type IDs, so the deposit fails before the nonce is consumed and stays externally recoverable. Alternatively, consider adding basic wallet functionality to the faucet and allowlisting the P2ID note type at construction.
Update: Acknowledged, not resolved. The Miden team stated:
“Unlikely. Making this unmintable on the Miden side would create a discrepancy with the deposit side: a user can make the deposit to whichever account they want to - and the deposit side has no understanding of whether the intended recipient is a faucet or not. And if such a mint was allowed on the Circle side, but then blocked on the Miden side, it would create an account discrepancy.”
OpenZeppelin agrees that the likelihood is low, but notes that the finding's alternative, a wallet component with P2ID allowlisted, rejects no mint and so raises no discrepancy concern. A rejection would also not be new, since deposits whose hookData exceeds the 3,840-byte transport ceiling already go unminted with the USDC locked on the source chain. At present, such a mint sends the funds in a P2ID note the faucet cannot consume and records the nonce as used, so the deposit cannot be claimed on Miden without an administrator code upgrade. The team accepts this risk.
Funded Burn Notes Can Become Unconsumable When The Burn Floor Is Raised
The faucet installs the stock MinBurnAmount policy (BurnPolicy::min_burn_amount), which reads the floor at consumption time and admits the floor setter in its note allowlist (allowed_note_scripts). Publishing a burn note moves the xUSDC out of the holder's vault (XReserveBurnNote::new) into a note committed to the stock burn script, which records no creation-time floor and offers no reclaim branch.
Raising the floor above an already published note's amount makes that note unconsumable for as long as the higher floor stands, a rejection the suite exercises (set_min_burn_raise_then_below_new_min_rejects). Only the issuing faucet can consume such a note, so the holder has no unilateral recovery and the amount stays locked until an administrator lowers the floor.
Consider binding a burn note to the floor in effect when it was funded, or admitting a burn script with a timeout-based sender reclaim, so a routine threshold change does not immobilize in-flight burns.
Update: Acknowledged, not resolved. The Miden team stated:
“Valid finding, though there are two considerations which make this issue have negligible impact:
”
- While the burn floor is not a static constant fixed forever, we expect to update it very rarely, if ever.
BURNnotes are typically consumed within the next block, so the notes are not expected to remain in-flight for long.
“For those reasons I'd mark this as ack, but will not address.”
OpenZeppelin agrees that the impact is low under the stated conditions, since the risk arises only when the floor is raised. A burn note below the floor at consumption time cannot be consumed, whether it was pending at the change or created afterwards by a client unaware of it, and the holder cannot reclaim it. The only recovery is for ADMIN to lower the floor to at most the note's amount until the note is consumed, which also admits other burns between the lowered and the raised floor in the meantime. This recovery is time-limited. The network transaction builder gives up on a note after 30 failed attempts, a limit fixed in the binary, and its block-based backoff places the last attempt at least 6,390 blocks after the first, about 5.3 hours at the default three-second block time. Lowering the floor neither resets this count nor triggers a retry. The note is next tried when its current backoff gap elapses, and that gap reaches 1,409 blocks, about 70 minutes, before the final attempt. After that, the builder's persistent database no longer offers the note for retry, a restart does not reload it, and only the node operator can revive it. Before raising the floor, the team should announce the new value, make its own tooling reject burns below it, and query each outstanding burn note through GetNetworkNoteStatus or a sync by the burn tag. It should check again after the change and, if a note is stuck, lower the floor well before the retries stop.
Emergency Attester Removal Can Be Reversed By Superseded Enable Notes
The attester allowlist setter overwrites the stored marker unconditionally, checking only that the note sender holds ADMIN, with no sequence, prior-value, or expiry constraint (set_attester). Each admin note draws a fresh serial number (build_admin_note), so two enable notes for one commitment are independently consumable under the permanently allowlisted script root (allowed_note_scripts).
Consuming a disable note therefore does not invalidate an outstanding enable note for the same attester. If one is still unconsumed when a compromised attester is removed, its later execution restores the enabled marker and the mint-time gate accepts that attester again (verify_attestation), so emergency removal is not durable.
Consider binding all setter notes to a monotonic sequence held in faucet storage and echoed in the note payload, rejecting any note that does not match the stored value and incrementing on every accepted write so that an applied update invalidates every note created before it. Alternatively, consider adding a deadline to setter notes.
Update: Acknowledged, not resolved. The Miden team stated:
“Acknowledged and known: #85
Under a centralized and trusted operator, this should be solved by the offchain component that sends admin/management notes to the faucet. Specifically, the operator should ensure that no other admin notes are currently in-flight before submitting a new admin note.
This will eventually be solved at the standards level and can be adapted here, but is out of scope for the next deployment version. See 0xMiden/protocol#3551”
OpenZeppelin agrees that the operator procedure reduces the risk under a trusted operator, but it requires waiting until no admin notes are pending, and that wait has no bound. A pending note does not expire, cannot be withdrawn, stays consumable on chain after the network transaction builder stops retrying it, and, if its sender has lost the role, fails on every attempt and stays pending indefinitely. Because the role is checked at consumption, the only on-chain cancellation is to remove the role from the sender first, by revocation or renunciation, and never grant it back. In the fixed version, set_attester requires ATTEST_ADMIN, which ADMIN can grant, and the faucet starts with one holder. Pending unpause, role-grant and role-admin notes are likewise not invalidated by later notes. Until the standards-level fix is adopted, the incident response should be the following, with each step's notes consumed before the next begins:
- The
DOM_PAUSERholder pauses the faucet, which halts minting, burning and USDCx transfers but notset_attesteror role changes. - If an
ADMINaccount may have pending role-grant or role-admin notes, it grantsADMINto a new account, which then revokesADMINfrom the old one. ADMINrevokesATTEST_ADMINandDOM_UNPAUSERfrom every holder that may have pendingset_attesteror unpause notes, granting replacements where needed.- The
DOM_PAUSERholder confirms the faucet is still paused and pauses it again if not. - A new
ATTEST_ADMINholder disables the attester. - A
DOM_UNPAUSERholder unpauses once the attester is confirmed disabled.
Burn Note Payload Carries a Salt Field Not Prescribed by the xReserve Specification
The XReserveBurnNote carries its withdrawal payload as a scheme-tagged note attachment, encoded by XReserveBurnItems as (amount, destDomain, destRecipient, salt). The first three fields correspond to the parameters of the reference burn function, which the xReserve specification states partner chains should implement with the exact interface shown. The salt field has no counterpart there: the reference USDCx implementation emits Burn with the depositor, the source domain, the amount, the destination domain, and the destination recipient, and it neither accepts nor records a salt.
The field is not required for the withdrawal path either. Circle's prepare-withdrawal documentation states that the request salt is optional and is randomly generated when omitted, which the schema type records. Note identity does not depend on it, because the note id already commits to a randomly drawn serial_num, so two burns with identical parameters yield distinct notes regardless of the salt. The value is also never checked against the one Circle returns, since the pre-signing gate compares only the amount, the destination domain, and the destination recipient. Its sole current use is as the request salt the prepare call sends, which keeps the prepared burn intent reproducible across repeated prepare calls for one burn.
Consider removing salt from the burn note payload so that the note carries exactly the fields the xReserve burn interface prescribes, and letting Circle generate the value. If reproducibility of the prepared burn intent is to be retained, consider deriving the salt off-chain from data already bound to the burn, such as the note id, rather than carrying a dedicated field, and comparing the derived value against the salt in the returned transfer specification.
Update: Resolved in pull request #201, which landed at commit acd4904.
Withdrawal Payload Is Not Bound To The Burned Asset
The USDCx withdrawal path begins with a burn note. The XReserveBurnNote factory builds a public note carrying one USDCx asset, reuses the stock BurnNote consume script, and places the Circle withdrawal payload (amount, destDomain, destRecipient, and salt) in a scheme-6 attachment. No on-chain constraint applies to that attachment: the stock consume script validates note storage and the carried asset but never reads attachments, the active MinBurnAmount policy compares only the burned amount against a configured floor, and the faucet note allowlist admits burns by the stock BurnNote script root. A hand-built note carrying that root burns successfully whether it omits the withdrawal attachment, carries a malformed one, carries several, or declares an amount unrelated to the asset destroyed. The mint policy of the same faucet demonstrates the discipline that is absent, asserting an exact attachment count and hash-verifying the transport attachment against its committed commitment before reading it.
The off-chain path does not compensate, because it reads the attachment and nothing else. validate_discovery checks the note tag, the presence of note details, payload decodability, and the sender, build_prepare_request sends the declared amount to Circle as valueIncludingFees, and check_spec compares the amount Circle returns against that same declared amount before the cleared digest is signed. The burned asset never enters the crate, since DiscoveredDetails carries only the payload felts and the sender, so the comparison that the architecture documentation designates as the mitigation cannot be added without changing those types. No step selects an attachment by scheme: decode_burn_payload receives the payload felts already extracted, so which of a note's attachments supplies them is undetermined.
The result is a withdrawal record that is tamper-evident but unverified: a burn of the minimum floor, one base unit under the default configuration, paired with a payload declaring any amount the codec accepts, passes every check present in the codebase. Two facts limit the severity. The consuming crate cannot run, because miden-client is deliberately absent and every Miden read the listener would perform is parked, and the crate declares no binary. The comparison of the declared payload against the burned asset is also assigned to the withdrawal attester, which this report treats as trusted with funds. The behavior is disclosed in docs/DEVIATIONS.md and section 6 of docs/ARCHITECTURE.md, and tracked as issue 146 of the implementation repository. It is recorded here because the on-chain half is complete and shipped while the compensating control the documentation designates does not exist in the audited code.
Consider introducing a dedicated burn policy that enforces the withdrawal record on chain, and reshaping the payload so that no amount can be declared. In particular:
- Require the withdrawal attachment to be present, and reject any burn note that does not carry it.
- Validate the attachment for well-formedness before the burn proceeds, covering the expected scheme, the expected word count, hash verification of its content against its committed commitment, an exact total attachment count rather than the protocol's upper bound of four, and the absence of duplicate schemes.
- Remove the
amountfield from the payload and derive the withdrawal amount from the asset actually presented for burning. A single source for the amount removes the divergence, rather than obliging every consumer to detect it.
Update: Resolved in pull request #201 and #213.
Advice-Staged Signature Scalars Are Bound Positionally Rather Than By Commitment
verify_signature stages the signature scalars and the public key on the advice provider, each keyed by a Poseidon2 commitment of its own contents, and stage_region_on_advice relies on adv.insert_mem rejecting a key that is already present with different values. However, ecdsa_k256_keccak::verify_bytes binds only the public key, through an assert_eqw against PK_COMM, while load_signature_k1 consumes the next 16 advice elements with four adv_pushw and never compares them against SCALARS_COMMITMENT. The scalars are therefore read at the intended stack depth only while keccak256::hash_bytes_mem, k1_scalar::load_reduced_256, and secp256k1::load_mem remain advice-neutral. That property holds at miden-core-lib 0.29.1, but it is positional rather than cryptographic, and it depends on the core library rather than on miden-standards. If an intervening wrapper changed its advice consumption, the verifier would read scalar limbs from the prover's advice tail instead of from the attested region, which mint_ignores_a_hostile_advice_stack and every ordinary mint execution test would surface as a verification failure. A forgery would still require a valid ECDSA witness, so the practical consequence is that the signature recorded in the note may not be the signature that was verified. Section 2 of docs/DEVIATIONS.md presents the commitment as covering the signature half, while the comment in verify_signature states the contingency.
Consider binding the scalars in the same manner as the public key, by hashing the 16 staged elements inside the verification path and asserting the result against a caller-supplied commitment. If that is not practical, consider correcting docs/DEVIATIONS.md to state that the commitment does not cover the signature.
Update: Partially Resolved in pull request #203. It rewrites Section 2 of docs/DEVIATIONS.md to record that the staging commitment covers the public key and not the signature. The binding remains absent. The body of verify_signature is unchanged from the audited version, and SCALARS_COMMITMENT is still computed only as the adv.insert_mem key for the staged region, with nothing in the verification path asserting against it. load_signature_k1 consumes the 16 scalar limbs positionally through four adv_pushw, so the signature recorded in the note is the signature that was verified only while the core-library procedures between the public key load and the scalar load consume no advice elements, a property that holds at the pinned miden-core-lib 0.29.1 and is positional rather than cryptographic.
Withdrawal Fee And Forwarding Terms Are Not Bound To The Burn Note
The burn note's withdrawal attachment commits only the amount, destination domain, destination recipient, and salt (XReserveBurnItems). The remaining redemption terms that the prepare request selects, including fee mode, finalDestinationCaller, useCircleForwarding, and the forwarding options (PrepareBurnIntentInput), are chosen by the off-chain attester alone.
Before signing, the returned intent is compared against the burn on value, destination domain and destination recipient only (check_spec); the returned maxFee, destinationCaller and hookData are never read. A response carrying unrequested terms is therefore signed unchecked, which can lower the amount released or restrict who may complete the redemption. The only in-repo builder hardcodes neutral terms, and the attachment is never read on chain, so exploitation needs a divergent response or a non-canonical attester deployment.
Consider fixing one canonical mapping from burn to redemption terms and rejecting any returned intent whose fee, caller, forwarding, or hook fields diverge from it, or committing those terms in the burn attachment.
Update: Resolved in pull request #195. The team stated:
“PR #195 addresses this finding in the existing attester by validating withdrawal fees, destination caller, and forwarding terms before signing. It also requires the payout plus fee to equal the burned amount.
For transparency, we are replacing this specific attester implementation with the lightweight attester introduced in the #194 stack. PR #217 carries the corresponding checks into the replacement logic and validates the returned signing hash against the checked fields. PR #217 is still a draft PR.”
Oversized hookData Strands Deposits Until Off-Chain Recovery
The Miden mint transport carries the attestation and the compressed deposit intent in a single note attachment. Its XUSDC_MINT_TRANSPORT_HOOK_DATA_MAX_LEN is computed as the per-attachment capacity less that fixed prefix, which leaves 3,840 bytes for hookData. HookData::new rejects anything longer, and the DepositIntent decoder applies the same bound to the declared length before reading the tail.
The source side enforces no matching limit. Circle's DepositToRemote takes hookData as a caller-controlled argument with no length check, and the client has confirmed that recovering such a deposit requires Circle and the remote-chain operator to coordinate a refund. A deposit carrying more than 3,840 bytes can therefore enter source custody and then have no representation the destination transport can express. Constructing the note by hand does not work around it, because XUsdcDeposit serializes into that one bounded attachment and the mint note's fixed construction offers no alternate transport path. The depositor holds no destination tokens, so the ordinary burn-backed withdrawal flow is unavailable and the funds stay locked until both parties act.
Within the audited code the rejection is clean: it mints nothing and consumes no deposit nonce, since the nonce is marked used only after every assertion passes. What is in scope is the size of the ceiling itself, only part of which the protocol imposes. A note may carry four attachments totalling 512 words while a single attachment holds 256, so placing the whole transport in one attachment halves the available budget, and a further 64 field elements go to formats this repository defines, three of them pure word alignment in the attestation prefix. The value is also recorded in the code as an open, Circle-owned question under DEV-6 rather than as a settled cross-domain interface.
Consider agreeing the ceiling with Circle as an explicit interface value, and stating it in the documentation as a requirement on the source domain rather than only as a destination capability. If the agreed value exceeds 3,840 bytes, consider spreading the transport across more than one attachment and removing the alignment padding from the attestation prefix, either of which raises the cap without a protocol change. Consider also having the source domain reject an oversized hookData atomically before custody is taken, with the destination check retained as defense in depth, so that recovery does not rest on a manual multi-party process.
Update: Partially Resolved in pull request #202. The repository overview, the architecture notes and the DepositIntent module documentation now state the 3,840-byte ceiling as a requirement on the depositor rather than only as a destination capability, and record that an oversized deposit cannot be claimed on Miden and leaves the USDC locked on the source chain. The ceiling itself is unchanged: XUSDC_MINT_TRANSPORT_HOOK_DATA_MAX_LEN still derives from a single attachment and retains the three felts of alignment padding, and the vector that pins the rejection still cites DEV-6 (REQUIRES CIRCLE CONFIRMATION), so the value is not yet an agreed cross-domain interface. The team reports that the source contract cannot cap hookData programmatically and that the in-house bridging interface will control the value it constructs.
Notes & Additional Information
Recipient Validation Accepts Unsupported Account Versions, Stranding Minted Funds
The mint policy gates the attested recipient with account_id::validate_structure, which requires only a non-zero version nibble, while the pinned protocol supports version one alone. A deposit naming any other version mints into a P2ID note that no account can consume, and the accept path records the deposit nonce, so the amount cannot be reclaimed. The stricter Rust decoder is not the security boundary.
Consider gating the recipient with account_id::validate, which also asserts the supported version, or recording the structural check as a deliberate forward-compatibility choice.
Update: Resolved in pull request #318. The Miden team documented the structural-only recipient check as an accepted trade-off.
Attestation Golden Vectors Are Not Checked For Cryptographic Self-Consistency
The att vector assertions check felt widths, packing, and the key commitment, but never recompute the payload digest or verify the signature against the emitted public key. No in-scope executing test consumes the digest or the signature: the attestation suite hard-codes one vector in ATTESTATION_VECTOR and signs it with locally generated keys. An internally inconsistent tuple is caught only outside the audited crate, where the deposit relayer's tests recompute each digest and verify each signature over the same artifact, though no on-chain decision rests on these vectors.
Consider recomputing each digest and verifying each signature inside the audited crate, and adding vectors with non-empty hookData.
Update: Resolved in pull request #215.
Unvalidated Golden-Vector Artifact Can Silently Lose Test Coverage
The canonical vector artifact is deserialized without deny_unknown_fields, and artifact_guard checks only the version, provenance tags, and six non-empty families, omitting mi, identifier uniqueness, and any required inventory. No filtered test asserts its selected count, so an artifact edit can silently drop coverage, such as an accepted amount vector that tv_amt_1_in_bound reaches only through an unasserted filter, while the suite stays green.
Consider enforcing a strict schema with typed kind discriminants, unique vector identifiers, and a required per-family inventory covering mi, with each filtered test asserting its selected count.
Update: Resolved in pull request #210 of the 0xMiden/miden-usdcx repository and in pull request #4060 of the 0xMiden/protocol repository. Every vector struct now rejects unknown fields. artifact_guard pins an exact row count per family, covers the previously unchecked mi family, constrains kind to accept or reject, and rejects duplicate vector identifiers across the whole artifact. Every test that narrows a family by kind, id, or an optional field now goes through a selected helper that asserts the number of rows selected. A filter that matches no rows, or fewer rows than the test expects, therefore fails instead of passing silently.
Mint-Intent Golden Vectors Are Derived From the Codec They Check
The generator's module doc claims the vectors are derived independently of the code they check, but the mi accept rows record carried_felts from the production MintIntent::to_elements in mi_accept, and the test tv_dual_6_carried_and_rebuilt_felts_match_the_vectors compares that method against its own output. A coordinated field-order change would survive regeneration there, though frozen mint-note anchors and the MASM cross-check still catch it.
Consider deriving the mi expectations from the generator's independent wire encoder, or narrowing the module doc to the families whose independence claim holds.
Update: Resolved in pull request #210.
Unused Domain Configuration Storage Slots
The faucet extension declares and seeds four value slots for its domain configuration, but only xusdc::xreserve::domain_config::domain is ever read on chain, by get_configured_domain while the DepositIntent preimage is rebuilt. The source_domain, xreserve_contract_hi, and xreserve_contract_lo slots are written at composition time and read back only by the test suites and the parked validation harness. No procedure in the account's MASM reads them, so the configured source domain and source-chain xReserve contract address place no constraint on which deposits the mint policy admits.
Binding them on chain would take more than a storage read. The signed DepositIntent wire form carries no source-domain field and no xReserve contract address, so the attested message holds nothing for the faucet to compare the two configured values against; the only source-side fields rebuild handles are the source-chain token and depositor, which it copies into the preimage verbatim. The faucet also installs no setter for any value slot, its only storage writes being the attester allowlist and the used-nonce registry, so all three values are fixed at deployment and a mis-seeded or later-stale entry produces no on-chain failure. A reader inspecting the deployed account's storage may reasonably infer that a source_domain slot constrains which deposits the faucet accepts, which it does not.
Consider removing the three slots if the values are only required off chain, or documenting them in the component as published metadata rather than as an enforced constraint. If the faucet is instead meant to enforce them, consider raising the missing source-domain and origin-contract fields with Circle, since the current wire form gives the on-chain policy nothing to check them against.
Update: Resolved in pull request #211.
Misleading Comments
Several comments describe behavior the code does not have. Each one would send a maintainer or a reviewer looking for a slot, a test, or a guarantee that is not there.
In the MASM sources:
- The
Localsblock that documentscheck_policydescribes a frame the procedure does not use. Four entries diverge from the code: - Slots
10and11are listed for the carried hookData length and the note asset amount, but no constant names either slot and the procedure never writes them. Both values reside in the frame ofdeposit_intent::rebuildand on the operand stack. - The rebuilt
DepositIntentpreimage is listed at32..1056, but it is not a local.rebuildwrites it to global memory atDEPOSIT_INTENT_PTR. - Every transport offset is consequently 1024 too high.
TRANSPORT_LOCis32, so the hash-verified attachment occupies32..1056, its pubkey32..48, its signature and pad48..68, its carried payload68..96, and its packed hookData96onward. The documented upper bound of2080also exceeds the@locals(1056)frame. - The carried payload entry spans 24 felts, while
MINT_INTENT_FELTSis 28. - The comment on
DEPOSIT_INTENT_PTRexplains thatrebuildsets only the fields the message actually carries, and therefore depends on the region reading as zero. It then states thatrebuild_depends_on_a_zeroed_regionpins that dependency so it cannot go invisible. No test of that name exists in the repository.
In the Rust sources:
- The
DEPOSIT_SCALE_EXPdoc comment states thatconstant_parity.rspins the value so that a change fails a test. That file asserts nothing about the constant, and its own comment defers in turn to a test nameddeposit_scale_exp_is_zerothat exists nowhere in the repository. The value is pinned, by theamtgolden vectors and by the mint-scale conformance suite, so both comments point a maintainer at a guard that is not where they say it is. - The
artifact_guarddoc comment claims that every vector family is non-empty and that every entry carries provenance and spec-row tags. The test checks six families and omits the mint-payload family, so an empty or provenance-freemifamily would pass a guard whose comment presents it as covered. This one misdescribes the coverage of a guard rather than a layout, so it is the instance most likely to let a real gap through. - The generator's documented regeneration command is
cargo run --bin gen_vectors, which fails because the binary declares required features. The manifest's own comment carries the working invocation. - The
BLK_MANAGERdoc comment closes by stating that the role is seeded role id 4. The stock role-based access control keys roles by symbol in an ordered map and defines no numeric role id, so the sentence names a value that has no referent in the code.
Consider correcting the comments to increase code clarity.
Update: Resolved in pull request #4029 of the 0xMiden/protocol repository and in pull request #210 and pull request #231 of the 0xMiden/miden-usdcx repository.
Relationship Between The Faucet Blocklist And The xReserve Blocklist Is Undocumented
Two blocklists apply to the same Miden accounts. On the source chain, xReserve maintains one keyed by remote domain and remote address, administered by its blocklister role, and checks it on both cross-domain paths: a deposit naming a blocklisted recipient is rejected during input validation, and a withdrawal whose remoteDepositor is blocklisted is rejected before funds are released. On Miden, the faucet carries its own transfer blocklist, administered by a dedicated role. Neither list is derived from the other, and the repository contains no mechanism or procedure that keeps them in sync. No public documentation states whether a partner chain is expected to enforce xReserve's list. The deviations document describes the faucet blocklist as a local addition with no Circle counterpart, which omits that xReserve maintains its own list over Miden accounts.
If xReserve's list were meant to be enforced on Miden, the divergence would leave it ineffective on the withdrawal path. The withdrawal entry point checks only the remoteDepositor carried in the attestation hook data. The withdrawal listener takes that value from the burn note's sender and forwards it unchanged, while the faucet transfer policy rejects only accounts in its own map, which is empty at deployment. An account on xReserve's list could then withdraw its balance as follows:
- The account transfers its xUSDC to a newly created Miden account. The faucet transfer policy permits the transfer because the sender has no entry in the faucet map.
- The new account burns the xUSDC. The burn note records the new account as its sender, so the withdrawal carries it as
remoteDepositor. - The source-chain blocklist check passes on the new account, and the reserve releases the funds.
A listed holder who burns directly instead has the withdrawal rejected on the source chain after the tokens are destroyed on Miden. The funds then remain in the reserve, and no faucet administrator action can recover them. The deposit-side check is unaffected, so a listed account cannot receive new deposits. Separately, the test suite covers only the receive direction of the faucet blocklist, and does not show that a listed account cannot send or create a burn note.
Consider documenting in docs/DEVIATIONS.md whether Miden is expected to enforce xReserve's blocklist, that the two lists are maintained independently with nothing keeping them in sync, that an account on xReserve's list can withdraw by first moving its balance to another Miden account, and that a listed account which burns directly leaves its funds in the reserve without a recovery path. If enforcement is intended, the faucet blocklist would need to be populated from xReserve's list through a defined operational procedure. Consider also adding a test that an account on the faucet blocklist cannot create a transfer or burn note.
Update: Resolved in pull request #258 and pull request #318 of the 0xMiden/miden-usdcx repository and in pull request #4054 of the 0xMiden/protocol repository. docs/DEVIATIONS.md now records that xReserve maintains its own blocklist over Miden accounts, that the two lists are maintained independently and nothing copies entries between them, that Circle does not require the partner chain to enforce its list, that a direct burn by an account on xReserve's list leaves the corresponding USDC in the reserve with no recovery path, and that such an account can still withdraw by first transferring its USDCx to another Miden account. The added transfer_blocklist_semantics.rs test funds a holder through the attested mint path on the production faucet and shows that, once the BLK_MANAGER role holder blocks it, the holder can create neither a transfer note nor a burn note.
Conclusion
The audited code implements the Miden side of Circle's xReserve deployment of USDCx. Two things ship in the crate: the hand-written MASM the faucet executes on-chain, and the Rust library that mirrors the shared wire formats for the off-chain services that consume them (the deposit relayer and the withdrawal listener/attester).
On-chain, the crate adds exactly two procedures to the stock fungible faucet. The first is the attestation mint policy, the account's only supply-increasing gate, which the stock mint_and_send dispatches on every mint. It validates the fields the mint note carries, rebuilds Circle's 240-byte DepositIntent preimage from those fields plus the faucet's own configuration instead of parsing a message handed to it by the caller, verifies the secp256k1 attestation over that preimage against a Poseidon2 public-key commitment held in the attester allowlist, and registers the deposit nonce so the same deposit cannot mint twice. The second is the allowlist setter, gated to the built-in ADMIN role and delivered by a public admin note. Withdrawals reuse the stock burn path under a minimum-amount burn policy; the destination domain, recipient and salt travel in a note attachment that no on-chain code reads, because they exist as evidence for Circle's off-chain attester rather than as input to the burn.
In Rust, the crate holds the mirror of every shared format (bytes32 hashing, the uint256 to AssetAmount reduction, the AccountId and DepositIntent codecs, the carried mint payload and the burn payload), the factories for the mint, burn and admin notes, the builder that composes the deployable faucet from the stock fungible faucet plus the standard pause, blocklist, fee and role-based access control components, and the golden vectors that both the Rust unit tests and the MASM execution tests read from a single file. The deployed faucet is a keyless network account, so its note-script allowlist and fee schedule are its authorization model, and the builder fixes both at construction time.
The engagement produced findings at high and medium severity, together with a number of low severity and informational observations. None of the more severe ones is a defect in the MASM's own arithmetic or codecs. Those are carefully written, and the crate executes them on a mock chain rather than only assembling them. The issues that matter are at the seams instead. Composition decisions and the boundary with the off-chain services, rather than the code written to run on-chain, account for most of what the review found.
The development team was responsive throughout the engagement.
Update: Following the audit, the Miden team addressed the findings in this report. The audited crate was moved to the 0xMiden/protocol repository as crates/miden-usdcx, and the fixes were reviewed up to commit 415b5006 of that repository and commit 2464f8f of the 0xMiden/miden-usdcx repository. Of the 19 findings, 13 have been resolved, 2 have been partially resolved, and 4 have been acknowledged by the team, which accepts the corresponding risks as documented in the respective issues.
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.