ShieldedPool v1.4 redeploy — laptop runbook
Background:
docs/incident-shielded-send-broken.mddocuments why the v1.3 pool was permanently broken for send/unshield. This runbook is the recovery path: a clean v1.4 redeploy with a real incremental Poseidon merkle tree.
What changed in v1.4
| Layer | v1.3 | v1.4 |
|---|---|---|
| Circuit hash | Aztec Poseidon-2 (no Solidity port) | Circom Poseidon-1 BN254 (iden3 has audited Solidity port) |
| On-chain root | keccak256 hash chain (broken — not field-reduced, not a tree) | Tornado-pattern incremental Poseidon merkle tree |
| Wallet root for proofs | pool.merkleRoot() (didn't match circuit's expected root) | Locally-computed root from scan.localRoot (matches contract's root because both use the same Poseidon) |
The wallet's tree builder is unchanged structurally — same depth, same zero-fill, same traversal — only the hash function under it changed.
Files added/changed this PR
| File | What |
|---|---|
privacy/contracts/IPoseidonT3.sol | New — 2-input Poseidon-1 hasher interface |
privacy/contracts/ShieldedPoolV14.sol | New — pool with incremental Poseidon merkle tree |
privacy/test/ShieldedPoolV14.t.sol | New — Foundry tests for tree logic (8 tests) |
privacy/script/DeployShieldedPoolV14.s.sol | New — Forge deploy script |
privacy/script/deploy-poseidon-t3.mjs | New — Node script to deploy iden3 PoseidonT3 |
privacy/circuits/transfer/src/main.nr | Changed — Poseidon-2 → Poseidon-1 |
staking-app/lib/poseidon.ts | Changed — bb.js → circomlibjs |
staking-app/lib/shielded.ts | Changed — uses scan.localRoot for send/unshield proof |
staking-app/package.json | Added circomlibjs dependency |
staking-app/circomlibjs.d.ts | New — ambient types |
Five-step laptop procedure
You need: nargo (1.0.0-beta.6 per privacy/circuits/manifest.json), bb (0.82.2), forge (any recent), node 20+, and $DEPLOYER_PRIVATE_KEY with a few SNCT on testnet for gas.
Step 1 — Recompile the circuit + verify Poseidon agreement
cd privacy/circuits/transfer
nargo test # runs the Poseidon cross-implementation vectors
nargo buildnargo test should pass 3 poseidon_vector_* tests — these confirm Noir's Poseidon matches the canonical Circom Poseidon-1 BN254 spec. If they fail, the Noir stdlib version is wrong (pin noir-lang/[email protected] per privacy/circuits/manifest.json).
Then nargo build regenerates target/transfer.json with the new Poseidon-1 wiring.
Step 2 — Regenerate the HonkVerifier + ship the new artifacts to the wallet
# Regenerate the verifier contract
bb write_solidity_verifier \
-k target/transfer.json \
-o ../verifier/Verifier.sol
# Ship the new circuit + VK to the wallet — the staking app loads these
# from public/circuits/ at proof-generation time. If you forget this,
# the wallet keeps using the OLD Poseidon-2 circuit while everything
# else is Poseidon-1, and proofs silently never verify.
bb write_vk -b target/transfer.json -o transfer.vk
cp target/transfer.json ../../../staking-app/public/circuits/transfer.json
cp transfer.vk ../../../staking-app/public/circuits/transfer.vk
cd ../..
forge build
# Repopulate the manifest with the new SHA256 + size — CI gate uses this
# to detect tampering. v1.4 was committed with the hash blanked.
cd .. # repo root
node scripts/build-manifest.js
git diff privacy/circuits/manifest.json # eyeball the new sha256/size linesThe verifier is regenerated from scratch — every proof against the old verifier becomes invalid (fine, we're cutting over).
Step 3a — Sanity-check the wallet Poseidon BEFORE touching chain
cd ../../staking-app
node ../privacy/script/cross-check-poseidon.mjsYou should see three ✅ matches against canonical test vectors. If any one shows ❌, the circomlibjs version has drifted — npm i [email protected] and re-run. Do not proceed if this step fails.
Step 3b — Deploy the iden3 PoseidonT3 hasher
cd .. # back to repo root
node privacy/script/deploy-poseidon-t3.mjs \
https://rpc.testnet.sanect.com \
$DEPLOYER_PRIVATE_KEYSave the printed address as POSEIDON_T3. The script also prints poseidon(1, 2) as a sanity check — should be 7853200120776062878684798364095072458815029376092732009249414926327459813530 (the canonical Circom Poseidon-1 BN254 output for inputs [1, 2]). If it doesn't match, the circomlibjs version drifted — npm install [email protected] to pin.
Step 3c — Cross-check wallet ↔ on-chain agreement
cd staking-app
node ../privacy/script/cross-check-poseidon.mjs \
https://rpc.testnet.sanect.com \
$POSEIDON_T3Now you should see SIX ✅ — three for the wallet's circomlibjs, three for the on-chain PoseidonT3. They must all match. If anything is off, fix it before deploying the verifier or pool — the bug surfaces in proof verification later, much harder to debug.
Step 4 — Deploy the new HonkVerifier
forge create privacy/circuits/verifier/Verifier.sol:HonkVerifier \
--rpc-url https://rpc.testnet.sanect.com \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcastSave the address as VERIFIER.
Step 5 — Deploy the new ShieldedPool
VERIFIER=0x... \
POSEIDON_T3=0x... \
POOL_OWNER=0x7cB61D4117AE31a12E393a1Cfa3BaC666481D02E \
forge script privacy/script/DeployShieldedPoolV14.s.sol \
--rpc-url https://rpc.testnet.sanect.com \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcastSave the printed ShieldedPool v1.4 deployed: address and the block number.
After deploy — Railway env vars
On the staking-app Railway service:
NEXT_PUBLIC_SHIELDED_POOL_ADDRESS=<v1.4 pool from step 5>
NEXT_PUBLIC_POOL_DEPLOY_BLOCK=<deploy block height from step 5>On the explorer-backend Railway service:
SHIELDED_POOL_ADDRESS=<v1.4 pool from step 5>Then redeploy both. Bake takes ~3 minutes.
Verify end-to-end
- Reload
https://app.testnet.sanect.com - Click 🛡 Shielded → Make Private → shield 1 SNCT
- Wait for the tx to confirm
- Click Balance — should show 1.0000 SNCT shielded (1 unspent note)
- Click Send → paste your own 0x address → 1 SNCT → "Send anonymously to ETH address"
- Should succeed. The proof generation step that previously hit
merkle_root exceeds field modulusnow works because both wallet and contract are using the same Poseidon-1.
Existing testnet funds
The v1.3 pool at 0x550Ff8f34042a9cDdD23729113590E678C9fAddA had 5 SNCT TVL. Those are permanently stuck — send/unshield against v1.3 was always broken; there's no recovery path. Testnet, so OK. Mainnet launch must use v1.4 from day 0.
Cross-implementation cross-check
The three Poseidon-1 implementations involved must produce identical output for the same inputs:
| Side | Implementation | Test |
|---|---|---|
| Circuit | std::hash::poseidon::bn254::hash_2 (Noir stdlib) | nargo test |
| Wallet | circomlibjs buildPoseidon() | console.log((await poseidonHash2(1n, 2n))) should print 7853200120... |
| Contract | iden3 PoseidonT3 deployed in step 3 | The deploy script prints poseidon(1, 2) — should be 7853200120... |
All three should match. If any one diverges, proofs won't verify. The runbook above pins versions to avoid drift.
Rollback
If anything goes wrong after step 5, the v1.3 contracts and verifier are still on chain — staking-app's previous deploy points at them. Revert the NEXT_PUBLIC_SHIELDED_POOL_ADDRESS env var on Railway and redeploy. Shield path keeps working under v1.3 (only send/unshield was broken).
Estimated gas costs
- PoseidonT3 deploy: ~3.5M gas (~0.0035 SNCT @ 1 gwei)
- HonkVerifier deploy: ~14M gas (~0.014 SNCT)
- ShieldedPool deploy + constructor
_initZeros: ~5M gas (32 Poseidon calls) - Per-transact insertion: ~1.6M gas per output commitment (was ~120K gas with keccak)
Per-transact cost goes up ~10× but the tree is actually correct. Still well within block gas limit (150M).