Privacy-focused shielded token system for EVM using:
- Noir circuits
- UltraHonk proofs (Barretenberg)
- Solidity verifier/contracts
- Relayer-mediated on-chain submission
- Encrypted note discovery for recipients via viewing keys
This repo supports two deployment patterns:
- Monolith (
ShieldedToken): a single contract coordinates public ERC20 balances + embedded privacy pool. - Multi-token pool (
ShieldedERC20Pool): an external pool contract that can hold and route shielded notes for multiple allowlisted ERC20 tokens.
Both patterns use the same Merkle/nullifier/proof model and routed encrypted note discovery (RoutedCommitment).
This project demonstrates a shielded token transfer system where in-pool transfers hide sender, receiver, and amount from public observers. The system uses a note-based UTXO model:
- Notes are commitments stored in an on-chain Merkle tree
- Spends are authorized with zero-knowledge proofs
- Double-spend prevention is enforced by nullifiers
- Recipients discover notes by decrypting encrypted note payloads using viewing keys
The architecture is inspired by STRK20-style privacy workflow and adapted to EVM + Noir + UltraHonk.
Inside the private pool (shielded transfers):
- Sender identity is hidden behind relayer submission
- Receiver identity is hidden in encrypted notes
- Amounts are hidden in encrypted notes and witness data
- On-chain records only commitments/nullifiers/roots/proof-related public inputs
At public boundaries (deposit/unshield):
- Boundary actions are on-chain and visible by EVM design
- Deposit/unshield metadata can still be observable at entry/exit points
Shielding and unshielding remain boundary actions on a public chain (visible entry/exit), while in-pool transfers aim for strong confidentiality.
packages/circuits: Noir circuit (main.nr) for shielded transfer constraintspackages/contracts: Foundry contracts (verifier, tree, monolithicShieldedToken, Poseidon helpers)services/relayer: HTTP relayer for submitting shielded transfers on-chainscripts/hardhat-local-e2e.mjs: complete local E2E orchestrationscripts/sepolia-e2e.mjs: same flow against Sepolia (deploy or reuse addresses, optional transfers-only)scripts/arbitrum-sepolia-pool-deploy.mjs: Arbitrum Sepolia pool deploy (npm run deploy:pool:arbitrum-sepolia)scripts/probe-arbitrum-sepolia-rpc.mjs: RPC /eth_getLogssmoke probe (npm run probe:arbitrum-rpc)apps/web: frontend shell (not required for CLI E2E)
Single contract combining:
- ERC20 surface:
transfer,approve,transferFrom,balanceOf,totalSupplyfor transparent interoperability - Embedded pool:
shieldRouted,shieldedTransferRouted,unshield - Nullifier set and UltraHonk verification against the shared Merkle tree
tokenField:bytes32(uint256(uint160(address(this))))so the Noir circuit binds to this token addressRoutedCommitment(channel, subchannel, encryptedNote): indexed encrypted payloads for channel/subchannel-scoped recipient discovery
Standalone privacy pool for existing ERC20s:
- Token-aware shielding:
shieldRouted(token, amount, commitment, encryptedNote, channel, subchannel) - Token-bound transfer verification:
shieldedTransferRouted(..., tokenField, fee, feeRecipientPk)wheretokenFieldmust map to an enabled ERC20 - Unshield to EOA:
unshield(...)transfers underlying ERC20 from pool custody to recipient - Safety controls: token allowlist (
enabledToken), nullifier replay protection, root checks, and non-reentrancy - Same routed discovery surface: emits
RoutedCommitment(channel, subchannel, encryptedNote)like monolith mode
On-chain note commitment tree.
- Poseidon2-based parent hashing
- Rolling known root window for concurrency tolerance
- Membership roots referenced by proofs
UltraHonk Solidity verifier generated from circuit VK using bb contract.
Poseidon2, Poseidon2YulHasher, Poseidon2Hasher are used to align hashing across circuit + EVM.
These addresses are checked into the repo as deployment snapshots (scripts/sepolia-pool-deployment.json, scripts/base-sepolia-pool-deployment.json, scripts/arbitrum-sepolia-pool-deployment.json, scripts/sepolia-mock-erc20-deployment.json, scripts/base-sepolia-mock-erc20-deployment.json, scripts/arbitrum-sepolia-mock-erc20-deployment.json). If you redeploy, update those JSON files and your app env (NEXT_PUBLIC_* / VITE_*) to match.
On explorers, the in-repo verifier artifact is UltraVerifier (from packages/contracts/src/HonkVerifier.sol). Other deploys use the same Solidity names: Poseidon2, Poseidon2YulHasher, IncrementalMerkleTree, ShieldedERC20Pool, MockERC20.
| Contract | Address | Explorer |
|---|---|---|
Poseidon2 |
0xa9CC305Af95542673aea1518881B6F1E7A8DE3b8 |
Etherscan |
Poseidon2YulHasher |
0xE6d12EfF9db5FDb548Aa17Ad1587623FFAe3BE96 |
Etherscan |
UltraVerifier |
0xf45A783A47c68570b9D786a291e934F6A6B70950 |
Etherscan |
IncrementalMerkleTree |
0x3C4A041C4145B7FEF8C341Ca10D162A717adcc7A |
Etherscan |
ShieldedERC20Pool |
0xDd10f44Bc04451f0e1B698F5a8422f56d0d05966 |
Etherscan |
MockERC20 (pool primary MOCK, 18 decimals) |
0x9DBEd8AB4A05b5E4b6aF3bf61AA3051F6caa91b4 |
Etherscan |
Sepolia extra pool mocks (MockERC20, same pool owner allowlist — use scripts/enable-pool-tokens.mjs after deploy if they are not already enabled):
| Symbol | Decimals | Address |
|---|---|---|
| USDC | 6 | 0x093856dc11cbEFeBb6c53E112F85E807D44ca9c2 |
| USDT | 6 | 0x70bdC729406Ee9C547522529f43F48028FCf374A |
| DAI | 18 | 0xDc256389b94e511caEe10A75F1FE4246c185c288 |
| LINK | 18 | 0xFFBeF846263Af332CF34f7AC1F54aD09745c8c05 |
Indexed from block 10744004 for this pool deployment (see poolDeployBlock in scripts/sepolia-pool-deployment.json).
| Contract | Address | Explorer |
|---|---|---|
Poseidon2 |
0xEC71805247833595B77eF444D4e9EF95FFFB0fD5 |
Basescan |
Poseidon2YulHasher |
0x5056ecfD57e1a5D5b9CE15383cD3655fA434f8be |
Basescan |
UltraVerifier |
0x053a1257e5c69754F772e549A93752963B35D66a |
Basescan |
IncrementalMerkleTree |
0x3AD3c6ffE9323A58bcf4ADF3E091E07eC6570976 |
Basescan |
ShieldedERC20Pool |
0xA4421d963f0C89FaAF489FfFC0eb662Fc67C030F |
Basescan |
MockERC20 (pool primary MOCK, 18 decimals) |
0x19DCe2d215C6b7EA1B247460E7FA6A9f7FFc60e8 |
Basescan |
Base Sepolia batch mocks (deployed with scripts/deploy-mock-erc20-batch.mjs; pool deployer in snapshot: 0x82E6d844629b734a118c339B9f80e404f117DF61):
| Symbol | Decimals | Address |
|---|---|---|
| USDC | 6 | 0x120d58806E33b07d1eBd6946d4691b13e259712a |
| USDT | 6 | 0x6A3b629F9eB189E194B947CF84d47c60CCc6a1Df |
| DAI | 18 | 0x4f57D26465D51d1Bb91Ed44ae85F2245256B7cAa |
| LINK | 18 | 0x819EA63eB94992766c935B8C34D00b259cF45BF6 |
Indexed from block 41373731 for this pool deployment (see poolDeployBlock in scripts/base-sepolia-pool-deployment.json).
Arbitrum Sepolia uses the same multi-token pool stack as Base Sepolia: ShieldedERC20Pool, IncrementalMerkleTree, Poseidon helpers, UltraVerifier, and batch MockERC20 stables. Notes, scans, and proofs are per chain: switching the in-app Pool network targets this chain’s pool and RPC read path (and can prompt MetaMask to switch when a wallet is connected).
- Copy
.env.arbitrum-sepolia.exampleto.env.arbitrum-sepolia, setPRIVATE_KEY, and setTESTNET_RPC_URL(defaults tohttps://sepolia-rollup.arbitrum.io/rpc). - Run
npm run deploy:pool:arbitrum-sepolia(writesscripts/arbitrum-sepolia-pool-deployment.json). - Deploy mock USDC/USDT/DAI/LINK with
npm run deploy:mock-tokens-batchusingTESTNET_CHAIN_ID=421614,DEPLOYMENT_JSON=scripts/arbitrum-sepolia-mock-erc20-deployment.json, and the same RPC URL as step 1. - Run
node --env-file=.env.arbitrum-sepolia scripts/enable-pool-tokens.mjsso the pool allowlists those tokens. - Point clients at your pool: set
NEXT_PUBLIC_ARBITRUM_SEPOLIA_POOL_ADDRESS(web) and/orVITE_ARBITRUM_SEPOLIA_POOL_ADDRESS(extension) to the deployedShieldedERC20Pool. When the address matches the canonical pool baked intoapps/web/lib/networks.ts/apps/wallet-extension/src/networks.ts, the rest of the contract set is inferred; otherwise set the matchingNEXT_PUBLIC_ARBITRUM_SEPOLIA_*/VITE_ARBITRUM_SEPOLIA_*overrides from your deployment JSON. - Relayer: set
RELAYER_RPC_URL_ARBITRUM_SEPOLIAinservices/relayer/.env(seeservices/relayer/README.md). Fund relayer signers with Arbitrum Sepolia ETH for gas.
Always verify on-chain (bytecode + labels); prefer values from scripts/arbitrum-sepolia-pool-deployment.json and scripts/arbitrum-sepolia-mock-erc20-deployment.json after your own deploy. Same-address strings can appear on different chains by coincidence—confirm chain id 421614 on Arbiscan Sepolia.
| Contract | Address | Explorer |
|---|---|---|
Poseidon2 |
0xAAe87a37cFb56a5E81D0D2587956b7Bc9d1bFA83 |
Arbiscan |
Poseidon2YulHasher |
0xDb16E79C2321fA4fE89127E7a654d13f5A6D9df8 |
Arbiscan |
UltraVerifier |
0xB7D117E9127494EeF11D274A316584973E6Ec684 |
Arbiscan |
IncrementalMerkleTree |
0xEC71805247833595B77eF444D4e9EF95FFFB0fD5 |
Arbiscan |
ShieldedERC20Pool |
0x3AD3c6ffE9323A58bcf4ADF3E091E07eC6570976 |
Arbiscan |
MockERC20 (pool primary MOCK, 18 decimals) |
0x5056ecfD57e1a5D5b9CE15383cD3655fA434f8be |
Arbiscan |
Arbitrum Sepolia batch mocks (scripts/arbitrum-sepolia-mock-erc20-deployment.json):
| Symbol | Decimals | Address | Explorer |
|---|---|---|---|
| USDC | 6 | 0x19DCe2d215C6b7EA1B247460E7FA6A9f7FFc60e8 |
Arbiscan |
| USDT | 6 | 0x01603A654B0d785CF0790614a80a9404f9C5F4D8 |
Arbiscan |
| DAI | 18 | 0xA4421d963f0C89FaAF489FfFC0eb662Fc67C030F |
Arbiscan |
| LINK | 18 | 0x120d58806E33b07d1eBd6946d4691b13e259712a |
Arbiscan |
Indexing / scans: poolDeployBlock for this snapshot is 267768393 (see scripts/arbitrum-sepolia-pool-deployment.json). The web app scans RoutedCommitment logs from that block upward; the first full pass can take noticeable time on public RPCs.
| Concern | What to set |
|---|---|
| Pool address | NEXT_PUBLIC_ARBITRUM_SEPOLIA_POOL_ADDRESS (web), VITE_ARBITRUM_SEPOLIA_POOL_ADDRESS (extension) when not using the default baked into networks.ts. |
| Read RPC list (optional) | NEXT_PUBLIC_ARBITRUM_SEPOLIA_RPC_URLS — comma-separated URLs; merged with curated fallbacks in apps/web/lib/rpc-read.ts. Premium hosts (Alchemy / Infura / QuickNode) are tried after public mirrors to reduce 429s during multi-RPC log merges. |
| Default read order (if you only rely on fallbacks) | https://sepolia-rollup.arbitrum.io/rpc → https://arbitrum-sepolia-rpc.publicnode.com → https://arbitrum-sepolia.gateway.tenderly.co (see ARBITRUM_SEPOLIA_READ_RPC_FALLBACKS). |
| Relayer URL | NEXT_PUBLIC_RELAYER_URL must point at your running relayer; relayer must have RELAYER_RPC_URL_ARBITRUM_SEPOLIA for chain 421614. |
| Debug scans | NEXT_PUBLIC_SHIELDED_SCAN_DEBUG=1 (or the localStorage flag documented in apps/web/lib/shielded-scan-debug.ts) for verbose shielded-scan logs. |
| RPC smoke test | npm run probe:arbitrum-rpc — probes head block and a small eth_getLogs window against candidate URLs. |
Browser / RPC caveats: many public endpoints cap eth_getLogs block span (often ~2000 blocks). The app chunks Merkle LeafInserted reads accordingly; shielded note scans use smaller L2 chunk sizes and can merge results across mirrors. If you see Failed to fetch or flaky scans, add a dedicated RPC in NEXT_PUBLIC_ARBITRUM_SEPOLIA_RPC_URLS or switch the wallet RPC in MetaMask to the same chain.
Relay requests must include chainId: 421614 for Arbitrum Sepolia so the relayer picks RELAYER_RPC_URL_ARBITRUM_SEPOLIA and the correct signer pool (see services/relayer/README.md).
The Noir circuit enforces:
- Input note reconstruction from private witnesses
- Merkle membership for each input note
- Nullifier correctness (
Poseidon(spending_key, commitment)) - Output commitment correctness
- Conservation rule: sum(inputs) = sum(outputs)
- If
fee > 0, output note #2 is enforced as fee note (owner = feeRecipientPk,amount = fee) - Distinct nullifiers inside a transfer
Public inputs include:
- token field
- Merkle root
- nullifiers
- output commitments
- fee
- fee recipient pk
Private witness includes secrets (spending key, paths, note amounts/blindings, etc.).
Relayer endpoint:
POST /relay/shielded-transfer
Status endpoint:
GET /relay/status/:requestId
Health endpoint:
GET /healthz
Relayer behavior:
- Accepts proof bundle + commitments/nullifiers/encrypted notes +
channels/subchannels - Supports both targets:
- monolith via
shieldedToken(legacy field) - pool/monolith via
shieldedTarget(preferred field)
- monolith via
- Broadcasts tx with relayer signer
- Polls for receipt and updates request status (
submitted,confirmed,failed,timeout)
- Node.js 20+ (22 recommended)
- Foundry (
forge,anvil) - Noir (
nargo) - Barretenberg CLI (
bb)
npm installanvilUse services/relayer/.env (already configured for local Anvil defaults):
RELAYER_PORT=8787RELAYER_RPC_URL=http://127.0.0.1:8545RELAYER_SIGNER_PRIVATE_KEYS=0xkey1,0xkey2,0xkey3(preferred, round-robin)RELAYER_SIGNER_PRIVATE_KEY=...(legacy fallback)RELAYER_URL=http://127.0.0.1:8787
Run:
npm run dev:relayernpm run e2e:hardhat-localThis script does all of the following:
- Compiles circuit and contracts
- Generates verifier from VK
- Deploys Poseidon/hash adapter/verifier/tree
- Deploys monolithic
ShieldedToken - Deposits initial notes via
shieldRouted(public balance → commitments, routed discovery events) - Generates and submits routed shielded transfers through relayer
- Waits for on-chain confirmations
- Scans and decrypts recipient notes via viewing keys
Fund the deployer account with Sepolia ETH. Point the relayer at the same RPC and chain (services/relayer RELAYER_RPC_URL, and one or more funded keys via RELAYER_SIGNER_PRIVATE_KEYS or RELAYER_SIGNER_PRIVATE_KEY). Copy .env.sepolia.example to .env.sepolia, set PRIVATE_KEY, then:
node --env-file=.env.sepolia scripts/sepolia-e2e.mjs(npm run e2e:sepolia runs the same script; export vars or use a shell wrapper if you do not use --env-file.)
Required env: TESTNET_RPC_URL, PRIVATE_KEY (deployer, 0x optional), FEE_RECIPIENT_PK (per-transfer fee note owner), and relayer reachable at RELAYER_URL.
- First run (deploy): omit
SKIP_DEPLOY/TRANSFERS_ONLY. Writesscripts/sepolia-deployment.json(gitignored) andscripts/sepolia-e2e-state.jsonafter shields (before transfers). - Reuse deployment, empty Merkle tree:
SKIP_DEPLOY=1with addresses in env or the deployment JSON; runs shields + transfers. - Transfers only (after a run that saved state post-shield, before transfers finished):
TRANSFERS_ONLY=1plus the same deployment andscripts/sepolia-e2e-state.json. On success the state file is removed.
Do not commit .env files or private keys. If a key was pasted into chat or committed, rotate it.
npm run e2e:hardhat-local-poolThis deploys:
MockERC20(underlying token)ShieldedERC20Pool(multi-token routed privacy pool)- Poseidon/verifier/tree stack
Then it runs the same routed private transfer flow and balance summary over pool-held ERC20 notes.
node --env-file=.env.sepolia scripts/sepolia-erc20-pool-e2e.mjsOr via npm script:
npm run e2e:sepolia-poolKey env knobs:
TESTNET_POOL_TOKEN_ADDRESS(existing Sepolia ERC20 to use as underlying token)DEPLOY_MOCK_TOKEN=1(optional demo mode; deploysMockERC20on Sepolia)SKIP_DEPLOY=1(reusescripts/sepolia-pool-deployment.json/SEPOLIA_POOL_*env)ETHERSCAN_API_KEY+VERIFY_CONTRACTS=1(optional deploy-time verification)
For app integration with ShieldedERC20Pool, keep one wallet state per user:
- Spending key / viewing key material (local, never sent on-chain)
- Decrypted note set grouped by
tokenField - Spent-state tracking via nullifier checks
- Merkle sync state from pool events (
Shield,ShieldedTransfer,RoutedCommitment)
Existing Merkle trees are expected in production; clients should hydrate from chain/indexer and generate proofs against currently known roots.
Example: Alice privately sends shielded value to Bob.
- Alice has existing private notes in local wallet (plus Merkle context).
- Alice selects input notes to spend.
- Alice computes nullifiers from spending key + commitments.
- Alice creates output notes (Bob note + Alice change note).
- Alice encrypts each output note using ECDH + AEAD with recipient viewing pubkey.
- Alice generates Noir witness and UltraHonk proof locally.
- Alice sends proof bundle to relayer over HTTP.
- Relayer submits
shieldedTransferRoutedon-chain. - Pool verifies proof, marks nullifiers spent, inserts commitments, emits encrypted note events.
- Bob scans
RoutedCommitmentevents for his channel/subchannel paths. - Bob attempts decrypt with his viewing key.
- Bob discovers only notes addressed to him and stores them locally for future spends.
Current E2E implements:
- ECDH key agreement (
secp256k1) - HKDF-SHA256 key derivation
- AES-256-GCM encryption/decryption
Encrypted note envelope fields:
v: versioneph: ephemeral sender pubkeysalt: HKDF saltiv: AEAD noncect: ciphertexttag: AEAD auth tag
Discovery process:
- Query
RoutedCommitmentlogs filtered by(channel, subchannel)from token deploy block - Attempt decrypt with recipient viewing private key
- Successful decrypt means note belongs to that recipient
Run contracts tests:
npm run test:contractsBuild contracts:
npm run build:contractsCompile circuits:
npm run build:circuitsArbitrum Sepolia (pool deploy + RPC probe):
npm run deploy:pool:arbitrum-sepolia
npm run probe:arbitrum-rpcRelayer smoke test:
npm run test:relayer-smoke- Deposit and unshield are still public boundary actions.
- E2E currently keeps viewing keys in script memory (demo mode).
- Production wallet flow should persist encrypted local note store and key management.
- Additional hardening recommended:
- authenticated associated data (AAD) strategy
- replay/session binding for relayer payloads
- richer wallet-side note indexing and recovery
If you want to run the full system quickly:
anvilnpm run dev:relayernpm run e2e:hardhat-local
You will see deployments, relayed private transfers, on-chain confirmations, and recipient note decryption results in one run.