Known Limitations
What is not yet production-ready in the V4 STARK pool — collision risks, UX seams, infrastructure dependencies, and the mitigations that are queued before any non-demo release.
Read before integrating
The V4 STARK pool works end-to-end on Sepolia, but several limitations are intentionally documented here rather than papered over. None of them invalidate the underlying cryptography; most are infrastructure, UX, or deploy-time gaps that need to close before this becomes a public-facing product. If you're evaluating the protocol for production, this page is the honest answer to "what would block you?"
Severity rollup
| # | Issue | Severity | Class | Mitigation status |
|---|---|---|---|---|
| 1 | M31 token-address collision | Critical | Security | Tactical fix available; protocol fix designed |
| 2 | Permissionless tokens on by default | High | Security | One transaction, zero code |
| 3 | Manual flush after V4 transfer | Medium | UX | Service design; demo behavior intentional |
| 4 | Per-tx RPC gas cap | Medium | Infrastructure | Worked around by Plan B-1 |
| 5 | SP1 gateway is a single operator | Medium | Infrastructure | Open-sourcing + failover queued |
| 6 | ASP service is single-tenant | Medium | Infrastructure | Multi-operator design queued |
| 7 | STARK note payloads are plaintext | Medium | Privacy | Design constraint of M31; documented |
| 8 | deployBlock overwrite on impl upgrades | Low | Operational | One-line script fix queued |
| 9 | Single-source Subsquid dependency | Low | Operational | RPC fallback for live blocks only |
| 10 | Stuck-note risk if no one flushes | Low | UX | Self-healing in practice |
The rest of this page expands each item with a precise statement of the exposure and the mitigation we plan to land — or have already landed.
1. M31 token-address collision attack
The Circle STARK AIR operates over Mersenne-31 (). Token addresses (160-bit Ethereum addresses) are folded into a single M31 field element via uint160(addr) mod (2^{31}-1). The AIR seed binds the proof to that 31-bit value, not to the full 160-bit address.
The on-chain safeTransfer at withdraw time uses the full address the user supplies in the call data — not anything derived from the proof.
The attack
- Pick a target token
A_target(e.g. a real stablecoin). Computem31_target = uint160(A_target) mod (2^{31}-1). - Grind for a CREATE2 salt that yields a contract address
A_fakesuch thatuint160(A_fake) mod (2^{31}-1) == m31_target. With 31 bits of constraint, expect a hit in ~2 billion attempts — minutes on a single GPU. - Deploy a fake ERC20 at
A_fake. Mint yourself a balance. - Shield 1000
A_fakeinto the pool. The commitment bindsm31_target(which equalsm31(A_fake)), so the deposit looks indistinguishable from a deposit of the real token. - Generate a withdraw proof for that note, passing
A_target(the real token) as the withdrawal token. - The verifier accepts: the proof's M31 token signal matches
m31_target, the nullifier is fresh, the Merkle path is valid. - The pool calls
safeTransfer(A_target, attacker, 1000). The attacker walks away with 1000 real tokens they never deposited.
Why this is exploitable today
permissionlessTokens = trueon the current Sepolia deployment (see item 2)- No on-chain
commitment → tokenbinding exists; the contract trusts the call-data token because the proof was checked separately - The 31-bit collision space is small enough that this is not a theoretical attack
Mitigations, ranked by cost
Short term — tactical, zero code. Call pool.setPermissionlessTokens(false) and pool.addSupportedToken(<allowlist>) for the tokens we explicitly want available on Sepolia (e.g. UPD). The collision attack requires depositing a token the pool will accept; an allowlist closes that boundary entirely. This is the right posture for any public-facing demo until the protocol fix lands. It's a deliberate retreat from the permissionless design, not the end state.
Medium term — Solidity-only fix. Add a mapping(bytes32 commitment => address token) _tokenForCommitment to pool storage. On shield, write the full token address. On withdraw and transfer, require the call-data token to match the stored value. Cost: one SSTORE per shield (~20K gas), one SLOAD per spend. No circuit change, no new verifier, no ELF rebuild. This is the cheapest fix that preserves the permissionless-token design and is the one we expect to land first.
Long term — circuit-level fix. Bind the full 160-bit token address into the AIR seed via keccak256(token) instead of _addrToM31(token). Requires a new VKEY, a new SP1 verifier registration via StarkVerifierManager, and an ELF rebuild. Forecloses the attack at the proof layer and removes the need for the Solidity mapping. Larger blast radius, so it's queued after the SDK and indexer have stabilized on V4.
What you should do today
If you are running anything other than friendly-audience testing against this Sepolia deployment, do not assume the token address you withdraw is the token address that was originally shielded. The tactical mitigation (permissionlessTokens = false + allowlist) closes the gap until a protocol fix lands.
2. Permissionless tokens on by default
permissionlessTokens = true on the current Sepolia pool. Any ERC20 can be shielded without an explicit allowlist entry. Combined with the M31 collision attack, this is the immediately-exploitable path.
The design intent is permissionless — that's a deliberate choice that distinguishes the pool from token-specific privacy contracts. We're not abandoning it. But until a protocol-level fix for the M31 collision exists, permissionlessTokens = false plus a small allowlist is the correct interim posture. Cost: one transaction. No code change.
3. Manual flush required after V4 transfer
The V4 octary tree's insert cost (~14M gas per leaf in the worst case) does not fit under the per-transaction gas ceiling that every public Sepolia RPC enforces. We resolved this with the Plan B-1 commit/flush split:
transferSTARKV4Commit— verifies the proof and enqueues two commitments into a FIFO queue. ~230K gas.flushPendingV4Inserts(maxN)— popsmaxNentries off the queue and inserts them into the tree. ~12M gas per leaf. Anyone can call this.
A single transfer enqueues two commitments. Both must be flushed before the recipient's note becomes spendable. In the demo UI we expose a Flush 1 button that the user (or anyone watching the pool) clicks once per pending entry.
In a production deployment, a flusher service — a permissionless cron / sidecar that calls flushPendingV4Inserts whenever the queue has work — would drain the queue automatically. We have not written that service for the demo. The function is intentionally permissionless so anyone can run it.
What's blocking automation: nothing technical. The service is ~50 lines of code. It hasn't been written because the demo's pedagogy is improved by showing the queue drain transaction-by-transaction.
4. Per-tx RPC gas cap is load-bearing
Every public Sepolia RPC we tested — Infura, Alchemy, dRPC, Tenderly, Chainstack, BlockPI — enforces a per-transaction gas cap of 16.77M (= 2²⁴). This matches the default rpc.gascap value in Geth and is industry-wide on hosted RPC providers. It is not Sepolia's block gas limit (30M); it's an RPC-side pre-validation guard.
A monolithic V4 transfer at ~28M gas would be rejected by every public RPC before the wallet ever submits it. The same applies to integrators running self-hosted RPCs with default settings.
Plan B-1 was designed around this constraint, not in spite of it. Documented here because integrators evaluating the protocol should understand that the commit/flush split is not arbitrary — it is the unavoidable consequence of the RPC ecosystem's gas-cap posture.
Implication for self-hosted deployments: if you control your own RPC node and set --rpc.gascap=0 (unlimited), you can in principle bundle a transfer + flush into one transaction. We don't recommend this — Plan B-1 also gives you better tx-replay properties — but it is technically possible.
5. SP1 gateway is a single operator
The Circle STARK proofs we generate locally are wrapped in a Groth16 proof — the wrapping is what makes them affordable to verify on-chain. The wrapping itself happens on prover.upd.io, a single operator-run service backed by SP1's network mode.
The exposure: if prover.upd.io is offline, slow, or returns malformed responses, no STARK transfer or withdraw can complete. A user can still shield (no wrap required) but cannot spend.
What this does NOT mean: the gateway is not trusted for soundness. It cannot forge a proof; the Groth16 wrap is verified on-chain against a canonical SP1 verification key. The worst it can do is refuse service or sign a wrapped proof for someone else's input — both of which fail downstream verification.
Mitigations queued:
- Open-source the gateway image. The Docker image used in our ECS deployment will be published so other operators can run their own.
- SDK-level failover. Allow callers to configure multiple gateway URLs, falling back on timeout / non-200.
- Multiple operators. As the protocol matures, we'd expect at least 3 independent gateway operators.
None of these have shipped yet. Today's reality is: this is a centralization point.
6. ASP service is single-tenant
asp-whitelist.upd.io is the live ASP (Association Set Provider) for the current Sepolia pool. It is a single ECS task, running with a single operator key, indexing a single deployment.
The exposure:
- The operator key is the only signer for ASP root updates. Compromise of that key compromises the ASP root.
- A single Subsquid API key (see item 9) backs the historical catch-up. Loss of that key stalls catch-up.
- Single ECS task; an outage takes down ASP root publishing until it recovers.
What this does NOT compromise: the privacy of any individual user, the soundness of any proof, or the spendability of existing notes. ASP compromise is a compliance boundary, not a custody boundary.
Mitigations queued: multi-operator ASP signing (e.g. Safe-multisig-controlled root publishing), decentralized indexing, multi-replica deployment.
7. STARK note payloads are plaintext
The 32-byte note payload emitted in Shielded and Transferred events is little-endian-packed plaintext: (scaledAmount, ownerHash[16B], blinding, originM31, tokenM31). There is no ECIES envelope, no AES-GCM, no encryption at all.
Why this is acceptable in the M31 setting: the AIR enforces commitment integrity using the same M31-truncated values, so a sender cannot transmit a different note than they prove. The 16-byte ownerHash is the only secret that gates spendability, and it's derived from the recipient's private key. Plain visibility of the rest of the note's fields does not reveal who owns the note.
Why this is still a privacy delta vs SNARK notes: SNARK notes use AES-GCM under an ECIES-derived shared secret, so even amounts are private. STARK note amounts are visible on-chain. An observer can see "a transfer happened, amount = 1.2345, output token's m31 value is X" — they just can't tell who sent it, who received it, or which preceding shield it consumed.
This is a deliberate trade for now: making STARK notes encrypted in the M31-only setting would require either an in-circuit symmetric primitive (expensive) or a separate side-channel for the encrypted bundle (engineering work that hasn't shipped). It is not a soundness issue; it is a confidentiality trade-off we accept for the demo and will revisit before a non-demo release.
8. Deploy script overwrites deployBlock
DeployScript.sol writes block.number into metadata.deployBlock unconditionally on every deploy — including impl-only UUPS upgrades where the proxy address is unchanged. Indexers using deployBlock as their "scan from here" anchor will then miss every event between the proxy's original deploy and the latest impl upgrade.
This bit us once already: a fresh --only pool upgrade rewrote deployBlock to a block ~50K later than the proxy's true deploy, and the indexer silently skipped past every existing user's notes. The fix was to revert the JSON manually and rerun the indexer.
Mitigation: one-line guard in the script:
if (existing == 0) {
metadata.deployBlock = block.number;
}Queued for the next deploy.
9. Single-source Subsquid dependency
The ASP service's historical catch-up relies on Subsquid for fast indexing. As of 2026-05, Subsquid requires API keys (SQD_KEY env var) for the EVM archive. Loss of access to Subsquid does not break live ASP updates — those run from RPC — but it stalls historical catch-up if the service ever restarts cold.
The exposure: a brief operational dependency. Not a custody or privacy concern.
Mitigation: a secondary historical indexer is the long-term answer. RPC-only catch-up is workable on the modest history depth we have today.
10. Stuck-note risk if no one flushes
A user who commits via transferSTARKV4Commit is relying on someone subsequently calling flushPendingV4Inserts to make the recipient's note spendable. The flusher is permissionless — anyone, including the sender, the recipient, a watcher service, or a third-party MEV searcher — can drain the queue.
The risk in the absolute worst case: if no one ever flushes, the queued commitments sit in the FIFO indefinitely. The sender's note is gone (nullifier is recorded at commit time), and the recipient's note is not yet in the spendable tree.
Why this is self-healing in practice: anyone holding a pending receive has the incentive to flush. The cost (~12M gas, ~$0.50 on Sepolia) is small compared to the value of the note. Once a flusher service exists (item 3) it removes the manual dependency entirely.
Out of scope for this page
The audit-and-compliance system, viewing key hierarchy, ragequit semantics, and SNARK side of the pool are not enumerated here — they have their own design constraints documented under Concepts. This page is specifically about the V4 STARK pool's transition gap: what works end-to-end but is not yet ready for an unfriendly audience.
If you are evaluating the SNARK side, the high-level invariant is: shield, 1-in-2-out transfer, and withdraw are exercised end-to-end on Sepolia. Merge is implemented but has not been exercised in the demo flow and has a parked compliance hardening (no on-chain ASP check on merge inputs today; the hardening is on a feature branch awaiting a decision on whether the demo needs it). That gap is internal to the protocol and is not what this page is about.
Keccak Channel & Proof Serialization
Custom Fiat-Shamir channel replacing Blake2s with Keccak-256 for EVM-native hashing, and the deterministic proof serialization format.
Universal Private Compliance
Pluggable ZK compliance framework for institutions and governments — ASP infrastructure with PLONK proofs over BLS12-381, no per-circuit trusted setup.