Privacy architectureshielded pool · v1.4 · mainnet

The sender isa proof, notan address.

Sanect’s shielded pool is a Zcash-Sapling / Penumbra-style note pool inside a single EVM contract. It uses a custom Noir circuit proven with UltraHonk over Barretenberg’s universal SRS — so there is no per-circuit trusted setup — and keys derived from the wallet you already have.

circuit
2-in / 2-out
Noir · transfer.nr
gates · public inputs
6,307 · 8
ACIR · UltraHonk
on-chain verify
1block
HonkVerifier.verify() on chain
tree
232leaves
Poseidon-2 · append-only
root window
32
recent roots accepted
memo
240B
X25519 · ChaCha20-Poly1305

Four ideas behind the pool.

The design is deliberately conventional where it can be — the same note model Zcash has run since 2018 — and specific only where the EVM and wallet UX demand it.

01

Notes, not accounts

A note is a UTXO: {asset, value, owner_pk, salt}. The chain stores only its Poseidon-2 commitment and, once spent, its nullifier. Balances are reconstructed locally by the owner’s wallet.

02

Auto-receive, no claim step

Every commitment ships with a 240-byte encrypted memo. Wallets trial-decrypt new memos with the viewing key; a memo that opens is a note you own. Offline for a year — funds are simply there when you return.

03

Spending is a zero-knowledge proof

To spend, the wallet proves inclusion in the tree, ownership of the spending key, a correctly derived nullifier and value conservation. The chain verifies the proof; nothing else is revealed.

04

Keys derived from your existing wallet

One canonical message signed once in MetaMask. HKDF-SHA256 turns the signature into a viewing key and a spending key. No new seed phrase; recovery is “connect, sign once.”

What the proof has to satisfy.

A sketch of the constraint set in privacy/circuits/transfer/src/main.nr. Every line is a hard constraint: if any fails, no proof exists.

transfer.nr · constraints (sketch)nargo 1.0.0-beta.6 · bb 0.82.2
// for each of the 2 input notes
assert(owner_pk == derive_pk(spending_key));
assert(merkle_root(commit(note), path) == root);   // inclusion
assert(nullifier == hash(note, spending_key));
assert(note.asset_id == asset_id);

// for each of the 2 output notes
assert(out_commitment == commit(out_note));
assert(out_note.asset_id == asset_id);

// conservation — public_amount is +shield / 0 / −unshield
assert(in[0].value + in[1].value + public_amount
     == out[0].value + out[1].value);

// public inputs (8): root, 2 nullifiers, 2 commitments,
// public_amount, asset_id, recipient (unshield target)
  • 1
    Inclusion, not identity. The proof shows some note in the tree is being spent — not which one. The Merkle path is a private witness.
  • 2
    Nullifiers prevent double-spend. Deterministic per note and only computable with the spending key. Recorded on chain; a repeat reverts.
  • 3
    One circuit, every asset. Notes carry an asset_id; native SNCT and ERC-20s share the same proof system. v1 exposes SNCT.
  • 4
    Larger transfers batch. 2-in/2-out per proof; the wallet chains proofs for more inputs. Gas is public anyway, so batching leaks nothing about who is transacting.
  • 5
    Proving stays on your device. Noir’s wasm prover runs in the browser; the witness — your notes and keys — never leaves it.

One entry point on chain.

ShieldedPool.transact(proof, inputs, memos) handles all three operations. The sign of public_amount decides which.

public_amount > 0

Shield

Value moves from your public balance into a new note. Observers see a deposit and a commitment — not who will be able to spend it.

public_amount = 0

Internal send

Two notes in, two notes out, no value leaves the pool. Observers see a nullifier consumed and a commitment added.

public_amount < 0

Unshield

A note is spent and value is released to an EVM address via an internal call. The recipient is visible; the note’s origin is not.

7628ShieldedPool v1.4Holds value · Merkle tree · nullifier set · transact()
0x5532Dc2B743D45Fd0121E382696521ED62DD3ecf
7628HonkVerifierUltraHonk verifier generated from the circuit (keccak transcript)
0x580d7c1Ff04a5761D58a8Db08C37fb33c5e395f2
7628Poseidon2Bn254Aztec Poseidon-2 over BN254, hand-ported; matches Noir test vectors
0xaEE4A735027b86ccbF43042f4C31d1a856943c1C

Pool owner: protocol-governance 0xd5f05a16329c73efD21a57145A97c2b307E2dFED. Initial root 0x0b59baa3…568d (depth-32 Poseidon-2 zeros).

Keys from the wallet you already have.

The derivation message is frozen. Wallets that derived keys on the first testnet still hold the same keys on mainnet.

  1. 01
    personal_sign(DERIVATION_MESSAGE)One signature in MetaMask. Never sent anywhere.
  2. 02
    HKDF-SHA256, domain-separatedLabels VIEW_KEY_LABEL / SPEND_KEY_LABEL expand the signature into two independent secrets.
  3. 03
    Viewing key → X25519 public keySenders encrypt memos to it with a fresh ephemeral key per memo (ECDH → HKDF → ChaCha20-Poly1305; AAD binds the ephemeral key).
  4. 04
    Spending keyPrivate witness in the proof. Derives owner_pk in notes and nullifiers on spend.

What shielding does — and does not — hide.

Privacy that overpromises is worse than none. This is the honest boundary.

hidden
  • Which note is spent (the input’s position in the tree)
  • Who owns a newly created note
  • The link between a deposit and a later withdrawal
  • Amount and recipient in an internal send
  • Anything inside a memo — 99% of memos belong to other people and fail to decrypt
still visible
  • That the pool was called, when, and how much gas was paid
  • Shield amount and unshield amount (value crossing the pool boundary)
  • The recipient of an unshield — it lands on a normal EVM address
  • The size of the anonymity set. Privacy scales with how many others use the pool; the wallet shows the live number rather than pretending

Planned: an anonymity-maintenance service to keep the set meaningful in quiet periods, and Penumbra-style fuzzy message detection so heavy users can outsource scanning. Both are roadmap items, not shipped features.