Skip to content

shielded-org/shielded-token

Repository files navigation

Shielded Token

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


What this project is

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.


Privacy model (important)

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.


Monorepo structure

  • packages/circuits: Noir circuit (main.nr) for shielded transfer constraints
  • packages/contracts: Foundry contracts (verifier, tree, monolithic ShieldedToken, Poseidon helpers)
  • services/relayer: HTTP relayer for submitting shielded transfers on-chain
  • scripts/hardhat-local-e2e.mjs: complete local E2E orchestration
  • scripts/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_getLogs smoke probe (npm run probe:arbitrum-rpc)
  • apps/web: frontend shell (not required for CLI E2E)

Contracts and what they do

ShieldedToken.sol (monolith coordinator)

Single contract combining:

  • ERC20 surface: transfer, approve, transferFrom, balanceOf, totalSupply for 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 address
  • RoutedCommitment(channel, subchannel, encryptedNote): indexed encrypted payloads for channel/subchannel-scoped recipient discovery

ShieldedERC20Pool.sol (multi-token pool)

Standalone privacy pool for existing ERC20s:

  • Token-aware shielding: shieldRouted(token, amount, commitment, encryptedNote, channel, subchannel)
  • Token-bound transfer verification: shieldedTransferRouted(..., tokenField, fee, feeRecipientPk) where tokenField must 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

IncrementalMerkleTree.sol

On-chain note commitment tree.

  • Poseidon2-based parent hashing
  • Rolling known root window for concurrency tolerance
  • Membership roots referenced by proofs

HonkVerifier.sol (generated)

UltraHonk Solidity verifier generated from circuit VK using bb contract.

Poseidon contracts

Poseidon2, Poseidon2YulHasher, Poseidon2Hasher are used to align hashing across circuit + EVM.


Deployed testnet contracts (reference)

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.

Ethereum Sepolia (chain id 11155111)

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

Base Sepolia (chain id 84532)

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 (chain id 421614)

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

Deploy & allowlist (new environment)

  1. Copy .env.arbitrum-sepolia.example to .env.arbitrum-sepolia, set PRIVATE_KEY, and set TESTNET_RPC_URL (defaults to https://sepolia-rollup.arbitrum.io/rpc).
  2. Run npm run deploy:pool:arbitrum-sepolia (writes scripts/arbitrum-sepolia-pool-deployment.json).
  3. Deploy mock USDC/USDT/DAI/LINK with npm run deploy:mock-tokens-batch using TESTNET_CHAIN_ID=421614, DEPLOYMENT_JSON=scripts/arbitrum-sepolia-mock-erc20-deployment.json, and the same RPC URL as step 1.
  4. Run node --env-file=.env.arbitrum-sepolia scripts/enable-pool-tokens.mjs so the pool allowlists those tokens.
  5. Point clients at your pool: set NEXT_PUBLIC_ARBITRUM_SEPOLIA_POOL_ADDRESS (web) and/or VITE_ARBITRUM_SEPOLIA_POOL_ADDRESS (extension) to the deployed ShieldedERC20Pool. When the address matches the canonical pool baked into apps/web/lib/networks.ts / apps/wallet-extension/src/networks.ts, the rest of the contract set is inferred; otherwise set the matching NEXT_PUBLIC_ARBITRUM_SEPOLIA_* / VITE_ARBITRUM_SEPOLIA_* overrides from your deployment JSON.
  6. Relayer: set RELAYER_RPC_URL_ARBITRUM_SEPOLIA in services/relayer/.env (see services/relayer/README.md). Fund relayer signers with Arbitrum Sepolia ETH for gas.

Reference deploy (repo snapshot)

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.

Web app & wallet extension

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/rpchttps://arbitrum-sepolia-rpc.publicnode.comhttps://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.

Relayer & chainId

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


Circuit overview (packages/circuits/src/main.nr)

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 service

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)
  • Broadcasts tx with relayer signer
  • Polls for receipt and updates request status (submitted, confirmed, failed, timeout)

