V7 Shielded Pool

The V7 pool is the latest version of Kusama Shield's zero-knowledge privacy pool, deployed on both Paseo testnet and Polkadot mainnet.

Shield (Deposit)

Unshield (Withdraw)

What's New in V7

FeatureDescription
8 Public SignalsUp from 7 in earlier versions — enables stronger privacy guarantees
Linkability FixNo deposits[] mapping — deposits and withdrawals are fully unlinkable
Known-Roots Window16-slot recent-roots window prevents griefing attacks
Proxy WithdrawRoute withdrawals through a proxy contract for extra unlinkability
Leaner EventsDeposit(address,bytes32) — nullifierHash is never exposed at deposit time
Async ProofsBackground ZK proof generation with status polling

Commitment Derivation (V7)

All hashing uses Poseidon over BN254, matching the ZK circuit exactly:

nullifier     = poseidon2([secret, 1])
nullifierHash = poseidon1([nullifier])
precommitment = poseidon2([nullifier, secret])
valueAsset    = poseidon2([amountWei, assetId])
commitment    = poseidon2([valueAsset, precommitment])

poseidon1 is a single-input Poseidon hash. V7 uses it for nullifierHash — NOT poseidon2(nullifier, 0).

Withdrawal: 8 Public Signals

When the ZK proof is verified on-chain, the contract receives 8 public signals:

IndexSignalPurpose
[0]newCommitmentHashChange commitment inserted into tree
[1]existingNullifierHashMarks the spent commitment (prevents double-spend)
[2]contextHashReplay protection (binds to chain/transaction)
[3]withdrawnValueAmount being withdrawn
[4]treeDepthMerkle tree depth (fixed at 128)
[5]contextChain-specific context binding
[6]rootMerkle tree root the proof was generated against
[7]assetAsset identifier (precompile address for pallet assets, 0 for native)

Contract ABI (V7)

function depositNative(bytes32 commitment) external payable
function depositAsset(uint256 assetId, uint256 amount, bytes32 commitment) external
function depositAssetDirect(uint256 assetId, uint256 amount, bytes32 commitment) external
function withdraw(uint256[2] pA, uint256[2][2] pB, uint256[2] pC, uint[8] pubSignals, address recipient) external
function proxy_withdraw(uint256[2] pA, uint256[2][2] pB, uint256[2] pC, uint[8] pubSignals, address recipient) external
function currentRoot() external view returns (uint256)
function treeSize() external view returns (uint256)
function getEscrowBalance(address) external view returns (uint256)
function isNullifierSpent(bytes32) external view returns (bool)
function isKnownRoot(uint256) external view returns (bool)
function verifier() external view returns (address)

Proxy Withdrawals

The proxy_withdraw function routes the withdrawal through a separate proxy contract, giving the recipient a unique sender address each time.

Standard:   Pool ──────────────────▶ Recipient
            (pool address as sender)

Proxy:      Pool ──▶ Proxy Contract ──▶ Recipient
                     (fresh address as sender)

This adds a layer of unlinkability even if the recipient tries to trace the sender. Enable it via the Proxy Withdraw toggle on the Unshield tab.

Gas cost for proxy withdrawals is roughly 17x higher than standard withdrawals due to the extra contract deployment and forwarding logic.

Merkle Tree

V7 uses a LeanIMT (Lean Incremental Merkle Tree):

ParameterValue
Depth128
Hash functionPoseidon (BN254)
Known-roots window16 slots
EventDeposit(address,bytes32)
Unpaired nodesPropagated unchanged (not hashed with self)

The tree is synced from on-chain events by the backend proxy. A background monitor polls every 2 seconds for new deposits. The tree_update_lock ensures withdrawals wait for in-progress sync operations.

Root Mismatch Retry

If the local tree root doesn't match the on-chain currentRoot(), the proxy will:

  1. Rebuild the tree from the deployment block (not just recent blocks)
  2. Retry up to 5 times
  3. If still mismatched, return an error

SS58 Address Forwarding

When a withdrawal targets a native SS58 (Substrate) address, the proxy performs a two-step process:

Pool ── withdraw ──▶ Account 1 (H160: 0x74e539fc...) ── transfer_keep_alive ──▶ SS58 destination

This is necessary because the pool only supports EVM (H160) recipients, but users want funds at their Substrate addresses.

Proof Generation

BackendTimeUsed By
rapidsnark (C++)~4.3 secondsBackend proxy
snarkjs (WASM)~15 secondsBrowser UI (client-side)

The backend uses rapidsnark for production withdrawals. The browser falls back to snarkjs when generating proofs client-side (e.g., for the Unshield tab).

Circuit artifacts:

  • WASM: withdraw_phase2_fixed_v7.wasm
  • Proving key: withdraw_phase2_fixed_v7_0001.zkey

Gas Costs (pallet-revive)

OperationGasUSD (approx)
Deposit~45,000~$0.19
Withdraw (standard)~7,000~$0.03
Withdraw (proxy)~120,000~$0.50

Gas costs on pallet-revive are ~270x cheaper than equivalent operations on Ethereum mainnet.

Deployed Contracts

Polkadot AssetHub (Mainnet)

ContractAddress
Pool V70x0D694Da746e73D1e255c1894F90e38170db45809
Verifier0x6A13781E43AEA21918120CD0E7a2ed8614c01e14
Poseidon0xB8F0C6679D6Cc56450470522Bd96573C3D615052
Chain ID420420419
Deployment Block18460000

Paseo AssetHub (Testnet)

ContractAddress
Pool V70xbcE09D4De052b2816df1285663ac89528DF45380
Verifier0xcA4cBc5d31eccd08d393C43aF492F729FF30b685
Poseidon0x1d165f6fE5A30422E0E2140e91C8A9B800380637
Chain ID420420417
Deployment Block11273491

Event Format

event Deposit(address indexed asset, bytes32 commitment);

Only the asset address and commitment hash are emitted. The nullifierHash is never exposed during deposit — it is only revealed when the deposit is spent (in the ZK proof's public signals).

Context Hash

Every withdrawal binds to a context hash for replay protection:

contextHash = keccak256(abi.encodePacked(senderAddress)) % BN254_R

Where BN254_R = 21888242871839275222246405745257275088548364400416034343698204186575808495617.

This ensures a proof generated for one chain or sender cannot be replayed elsewhere.

Source Code