Incident: shielded send/unshield broken (merkle root mismatch)
Found 2026-06-20 while testing the staking app's "Send anonymously to ETH address" flow. The wallet errors with:
The value passed for parameter `merkle_root` is invalid: Value 0x35f7fbe24cd7fa0fb3db31f268d2a2be6b3e6cf0e61b53e16e6a0aa8d719a2ab exceeds field modulus.
What works vs what's broken
| Path | Status |
|---|---|
| Shield (Make Private) — public → shielded | ✅ Works |
| Balance scanning — read shielded balance locally | ✅ Works |
| Anonymity set / TVL view | ✅ Works |
| Send shielded → another sanect shielded address | ❌ Broken (same root issue) |
| Unshield (Send anonymously to 0x ETH address) | ❌ Broken (same root issue) |
Shielding has no input note, so the circuit doesn't require a merkle path or root. Spending (send / unshield) does, and that's where this breaks.
Root cause
privacy/contracts/ShieldedPool.sol:370:
bytes32 newRoot = keccak256(abi.encode(merkleRoot, commitment));
merkleRoot = newRoot;
recentRoots[rootCursor] = newRoot;Two fatal problems:
keccak256outputs full 256-bit values. The Noir circuit'smerkle_rootis a BN254Fieldelement with modulus21888242871839275222246405745257275088548364400416034343698204186575808495617(~2^253.5).keccak256outputs frequently exceed this, hence the "exceeds field modulus" rejection at proof-input time.- It's a hash chain, not a merkle tree. Even after reducing mod field,
H(prevRoot, newCommit)is a 1-deep hash chain. The circuit verifies a 32-deep Poseidon-2 inclusion path. The two are different commitments to different data structures — the proof can never verify, no matter what we pass.
The wallet's lib/merkleTree.ts already computes the correct Poseidon tree off-chain. The problem is the contract doesn't.
The fix (substantial — design needed)
Tornado/Aztec/Penumbra pattern: an on-chain incremental Poseidon merkle tree. The contract maintains the depth-32 Poseidon root on every commitment insertion. Components:
- Solidity Poseidon-2 helper. Either:
- A precompile at a known address (no Solidity build, but we'd need to add it to
cosmos/evm's precompile set — Go-level fork work, several weeks). - A pure-Solidity Poseidon-2 verifier. Iden3 has one (~50k gas per hash), Aztec has one (~30k gas per hash). Either drops in at the cost of ~1.5–2.5M gas per
transactinsertion.
- A precompile at a known address (no Solidity build, but we'd need to add it to
- Incremental tree state. Per-level "filled subtree" cache (32 slots) + nextLeafIndex counter — same structure Tornado Cash uses.
insert(commitment)walks up the tree, hashing siblings with precomputed zero-subtree values, updating filled-subtree slots, writing the new root. - Refactor
transact(). Replace the keccak hash-chain block with calls intoincrementalMerkleTree.insert(c). Keep the samemerkleRoot()getter andrecentRootsring buffer — clients don't need to change. - Redeploy + chain reset OR migration script. Old commitments at the legacy "root" become unspendable. For testnet the cleanest move is
transact()-side rewrite + redeploy fresh. Mainnet pre-launch means this MUST land before tokens have value.
Effort estimate: 1-2 weeks engineering + 1 week testing. Plus the audit gate already on the roadmap.
Hot-patch options (none are great)
- Trusted root: contract trusts whatever root the prover submits, only enforces nullifier uniqueness and value conservation. Breaks the "verifier-checks-everything" property → anyone can submit a proof against a fake root that "includes" a fake commitment. Do not ship.
- Off-chain root anchor: a trusted publisher posts the Poseidon root periodically; contract uses that. Adds a trusted party we don't want for v1. Do not ship.
- Different proof system: replace the SNARK with one that hashes the leaf list directly (no merkle tree). Throws away the existing Noir + bb circuit work. Not realistic this close to launch.
Conclusion: the proper Solidity Poseidon path is the only credible fix. Schedule it.
What "Send to 0x address" was supposed to do (UX)
The user pasted a 0x EVM address with 5 SNCT shielded balance, hit "Send anonymously to ETH address". The intended flow:
- Wallet picks an unspent shielded note from the local tree
- Builds a transact() witness with:
- inputs = [that note]
- outputs = [zero-value placeholder, change note]
- publicAmount = +(value sent) (positive = unshield)
- recipientAddress = 0xABC… (gets the public SNCT)
- Generates the proof
- Submits transact() — pool burns the input commitment (via nullifier), transfers public SNCT to recipientAddress
This IS unshielding. The on-chain effect: recipient sees a normal SNCT transfer; sender's identity stays hidden in the anonymity set. The "send to your own 0x" variant is the standard unshield-to-self.
It's the right flow — it just can't complete until the contract is fixed.
Tracking
- Affected contract:
0x550Ff8f34042a9cDdD23729113590E678C9fAddA(v1.3) - Discovered: 2026-06-20
- Status: NOT a hot-fix. Schedule contract rewrite + redeploy.
- Owner: privacy module team
- Pre-mainnet gate: MUST be fixed before mainnet launch; until then the privacy module is shield-only on testnet.