Skip to content

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

PathStatus
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:

solidity
bytes32 newRoot = keccak256(abi.encode(merkleRoot, commitment));
merkleRoot = newRoot;
recentRoots[rootCursor] = newRoot;

Two fatal problems:

  1. keccak256 outputs full 256-bit values. The Noir circuit's merkle_root is a BN254 Field element with modulus 21888242871839275222246405745257275088548364400416034343698204186575808495617 (~2^253.5). keccak256 outputs frequently exceed this, hence the "exceeds field modulus" rejection at proof-input time.
  2. 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:

  1. 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 transact insertion.
  2. 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.
  3. Refactor transact(). Replace the keccak hash-chain block with calls into incrementalMerkleTree.insert(c). Keep the same merkleRoot() getter and recentRoots ring buffer — clients don't need to change.
  4. 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:

  1. Wallet picks an unspent shielded note from the local tree
  2. 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)
  3. Generates the proof
  4. 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.