UPP — Universal Private PoolSTARK Vault

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

#IssueSeverityClassMitigation status
1M31 token-address collisionCriticalSecurityTactical fix available; protocol fix designed
2Permissionless tokens on by defaultHighSecurityOne transaction, zero code
3Manual flush after V4 transferMediumUXService design; demo behavior intentional
4Per-tx RPC gas capMediumInfrastructureWorked around by Plan B-1
5SP1 gateway is a single operatorMediumInfrastructureOpen-sourcing + failover queued
6ASP service is single-tenantMediumInfrastructureMulti-operator design queued
7STARK note payloads are plaintextMediumPrivacyDesign constraint of M31; documented
8deployBlock overwrite on impl upgradesLowOperationalOne-line script fix queued
9Single-source Subsquid dependencyLowOperationalRPC fallback for live blocks only
10Stuck-note risk if no one flushesLowUXSelf-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 (p=2311p = 2^{31}-1). 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

  1. Pick a target token A_target (e.g. a real stablecoin). Compute m31_target = uint160(A_target) mod (2^{31}-1).
  2. Grind for a CREATE2 salt that yields a contract address A_fake such that uint160(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.
  3. Deploy a fake ERC20 at A_fake. Mint yourself a balance.
  4. Shield 1000 A_fake into the pool. The commitment binds m31_target (which equals m31(A_fake)), so the deposit looks indistinguishable from a deposit of the real token.
  5. Generate a withdraw proof for that note, passing A_target (the real token) as the withdrawal token.
  6. The verifier accepts: the proof's M31 token signal matches m31_target, the nullifier is fresh, the Merkle path is valid.
  7. 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 = true on the current Sepolia deployment (see item 2)
  • No on-chain commitment → token binding 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) — pops maxN entries 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.

On this page