Skip to content

ShieldedPool v1.4 redeploy — laptop runbook ​

Background: docs/incident-shielded-send-broken.md documents 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 ​

Layerv1.3v1.4
Circuit hashAztec Poseidon-2 (no Solidity port)Circom Poseidon-1 BN254 (iden3 has audited Solidity port)
On-chain rootkeccak256 hash chain (broken — not field-reduced, not a tree)Tornado-pattern incremental Poseidon merkle tree
Wallet root for proofspool.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 ​

FileWhat
privacy/contracts/IPoseidonT3.solNew — 2-input Poseidon-1 hasher interface
privacy/contracts/ShieldedPoolV14.solNew — pool with incremental Poseidon merkle tree
privacy/test/ShieldedPoolV14.t.solNew — Foundry tests for tree logic (8 tests)
privacy/script/DeployShieldedPoolV14.s.solNew — Forge deploy script
privacy/script/deploy-poseidon-t3.mjsNew — Node script to deploy iden3 PoseidonT3
privacy/circuits/transfer/src/main.nrChanged — Poseidon-2 → Poseidon-1
staking-app/lib/poseidon.tsChanged — bb.js → circomlibjs
staking-app/lib/shielded.tsChanged — uses scan.localRoot for send/unshield proof
staking-app/package.jsonAdded circomlibjs dependency
staking-app/circomlibjs.d.tsNew — 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 ​

bash
cd privacy/circuits/transfer
nargo test    # runs the Poseidon cross-implementation vectors
nargo build

nargo 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 ​

bash
# 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 lines

The 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 ​

bash
cd ../../staking-app
node ../privacy/script/cross-check-poseidon.mjs

You 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 ​

bash
cd ..   # back to repo root
node privacy/script/deploy-poseidon-t3.mjs \
  https://rpc.testnet.sanect.com \
  $DEPLOYER_PRIVATE_KEY

Save 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 ​

bash
cd staking-app
node ../privacy/script/cross-check-poseidon.mjs \
  https://rpc.testnet.sanect.com \
  $POSEIDON_T3

Now 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 ​

bash
forge create privacy/circuits/verifier/Verifier.sol:HonkVerifier \
  --rpc-url https://rpc.testnet.sanect.com \
  --private-key $DEPLOYER_PRIVATE_KEY \
  --broadcast

Save the address as VERIFIER.

Step 5 — Deploy the new ShieldedPool ​

bash
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 \
  --broadcast

Save 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 ​

  1. Reload https://app.testnet.sanect.com
  2. Click 🛡 Shielded → Make Private → shield 1 SNCT
  3. Wait for the tx to confirm
  4. Click Balance — should show 1.0000 SNCT shielded (1 unspent note)
  5. Click Send → paste your own 0x address → 1 SNCT → "Send anonymously to ETH address"
  6. Should succeed. The proof generation step that previously hit merkle_root exceeds field modulus now 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:

SideImplementationTest
Circuitstd::hash::poseidon::bn254::hash_2 (Noir stdlib)nargo test
Walletcircomlibjs buildPoseidon()console.log((await poseidonHash2(1n, 2n))) should print 7853200120...
Contractiden3 PoseidonT3 deployed in step 3The 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).