Skip to content

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 verifierbb write_solidity_verifier generates 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 ShieldedPool contract holding the value.
  • Note-model UTXO design with Poseidon-committed amounts.
  • Viewing key derived from one MetaMask signature; auto-receive scan loop.
  • CommitmentAdded event 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

PhaseScopeStatus
Phase 0Scaffold + tests + design docDone
Phase 1Custom Noir circuit + UltraHonk verifier, real key derivation, real memo encryption, Foundry e2e tests with real proofsDone
Phase 2Deploy verifier on testnet and mainnet, ShieldedPool v1.4 with incremental Poseidon Merkle treeDone
Phase 3Browser proving in staking-app (Shield / Send / Unshield tabs), Noir WASM prover, circuit artifacts served from dAppDone
Phase 4Off-chain memo-indexer (Postgres-backed, replaces per-wallet scanning), deployed as Railway serviceDone
Phase 5Anonymity Maintenance Bot — treasury-funded decoy traffic for low-adoption periodsPlanned
AuditExternal 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?YesNo
Recipient visible?YesNo (encrypted to receiver)
Amount visible?YesNo (committed value)
Receiver has to do anything?Just be onlineNo — wallet scans + auto-receives
Tx hash in Blockscout?Yes (0x...)Yes (0x...) — only event payloads are encrypted
WalletAny EVM walletMetaMask (one signature -> viewing key)
Asset supportNative SNCT, ERC-20Native 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 with optimizer_runs=10000000.
  • ShieldedPool v1.4 — Tornado-pattern incremental Merkle tree (depth 32), filledSubtrees cache, 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 forkNoneYes — perpetual upstream merging
WalletMetaMask + ethersMetaMask + 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 pathLift contract address into a native precompile later (Phase 7+) — interface unchangedn/a
Production examplesAztec 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_sign once 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

solidity
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

solidity
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 learnCan they?Why / why not
Which addresses are using the pool at allYes — shield is publicBy design — the public->shielded boundary is auditable
Total pool TVL per assetYes — contract balanceBy design
Which deposits became which withdrawalsNoSpend proof hides which notes are consumed; nullifier reveals nothing about origin
Who sent a given shielded transferNoSender is not in the tx; only a nullifier, a new commitment, and an encrypted memo
Who received itNoEncrypted memo only opens with the recipient's viewing key
The value of a given transferNoCommitted value hides it; proof checks balance without revealing it
Network metadata (IP, time of day)YesNot 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Audit firm choice. Zellic, Veridise, Trail of Bits all have ZK depth. Engagement underway.


Further reading