Privacy model
Status: Shielded pool v1.4 deployed and verified on testnet and mainnet. Full shield/send/unshield round-trip confirmed working. Browser proving live in the staking dApp.
sanect adds a native shielded transfer path that hides the sender — your address never links to what you send. It lives on the same chain as your public EVM transactions. You choose per transaction.
The Noir decision
The original Phase 0 design considered forking Railgun's Halo2 circuits. That path was abandoned after discovering that Railgun does not publish prebuilt proving keys — their 100+ Groth16 variants each need a multi-party trusted-setup ceremony that would take months to coordinate.
Decision: write a custom Noir circuit on Barretenberg's UltraHonk backend.
Noir is a Rust-like domain-specific language for ZK circuits, maintained by Aztec. It compiles to UltraHonk proofs via Barretenberg, which uses a universal structured reference string (SRS) — already generated, publicly audited, and reusable across all circuits. No per-circuit ceremony needed.
Why this stack:
- No per-circuit trusted setup — the universal SRS works for any circuit. Circuit upgrades are a recompile, not a new ceremony.
- Automatic Solidity verifier —
bb write_solidity_verifiergenerates an EVM-compatible verifier contract directly from the circuit. - Browser proving — Noir's WASM prover runs entirely in the browser. Proof generation takes ~2-3 seconds on modern hardware.
- Active maintenance — Aztec is one of the best-funded teams in ZK. Barretenberg and Noir receive continuous development and security patches.
- BN254 native — the proving system operates over BN254, making Solidity verification efficient (EVM precompiles for BN254 pairing exist).
What stays the same from the original design:
- Single Solidity
ShieldedPoolcontract holding the value. - Note-model UTXO design with Poseidon-committed amounts.
- Viewing key derived from one MetaMask signature; auto-receive scan loop.
CommitmentAddedevent format the wallet scans.
What changed:
- Circuits: custom 2-in/2-out Noir circuit, not vendored from Railgun.
- Proving system: UltraHonk via Barretenberg, not Halo2 or Groth16.
- Hash function: Aztec Poseidon-2 over BN254 (circuit + wallet + on-chain all use the same hash).
- Key derivation: HKDF-SHA256 from the MetaMask signature -> X25519 viewing key + spending key.
- Memo encryption: X25519 ECDH + ChaCha20-Poly1305 packed into a fixed 240-byte envelope (32 + 96 + 16 ciphertext core, plus 96 bytes reserved for fuzzy message detection). Constant length defeats fingerprinting.
- v1 scope: native SNCT only (shield -> shielded transfer -> unshield). ERC-20 shielding moves to v2 to keep the v1 verifier minimal and the audit surface small.
Phase roadmap
| Phase | Scope | Status |
|---|---|---|
| Phase 0 | Scaffold + tests + design doc | Done |
| Phase 1 | Custom Noir circuit + UltraHonk verifier, real key derivation, real memo encryption, Foundry e2e tests with real proofs | Done |
| Phase 2 | Deploy verifier on testnet and mainnet, ShieldedPool v1.4 with incremental Poseidon Merkle tree | Done |
| Phase 3 | Browser proving in staking-app (Shield / Send / Unshield tabs), Noir WASM prover, circuit artifacts served from dApp | Done |
| Phase 4 | Off-chain memo-indexer (Postgres-backed, replaces per-wallet scanning), deployed as Railway service | Done |
| Phase 5 | Anonymity Maintenance Bot — treasury-funded decoy traffic for low-adoption periods | Planned |
| Audit | External audit before mainnet value at risk. Testnet + mainnet run as-is in the meantime. | In progress |
Goals
When this is done, a sanect user can pick per transaction:
| Send (public) | Shielded Send (private) | |
|---|---|---|
| Sender visible? | Yes | No |
| Recipient visible? | Yes | No (encrypted to receiver) |
| Amount visible? | Yes | No (committed value) |
| Receiver has to do anything? | Just be online | No — wallet scans + auto-receives |
| Tx hash in Blockscout? | Yes (0x...) | Yes (0x...) — only event payloads are encrypted |
| Wallet | Any EVM wallet | MetaMask (one signature -> viewing key) |
| Asset support | Native SNCT, ERC-20 | Native SNCT (v1); ERC-20 in v2 |
The public path is already live. This document is about the shielded path.
Non-goals
- Network-level anonymity. Hiding the IP that submits a tx is the user's job (Tor / VPN). The protocol assumes the network observer can see when a tx is submitted, just not who sent or what's inside.
- Cross-chain shielded transfers (Phase 0-5). Pool-to-pool over IBC is a Phase 6+ extension.
- Compliance / view keys for regulators. Out of scope. Viewing key is the user's own audit trail.
Architecture
sanect EVM
|
+-------------+------------------+
| | |
Public path Shielded path Staking, gov, ...
| | |
v v v
eth_sendTransaction ShieldedPool precompiles 0x...0800 etc.
|
+-----------------+-------------------+
| | |
v v v
transact() transact() transact()
(shield) (internal send) (unshield)
public -> pool pool -> pool pool -> public
|
v
CommitmentAdded events
|
v
Wallet scans + trial-decrypts
with the receiver's viewing key
|
v
Note appears in receiver's balance
(no on-chain claim needed)The shielded pool is a single Solidity contract deployed to sanect. It holds shielded balances for native SNCT (v1) with multi-asset support via asset_id for future ERC-20 shielding. All operations go through the unified transact() function, gated by UltraHonk zero-knowledge proofs. The publicAmount parameter determines the operation: positive = shield, zero = internal send, negative = unshield.
Key contracts
- Poseidon2Bn254 — Aztec Poseidon-2 hasher, hand-ported from Barretenberg's C++ reference. Verified against three Noir test vectors.
- HonkVerifier — UltraHonk verifier generated by
bb write_solidity_verifier. Fits within EIP-170's 24,576-byte limit withoptimizer_runs=10000000. - ShieldedPool v1.4 — Tornado-pattern incremental Merkle tree (depth 32),
filledSubtreescache, recent-roots ring buffer (32 slots), 1.6M gas per insertion.
Why a Solidity contract, not a Cosmos module
| Solidity contract (chosen) | Cosmos x/shielded module | |
|---|---|---|
cosmos/evm fork | None | Yes — perpetual upstream merging |
| Wallet | MetaMask + ethers | MetaMask + precompile or Keplr — splits UX |
| Gas per shielded tx | ~1.6M (UltraHonk EVM verifier) | ~100k (native precompile) |
| Mainnet readiness time | ~5-7 months | ~9-12 months |
| Migration path | Lift contract address into a native precompile later (Phase 7+) — interface unchanged | n/a |
| Production examples | Aztec V1, Railgun (live for years on multiple EVMs) | Penumbra, Namada (dedicated privacy chains) |
The contract approach loses on verify gas but wins on every other axis that matters for a small team launching a new chain. We can always migrate to a precompile in v2 — the contract address stays the same, dApps and wallets don't notice.
Cryptographic choices
Proving system: Noir + UltraHonk (Barretenberg)
- No per-circuit trusted setup ceremony — Barretenberg's universal SRS is public, audited, and reusable. Circuit upgrades are a recompile, not a new ceremony.
- Solidity verifier auto-generated via
bb write_solidity_verifier. Produced contracts fit within EIP-170 (24,576 bytes) and verify in ~1.6M gas. - Proof generation runs in-browser via Noir's WASM prover (~2-3 seconds on consumer hardware).
- Circuit: custom 2-in/2-out with 8 public inputs and ~6,300 gates.
Note commitment: Poseidon-2 over BN254
- ZK-friendly hash with cheap circuit cost (vs keccak / SHA-256).
- Uses Aztec's Poseidon-2 specification — same hash in circuit, wallet TypeScript, and on-chain Solidity.
- Commitment =
Poseidon2(asset, value, recipientPk, salt) - Nullifier =
Poseidon2(spendKey, commitment)— unique per note; computing it requires the spending key, so observers can't predict nullifiers ahead of the spend.
Memo encryption: ECDH + ChaCha20-Poly1305
- Sender generates an ephemeral X25519 key, ECDH against the recipient's viewing-key-derived stealth pubkey, expands via HKDF to a ChaCha20 key.
- Memo plaintext:
{asset, value, salt, recipientStealthPk}— everything the recipient needs to rebuild the note locally. - Ciphertext size is constant (240 bytes: 144-byte ciphertext core + 96-byte FMD-reserved tail) — an observer can't fingerprint users by memo length.
Viewing key derivation: MetaMask signature
- User signs a single canonical message via
personal_signonce per device. - Signature -> HKDF-SHA256-derived seed -> X25519 viewing key + spending key.
- No second seed phrase to manage. A user with their MetaMask seed can always recover all shielded notes by signing the same message on a new device.
- Trade-off: compromising MetaMask compromises the viewing key. Acceptable for early mainnet; future Ledger path keeps the spend key in hardware while MetaMask handles the EVM tx signature.
On-chain interface
Full ABI in privacy/contracts/ShieldedPool.sol.
Functions
function transact(
bytes calldata proof,
bytes32 merkleRoot,
bytes32[] calldata nullifiers,
bytes32[] calldata newCommitments,
bytes[] calldata encryptedMemos,
int256 publicAmount, // +N = shield, 0 = internal, -N = unshield
uint256[] calldata publicInputs
) external payable;Events
event CommitmentAdded(
uint256 indexed leafIndex,
bytes32 commitment,
bytes32 newMerkleRoot,
bytes encryptedMemo
);
event NullifierUsed(bytes32 indexed nullifier);End-to-end user flow
Alice (sender) Bob (receiver)
----- -----
1. Has shielded notes: 1. Connects MetaMask
N1 = {asset=SNCT, value=10, salt} 2. Signs derivation message
-> viewing key (one time, per device)
2. Wants to send 7 SNCT to Bob 3. Wallet starts scanning
CommitmentAdded events
3. Asks Bob for his stealth pubkey
(one-off, via QR / chat / paste)
4. Builds tx locally:
- inputs: [N1] (spend)
- outputs: [N2 = 7 to Bob,
N3 = 3 change]
- nullifier(N1) (proves spend)
- encrypted memo for N2
to Bob's viewing key
- encrypted memo for N3
to Alice's own viewing key
5. Generates UltraHonk proof (in WASM,
takes ~2-3 seconds, asserts
owner_pk match, Merkle inclusion,
nullifier correctness, asset
consistency, value conservation)
6. MetaMask signs the EVM tx
calling ShieldedPool.transact(...)
7. tx lands on sanect -> 2 new
CommitmentAdded events fire
8. Wallet sees the events.
Trial-decrypts each memo
with viewing key.
Bob's memo decrypts.
-> N2 (7 SNCT) appears in
his balance.
No claim tx. No notification.
Just there.The block explorer shows a successful tx. The calldata is encrypted. Nobody who isn't Alice or Bob can tell who sent or who received.
Privacy properties
Given a chain observer with full access to every block:
| What they want to learn | Can they? | Why / why not |
|---|---|---|
| Which addresses are using the pool at all | Yes — shield is public | By design — the public->shielded boundary is auditable |
| Total pool TVL per asset | Yes — contract balance | By design |
| Which deposits became which withdrawals | No | Spend proof hides which notes are consumed; nullifier reveals nothing about origin |
| Who sent a given shielded transfer | No | Sender is not in the tx; only a nullifier, a new commitment, and an encrypted memo |
| Who received it | No | Encrypted memo only opens with the recipient's viewing key |
| The value of a given transfer | No | Committed value hides it; proof checks balance without revealing it |
| Network metadata (IP, time of day) | Yes | Not the protocol's job — use Tor / VPN |
Anonymity set: all unspent commitments in the pool. A spent note is indistinguishable from any other unspent note ever deposited. Quality grows with usage — empty pool = trivially deanonymizable, busy pool = strong.
Threat model
In scope
- A passive chain observer (block explorers, analytics firms, exchanges) trying to deanonymize sender / recipient / amount from on-chain data.
- An active attacker submitting txs that try to spend others' notes, replay proofs, double-spend, or extract notes via forged commitments.
- A malicious validator censoring shielded txs — possible at small-validator-set scale, fixed by adequate operator diversity.
- Wallet bugs that leak the viewing key over the network.
Out of scope (Phase 0-5)
- Active network-layer adversary correlating tx submission with IP addresses. Solved by Tor / VPN; not a protocol problem.
- Side channels (timing, gas usage shape) that distinguish "deposit of 1000 SNCT" from "deposit of 1001 SNCT" — the 2-in/2-out circuit handles arbitrary amounts within the shielded pool.
- Quantum adversary against BN254 pairing assumptions. Post-quantum ZK is a different architecture.
Security note
This module needs a cryptographer in the loop and a security audit before carrying significant mainnet value. "Privacy" is easy to get subtly wrong (linkability via amounts, timing, gas payer, IP). Budget for: decoy/timing strategy, and relayers so the fee payer doesn't deanonymize the spender.
Open questions for v2
ERC-20 shielding. The circuit and pool contract support multi-asset via
asset_id. Wire up ERC-20 deposits and add per-asset denomination tracking. Additive to v1 — no contract redeploy needed.Relayer architecture for fee payment. A naive shielded
transact()requires the sender to pay gas — but the gas payer is publicly visible, leaking the sender. Standard fix: a relayer service that submits the tx on the sender's behalf and is compensated in a shielded change note.Anonymity Maintenance Bot (Phase 5). Treasury-funded automated decoy traffic in low-adoption periods. ~50 tx/block floor, auto-scales down as real adoption catches up. See PRIVACY.md for the full policy.
Storage growth. Nullifier set grows monotonically. With 400ms blocks and a million shielded txs / month, that's ~12M nullifier entries per year. Acceptable for years; add archival / Merkle proof-of-non-membership compression in v2 if needed.
Audit firm choice. Zellic, Veridise, Trail of Bits all have ZK depth. Engagement underway.
Further reading
- Architecture — how the shielded pool fits into the full node stack
- Precompiles reference — staking and other precompile entry points
- Penumbra spec — closest production reference (Cosmos-based)
- Noir documentation — circuit language reference
- Barretenberg — UltraHonk proving backend