A privacy-preserving token transfer protocol on Sui, inspired by Tornado Cash and Mixoor. Users deposit SUI into a shared pool and withdraw to any address with zero on-chain link between sender and receiver.
Zero-knowledge proofs are generated entirely in the browser — the server never sees your secret or nullifier.
- Deposit — User generates a random secret + nullifier in the browser, computes a Poseidon commitment, and deposits SUI into the on-chain pool. The commitment is stored in a Merkle tree.
- Wait — The backend indexer picks up the deposit event and updates its local Merkle tree.
- Prove — The browser fetches the Merkle path for the deposit and generates a Groth16 ZK proof entirely locally (using snarkjs + WASM). Secrets never leave the device.
- Withdraw — The proof is submitted to the relayer backend. The relayer verifies the proof on-chain and transfers SUI to any specified recipient.
Merkle/
├── circuits/ # Circom ZK circuit (transaction.circom)
├── contract/ # Sui Move smart contracts
│ └── sources/
│ ├── pool.move # Pool state and Merkle root management
│ ├── deposit.move # Deposit logic + event emission
│ ├── withdraw.move # ZK proof verification + withdrawal
│ ├── verifier.move # On-chain Groth16 verifier (BN254)
│ ├── admin.move # Pool admin controls
│ ├── events.move # Event type definitions
│ └── errors.move # Error codes
├── backend-nodejs/ # Express.js relayer + Merkle tree indexer
│ └── src/
│ ├── indexer.ts # Polls Sui events, builds Merkle tree
│ ├── merkle.ts # In-memory Poseidon Merkle tree
│ ├── relayer.ts # Signs and submits withdrawal TXs
│ ├── api.ts # REST API routes
│ └── config.ts # Environment configuration
└── client/ # Next.js frontend
└── lib/
├── prover.ts # Browser-side Groth16 proof generation
├── poseidon.ts # Browser-side Poseidon hashing
├── converter.ts # Arkworks G1/G2 compression for Sui
├── transactions.ts # Deposit/withdraw TX builders
└── api.ts # Backend API client
- Sui CLI (
sui) - Node.js v20+
- pnpm (backend) and bun (frontend)
- A funded Sui testnet wallet for the relayer
cd contract
sui client publish --gas-budget 200000000Copy the PACKAGE_ID and POOL_OBJECT_ID from the output.
cd backend-nodejs
cp .env.example .env # fill in PACKAGE_ID, POOL_OBJECT_ID, RELAYER_SECRET_KEY
pnpm install
pnpm devBackend runs on http://localhost:3001.
cd client
cp .env.example .env # set NEXT_PUBLIC_BACKEND_URL
bun install
bun devFrontend runs on http://localhost:3000.
| Variable | Description |
|---|---|
PORT |
Server port (default: 3001) |
SUI_RPC_URL |
Sui RPC endpoint |
PACKAGE_ID |
Deployed contract package ID |
POOL_OBJECT_ID |
Pool shared object ID |
RELAYER_SECRET_KEY |
Relayer Sui private key (suiprivkey1...) |
POLL_INTERVAL_MS |
Indexer poll interval in ms (default: 3000) |
| Variable | Description |
|---|---|
NEXT_PUBLIC_BACKEND_URL |
Backend API URL |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/health |
Health check + leaf count |
GET |
/api/pool/stats |
Pool stats (root, leaf count, etc.) |
GET |
/api/pool/root |
Current Merkle root |
GET |
/api/pool/path/:index |
Merkle path for a leaf index |
GET |
/api/relayer/status |
Relayer address + SUI balance |
POST |
/api/relay/submit |
Submit a pre-proven withdrawal |
- Secrets (
secret,nullifier) are generated and stored only in the user's browser - The backend never receives secrets — only the ZK proof bytes
- The nullifier hash prevents double-spending without revealing which deposit is being withdrawn
- The Merkle tree root links the proof to the pool state without leaking the depositor's identity
| Layer | Technology |
|---|---|
| Smart Contracts | Sui Move |
| ZK Circuits | Circom 2 + Groth16 (BN254) |
| On-chain Verifier | Arkworks BN254 (Move) |
| Backend | Node.js + Express + TypeScript |
| Frontend | Next.js + TypeScript |
| Browser Proving | snarkjs + circomlibjs |
MIT