Running the project

1) Prerequisites

  • Node.js 20+ (22 recommended)
  • Foundry (forge, anvil)
  • Noir (nargo)
  • Barretenberg CLI (bb)

2) Install dependencies

npm install

3) Start local chain

anvil

4) Start relayer

Use services/relayer/.env (already configured for local Anvil defaults):

  • RELAYER_PORT=8787
  • RELAYER_RPC_URL=http://127.0.0.1:8545
  • RELAYER_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:relayer

5) Run full E2E (local)

npm run e2e:hardhat-local

This 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

5b) Sepolia testnet E2E

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. Writes scripts/sepolia-deployment.json (gitignored) and scripts/sepolia-e2e-state.json after shields (before transfers).
  • Reuse deployment, empty Merkle tree: SKIP_DEPLOY=1 with 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=1 plus the same deployment and scripts/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.

5c) Local multi-token ERC20 pool E2E

npm run e2e:hardhat-local-pool

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

5d) Sepolia multi-token ERC20 pool E2E

node --env-file=.env.sepolia scripts/sepolia-erc20-pool-e2e.mjs

Or via npm script:

npm run e2e:sepolia-pool

Key env knobs:

  • TESTNET_POOL_TOKEN_ADDRESS (existing Sepolia ERC20 to use as underlying token)
  • DEPLOY_MOCK_TOKEN=1 (optional demo mode; deploys MockERC20 on Sepolia)
  • SKIP_DEPLOY=1 (reuse scripts/sepolia-pool-deployment.json / SEPOLIA_POOL_* env)
  • ETHERSCAN_API_KEY + VERIFY_CONTRACTS=1 (optional deploy-time verification)

5e) Frontend integration note (multi-token pool)

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.


User POV walkthrough (transfer lifecycle)

Example: Alice privately sends shielded value to Bob.

  1. Alice has existing private notes in local wallet (plus Merkle context).
  2. Alice selects input notes to spend.
  3. Alice computes nullifiers from spending key + commitments.
  4. Alice creates output notes (Bob note + Alice change note).
  5. Alice encrypts each output note using ECDH + AEAD with recipient viewing pubkey.
  6. Alice generates Noir witness and UltraHonk proof locally.
  7. Alice sends proof bundle to relayer over HTTP.
  8. Relayer submits shieldedTransferRouted on-chain.
  9. Pool verifies proof, marks nullifiers spent, inserts commitments, emits encrypted note events.
  10. Bob scans RoutedCommitment events for his channel/subchannel paths.
  11. Bob attempts decrypt with his viewing key.
  12. Bob discovers only notes addressed to him and stores them locally for future spends.

Viewing keys and note discovery

Current E2E implements:

  • ECDH key agreement (secp256k1)
  • HKDF-SHA256 key derivation
  • AES-256-GCM encryption/decryption

Encrypted note envelope fields:

  • v: version
  • eph: ephemeral sender pubkey
  • salt: HKDF salt
  • iv: AEAD nonce
  • ct: ciphertext
  • tag: AEAD auth tag

Discovery process:

  • Query RoutedCommitment logs filtered by (channel, subchannel) from token deploy block
  • Attempt decrypt with recipient viewing private key
  • Successful decrypt means note belongs to that recipient

Useful commands

Run contracts tests:

npm run test:contracts

Build contracts:

npm run build:contracts

Compile circuits:

npm run build:circuits

Arbitrum Sepolia (pool deploy + RPC probe):

npm run deploy:pool:arbitrum-sepolia
npm run probe:arbitrum-rpc

Relayer smoke test:

npm run test:relayer-smoke

Current limitations and next hardening steps

  • 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

TL;DR

If you want to run the full system quickly:

  1. anvil
  2. npm run dev:relayer
  3. npm run e2e:hardhat-local

You will see deployments, relayed private transfers, on-chain confirmations, and recipient note decryption results in one run.

Releases

No releases published

Packages

 
 
 

Contributors