Current Flaws & Doubts
Known issues and open questions in the reference implementation, organized by category.
Cryptographic / Design Flaws
1. Per-operation key derivation adds complexity without security benefit
Location: All circuits (circuits/transfer/src/main.nr, circuits/stake/src/main.nr, circuits/unstake/src/main.nr), packages/compliance
The current design derives a unique compliance key per operation:
P_op = quorumPK + H(DOMAIN_UKRC, op_id)·G
For transfer and stake, op_id is computed entirely from public inputs (ciphertext components, commitment + leaf index). The quorum can see these on-chain, compute op_id, and derive the per-op secret key sk + H(DOMAIN, op_id). The "salting" provides zero additional unlinkability — the quorum can decrypt and correlate everything it wants.
The stated motivation (reference/docs/UKRC_IMPLEMENTATION_PLAN.md, lines 5, 27, 347) is "selective disclosure" and "forward secrecy via unique keys." Neither holds:
- Selective disclosure: the quorum holds
skand can compute everyop_idfrom public on-chain data, so they can decrypt everything — there is nothing selective about it. If the goal were true selective disclosure (proving a specific transaction's amount to a third party without revealing the quorum key), a simpler and correct approach is to produce a SNARK of the decryption — no per-operation key derivation needed. - Forward secrecy: compromising
skat any point reveals all past per-op secrets (sk + H(DOMAIN, op_id)for every publicop_id), so the derivation adds no forward secrecy.
Plain quorumPK without derivation would be simpler and functionally equivalent.
2. Unstake compliance encryption is non-functional
Location: circuits/unstake/src/main.nr:247-254
The unstake ID depends on first_nullifier:
fn compute_unstake_id(first_nullifier: Field, recipient_bpk_x: Field, recipient_bpk_y: Field) -> Field {
let recipient_hash = hash2(recipient_bpk_x, recipient_bpk_y);
hash3(first_nullifier, recipient_hash, DOMAIN_UKRC)
}
first_nullifier is a private input — it never appears in public inputs or on-chain events. The quorum cannot compute the per-op key and therefore cannot decrypt unstake amounts.
The encryptedPreimage emitted in the Staked event contains (amount, blinding) for disaster recovery, but this doesn't help: unstake compliance needs (amount, recipient_bpk), which is only known at unstake time.
Architecture Limitations
These are inherent to the design rather than implementation bugs. Worth documenting for team awareness.
3. Relayer is permissionless and has no gas protection
Location: packages/relayer/src/routes.ts, packages/relayer/src/index.ts
Anyone can call any proof-submission endpoint without authentication. The only gate is IP-based rate limiting (60 req/min), trivially bypassed with IP rotation. An attacker can send bogus proofs that revert on-chain, burning relayer gas at no cost to themselves.
The relayer pays gas from a single EOA for every submitted transaction. There is no gas budget, no circuit breaker, no off-chain proof pre-verification, and no relationship between relayer spending and protocol revenue. It will submit transactions until the wallet is empty.
Stronger authorization (e.g., requiring user signatures) conflicts with privacy goals — the relayer would learn who is transacting. The right trade-off between gas protection and privacy is an open design question.
4. Nullifier IMT creates an unsolvable relayer dilemma
The nullifier IMT design forces a choice between two broken options:
Option A: Nullifiers are private, relayer is privileged. This is the current design. Individual nullifiers never appear on-chain (only the root transitions are visible). The relayer is the sole entity that tracks the full IMT state, because it must compute insertion witnesses for unstake proofs. Consequences:
- The relayer can censor users by refusing to compute witnesses, effectively locking their staked funds.
- The relayer can observe which nullifiers are being requested, leaking metadata about who is unstaking.
- Users cannot run their own relayer without first reconstructing the full IMT — but this is impossible from on-chain data alone, since nullifiers aren't in events. A new relayer can only bootstrap by copying state from an existing one.
- If the relayer loses its state, all staked funds become unrecoverable (nobody can produce valid IMT witnesses).
Option B: Nullifiers are published on-chain, relayer is permissionless. Emitting nullifiers in the Unstaked event would let anyone reconstruct the IMT from on-chain data. This makes the relayer role permissionless — anyone can sync and serve witnesses. But:
- Published nullifiers let the compliance quorum (or anyone with the stake
encryptedPreimage) computeH(blinding, stakeIndex, 0)and check if it matches a published nullifier, linking stakes to unstakes and breaking L3's graph privacy. - Concurrent unstakes still race on
nullifierRoot(see flaw #6), but at least recovery and decentralization become possible.
The current implementation chose Option A without acknowledging the liveness risk. Neither option is clearly better — it's a fundamental tension between privacy and permissionlessness that the design doesn't resolve.
5. Single in-flight transfer per sender
The contract checks old_balance == stored_balance before accepting a proof. If a second operation is submitted before the first settles, it reverts with OldBalanceMismatch. This affects transfer, unshield, and stake — any operation where the sender's encrypted balance is a public input. Since incoming transfers also modify the recipient's stored ciphertext (via on-chain EC point addition), anyone can grief a user by sending them a transfer while they have a proof in flight.
6. Concurrent unstakes are serialized by nullifier root
Two users requesting IMT witnesses concurrently both prove against the same nullifierRoot. When the first transaction lands, the root changes and the second transaction reverts (InvalidNullifierRoot). The second user must re-fetch the witness, regenerate the proof (~28s), and resubmit.
Unlike the stake tree (which accepts historical roots), the nullifier IMT cannot — accepting a stale nullifier root would allow double-spends. This means unstake throughput is fundamentally limited to one transaction per block, creating a bottleneck under load.
7. L1/L2/L3 nomenclature conflicts with standard blockchain terminology
The reference project uses "L1" (public), "L2" (encrypted), "L3" (anonymous) to name its privacy layers. In blockchain, L1 and L2 already mean base chains and rollups. This system is itself deployed on Base (an L2), making the project's "L2" a privacy mode inside an actual L2. The naming should be replaced — e.g., "public layer", "encrypted layer", "anonymous layer" — to avoid confusion.
8. "Staking" conflicts with standard blockchain terminology
The reference project uses "stake" and "unstake" to mean depositing into and withdrawing from the anonymous pool. In blockchain, staking universally refers to locking tokens for consensus participation or yield. Using the same term for a privacy deposit is misleading.
9. shieldWithPermit BPK is not covered by the permit signature
Location: contracts/src/EBEMT.sol:483-512
The EIP-2612 permit signature covers (owner, spender, amount, nonce, deadline) but not the bpk parameter. An attacker who observes a shieldWithPermit transaction in the mempool can frontrun it with the same permit signature but a different bpk, redirecting the shielded funds to their own encrypted balance.
The other relayer-compatible functions (registerBPKWithSig, setAutoshieldWithSig) use custom EIP-712 signatures that bind all parameters. shieldWithPermit does not — it reuses the standard permit(), which has no knowledge of the BPK.
10. Unshield recipient is not bound by the proof
Location: contracts/src/EBEMT.sol:673-721, circuits/unshield/src/main.nr
The unshield circuit's public inputs are sender_BPK, old_balance, new_balance, and unshield_amount. The recipient address is not a public input — it is a separate parameter passed to the contract. An attacker who observes an unshield transaction in the mempool can frontrun it with the same proof and public inputs but a different recipient, stealing the minted tokens. The original transaction then reverts with OldBalanceMismatch since the sender's balance was already updated.
11. UKRC compliance encryption does not bind the transfer amount
Location: circuits/transfer/src/main.nr:178-210
The transfer circuit proves that the UKRC ciphertext (R, C) is a correctly formed ElGamal encryption under the derived per-transfer compliance key. However, it does not constrain the encrypted value to equal the actual transfer_amount used in the balance update. The UKRC encryption uses a separate variable, and no assertion links it to the transfer amount. A malicious prover can transfer 100 tokens while encrypting 50 (or 0) in the UKRC ciphertext. The compliance quorum would then see a false amount and have no way to detect the discrepancy from on-chain data alone.
12. complianceData mapping is declared but never written to
Location: contracts/src/EBEMT.sol:84
The contract declares mapping(bytes32 => bytes) public complianceData but no function ever writes to it. UKRC compliance data is only available through PrivateTransfer event parameters — it is never persisted in contract storage.
13. Burn BPK has a known discrete log — "burned" funds are spendable
Location: packages/core/src/l3/crypto.ts:114-124, circuits/transfer/src/main.nr:284-288
computeBurnBPK produces the burn address as scalarMulBase(poseidon2Hash([domain, stakeRoot, burnSalt])). All three inputs are known to the user: domain is a hardcoded constant, stakeRoot is on-chain, and burnSalt is given to the user by the emitter. The user can therefore recompute the Poseidon hash and use it as a spending key.
The transfer and unstake circuits validate BPK ownership as scalar_mul_base(sender_sk) == sender_BPK. Since burnBPK = scalar · G, the hash output is a valid sender_sk. After claiming fiat redemption, the user can transfer the "burned" tokens back to themselves and repeat indefinitely.
The design doc (docs/internal/redemption.md:376-388) specifies RFC 9380 hash-to-curve, which would drop the discrete log, but the implementation diverges.
14. Swap amounts are not bound by the proofs — MakerBot trusts taker's claimed amounts
Location: contracts/src/SwapRegistry.sol:156-208, scripts/maker-bot.ts:444-494
atomicSwap takes takerGives and takerReceives as plain uint256 parameters and checks they are rate-consistent (line 182), but never verifies they match the encrypted transfer amounts inside the ZK proofs. The transfer circuit commits the amount only as an ElGamal ciphertext (public inputs [10–13]), not as a plaintext field.
The MakerBot validates only rate consistency (line 444), then generates a real proof sending takerReceives tokens to the taker without decrypting the taker's ciphertext. A taker can generate a valid proof transferring 0 tokens to the maker while claiming takerGives=1000. The MakerBot sends real tokens in return. Both proofs verify on-chain.
The maker holds the decryption key for the taker's transfer ciphertext (it's encrypted to the maker's BPK) but never uses it. Decrypting and comparing before generating the counter-proof would close the vulnerability.
15. encryptedPreimage in stake() is not bound by the proof
Location: contracts/src/EBEMT.sol:787-800, circuits/stake/src/main.nr:241-274
The stake() function accepts encryptedPreimage as a separate bytes parameter intended for disaster recovery — it contains the encrypted (amount, blinding) behind the stake commitment. This parameter is passed only to _emitStaked() (line 799), which emits it verbatim in the Staked event (line 872). It is never passed to the verifier. The stake circuit's 20 public inputs include stake_commitment (a Poseidon hash) but not the encrypted preimage.
A front-runner who observes a stake() transaction can copy the valid proof and publicInputs, replace encryptedPreimage with garbage, and submit with higher gas. The proof verifies, the leaf index is consumed, and the victim's transaction reverts. The Staked event now contains corrupted recovery data. If the victim later needs to recover their stake from on-chain data (e.g., lost local state), the (amount, blinding) are unrecoverable.
Relayer nullifier tree: poisoning (16) and broken recovery (17)
16 and 17 are independent bugs, but together they are worse than the sum of their parts. 16 allows an attacker to corrupt the relayer's local nullifier IMT, breaking unstake operations for all users. The natural response is for relayer to resync from on-chain data — but 17 means the resync path is itself broken, reconstructing a tree filled with garbage values. The combination turns a recoverable DoS into a potentially permanent one: the relayer has no automated way to restore a correct nullifier tree, because nullifiers are private circuit inputs that never appear on-chain.
16. /api/unstake accepts user-supplied nullifiers without verification
Location: packages/relayer/src/routes.ts:306-398
The relayer maintains a local copy of the nullifier IMT so it can compute insertion witnesses for users who want to unstake. When a user calls /api/unstake, they include a nullifiers array in the request body (line 312). After the on-chain transaction confirms, the relayer inserts these client-supplied values into its local tree (lines 365-371).
The relayer has no way to verify these values are correct. Nullifiers are private circuit inputs — they don't appear in the proof's public inputs, in on-chain events, or anywhere else the relayer can observe. The on-chain contract doesn't need them individually; it only checks that the proven oldNullifierRoot matches the stored root (line 928) and then overwrites it with the proven newNullifierRoot (line 942). The ZK proof guarantees correctness of the root transition, but the relayer never sees the proof internals.
A malicious user can submit a valid unstake proof while providing fabricated nullifiers. The on-chain transaction succeeds — the contract updates the nullifier root correctly via the verified proof. But the relayer inserts wrong values, and its local tree diverges from the chain. The /api/imt-witness endpoint (lines 62-139) then serves corrupted Merkle witnesses to all subsequent users. Their proofs contain an incorrect oldNullifierRoot, which the contract rejects. One poisoned request breaks unstaking for everyone using this relayer.
The integrity checker detects the root mismatch but does not auto-resync in the unstake path — it logs a warning and proceeds (lines 337-342). Recovery requires triggering fullResync(), which is broken for a separate reason (see 17).
17. Relayer resync parses ciphertexts and BPK coordinates as nullifiers
Location: packages/relayer/src/chain.ts:270-309, packages/relayer/src/integrity.ts:247-284
getNullifiersFromTx reconstructs nullifiers from historical unstake transactions by decoding the calldata's publicInputs array. It assumes the layout is [stakeRoot, oldNullifierRoot, newNullifierRoot, ...nullifiers] and reads everything from index 3 onward as nullifiers (line 298). The actual layout is [stakeRoot, oldNullifierRoot, newNullifierRoot, transferCt(4), recipientBPK(2), quorumPK(2), UKRC(4)] — 15 fields total, none of which after index 2 are nullifiers. They are ElGamal ciphertext components, BPK coordinates, and compliance encryption points.
This function is the core of fullResync() (integrity.ts:272), the relayer's disaster recovery path. When resync runs — after a crash, disk loss, or manual trigger via /api/resync — it resets the local IMT and rebuilds it by calling getNullifiersFromTx on every historical Unstaked event. Each call inserts ~12 garbage values (ciphertext components, curve points) into the tree. The resulting root will never match the on-chain nullifier root, so the integrity check fails permanently.
This means the relayer's only recovery mechanism silently produces a corrupt tree. Normal operation is unaffected — the /api/unstake request path receives nullifiers from the user rather than parsing calldata. But any scenario that triggers resync (including as a response to the poisoning attack in 16) leaves the relayer permanently broken until someone patches the code.