Prove OFAC non-membership without revealing your wallet address.
Project Homepage · Live Demo · YouTube · Verified Tx · Docs
GitHub README links always open in the same tab. Use Project Homepage for external links that open in a new tab, or Ctrl/Cmd+click any link here.
NullProof is a zero-knowledge compliance layer for Ethereum. A user generates a cryptographic proof in the browser that their address is absent from the U.S. Treasury OFAC SDN sanctions list. The proof is verified on-chain through a ComplianceGate contract that DeFi protocols can integrate into deposits, swaps, and other gated flows. The wallet address never appears in transaction calldata or on a public block explorer when submitted through the relayer.
The system spans a Noir circuit, UltraHonk verifier contracts, a live OFAC ingestion pipeline, a privacy-preserving relayer, and a production React frontend with in-browser proving via Barretenberg WASM.
- Demo
- The Problem
- Solution Overview
- Proof Lifecycle
- Privacy Architecture
- Technology Stack
- Repository Structure
- Circuit Design
- Smart Contracts
- Oracle and Data Pipeline
- Getting Started
- Production Deployment
- Sepolia Testnet Deployment
- Integration Guide
- Continuous Integration
- Security Considerations
- License
Project Homepage · Live Demo · Verified Submission on Sepolia · Watch on YouTube
External links open in a new tab on the Project Homepage. GitHub README links always open in the same tab.
The walkthrough covers wallet connection, in-browser proof generation, relayer submission, on-chain verification, and an optional CompliantVault deposit.
Financial protocols operating in regulated jurisdictions must screen users against sanctions lists. The standard approach exposes the user's wallet address to the protocol, relayers, indexers, and anyone reading chain data. That creates a permanent, linkable record of who attempted to use a service.
NullProof inverts the trust model: the protocol learns only that some compliant user passed verification, not which address proved compliance.
flowchart TB
subgraph OffChain["Off-chain"]
OFAC["OFAC SDN XML feed"]
Oracle["Oracle pipeline"]
Snapshot["sanctions-imt.json"]
Browser["Browser prover\nNoir + bb.js WASM"]
Relayer["Relayer API"]
end
subgraph OnChain["Ethereum Sepolia"]
SL["SanctionsList"]
CG["ComplianceGate"]
VR["HonkVerifier"]
SR["SubmissionRouter"]
CV["CompliantVault"]
end
OFAC --> Oracle
Oracle -->|"updateRoot()"| SL
Oracle --> Snapshot
Snapshot --> Browser
Browser -->|"ZK proof + nullifier"| Relayer
Relayer -->|"submitCompliant()"| SR
SR --> CG
CG --> VR
CG --> SL
CV -->|"assertCompliant()"| CG
| Capability | Mechanism |
|---|---|
| Non-membership | Indexed Merkle Tree non-membership proof over ~90 sanctioned Ethereum addresses extracted from OFAC SDN |
| Address privacy | Wallet address is a private circuit witness; only Merkle root and nullifier are public inputs |
| Submission privacy | Relayer broadcasts via SubmissionRouter; user EOA is not the on-chain tx.from |
| Replay protection | Poseidon-derived nullifier bound to (address, root, validity_epoch) and consumed on-chain |
| Freshness | Oracle publishes a new Merkle root daily when the OFAC list changes; proofs expire after a configurable window (default 24 hours) |
sequenceDiagram
participant User as User browser
participant Worker as Proof worker
participant Snap as sanctions-imt.json
participant Relayer as Relayer API
participant Router as SubmissionRouter
participant Gate as ComplianceGate
participant Verifier as HonkVerifier
participant List as SanctionsList
User->>Worker: Prove(address)
Worker->>Snap: Load IMT snapshot
Worker->>Worker: Build non-membership witness
Worker->>Worker: Execute Noir circuit
Worker->>Worker: Generate UltraHonk proof
Worker-->>User: proof, publicInputs, nullifier
opt Relayer path
User->>Relayer: POST /api/submit (+ EIP-712 sig)
Relayer->>Gate: checkCompliant (view)
Relayer->>Router: submitCompliant
Router->>Gate: assertCompliant
end
Gate->>Verifier: verify(proof, publicInputs)
Gate->>List: isKnownRoot(root)
Gate->>Gate: consume nullifier
Gate-->>User: Compliant (event emitted)
Public inputs exposed on-chain: Merkle root of the sanctions tree, nullifier hash.
Never exposed on-chain: The user's Ethereum address.
NullProof applies privacy in three independent layers. Each layer removes a different identifier from public view.
flowchart LR
subgraph L1["Layer 1 — Zero-knowledge proof"]
A1["Private: wallet address"]
A2["Public: Merkle root, nullifier"]
end
subgraph L2["Layer 2 — Relayer"]
B1["Private: user EOA as tx sender"]
B2["Public: relayer hot wallet"]
end
subgraph L3["Layer 3 — Submission router"]
C1["Private: user in gate events"]
C2["Public: router contract address"]
end
L1 --> L2 --> L3
| Layer | What stays hidden | How |
|---|---|---|
| ZK circuit | Wallet address in calldata | Address encoded as a u64 witness; only root and nullifier are public |
| Relayer | User EOA on Etherscan | Relayer wallet pays gas and signs the router transaction |
| Router | User in NullifierConsumed events |
ComplianceGate sees msg.sender as the router contract |
Phase 1 optionally requires an off-chain EIP-712 AuthorizeNullifier signature so the relayer can authenticate requests without publishing the signer on-chain. After nullifier binding is enforced in-circuit, relayer auth can be disabled (REQUIRE_RELAYER_AUTH=false).
See docs/privacy-architecture.md for the full threat model and deployment checklist.
| Layer | Technology |
|---|---|
| ZK circuit | Noir (nargo), Indexed Merkle Tree non-membership, Poseidon hashing |
| Proving system | Barretenberg UltraHonk (@aztec/bb.js WASM in browser) |
| Smart contracts | Solidity 0.8.24, Foundry, OpenZeppelin, auto-generated HonkVerifier |
| Frontend | React 18, TypeScript, Vite, wagmi, Reown AppKit, Web Worker proving |
| Oracle | Node.js 20, ethers v6, fast-xml-parser, circuit-aligned snapshot builder |
| Relayer API | Hono, @hono/node-server, EIP-712 recovery |
| CI/CD | GitHub Actions (typecheck, Foundry tests, Vercel deploy, daily sanctions cron) |
| Hosting | Vercel (frontend), Railway / Fly.io / Render (relayer container) |
nullproof/
├── circuit/ # Noir non-membership circuit + nargo tests
├── contracts/ # Solidity: gate, sanctions list, router, vault, verifier
│ ├── src/
│ ├── test/ # 83 Foundry tests
│ └── script/Deploy.s.sol
├── frontend/ # React app, in-browser prover, OFAC snapshot builder
│ ├── public/circuits/ # Compiled bytecode + WASM artifacts
│ ├── public/data/ # sanctions-imt.json, imt-manifest.json
│ └── scripts/build-snapshot.ts
├── oracle/ # Root publisher + relayer HTTP API
│ ├── src/
│ └── Dockerfile # Production relayer image
├── docs/ # Architecture and deployment notes
├── scripts/ # Circuit build, ABI sync, benchmarks
└── .github/workflows/ # ci.yml, deploy.yml, update-sanctions.yml
The Noir circuit (circuit/src/main.nr) proves two statements in a single proof:
- Non-membership — the user's address value lies strictly between two consecutive leaves in a sorted Indexed Merkle Tree of sanctioned addresses, therefore it is not a sanctioned entry.
- Nullifier binding —
nullifier = Poseidon(query_value, root, validity_epoch), tying the proof to a specific address, snapshot, and time window without revealing the address.
flowchart TD
subgraph Witness["Private witness"]
ADDR["Ethereum address → u64 fingerprint"]
LOW["Low leaf + siblings + path indices"]
EPOCH["validity_epoch"]
end
subgraph Public["Public inputs"]
ROOT["Merkle root"]
NULL["Nullifier"]
end
ADDR --> NM["assert_non_membership"]
LOW --> NM
ROOT --> NM
ADDR --> NB["assert_nullifier_binding"]
EPOCH --> NB
ROOT --> NB
NULL --> NB
Tree depth is fixed at 20, supporting up to ~1M leaves with sentinel padding. The live OFAC feed currently yields approximately 90 unique Ethereum addresses from 17,600+ total SDN entries (individuals, entities, non-ETH digital currency addresses, and physical locations are excluded).
| Contract | Role |
|---|---|
SanctionsList |
Stores the current OFAC Merkle root; authorised oracle wallet calls updateRoot() |
HonkVerifier |
On-chain UltraHonk proof verification (generated from circuit VK via bb contract) |
ComplianceGate |
Verifies proofs, checks root freshness, enforces nullifier uniqueness and expiry window |
SubmissionRouter |
Relayer-only entry point; decouples end-user identity from gate events |
CompliantVault |
Reference integration: atomic assertCompliant + ETH deposit |
classDiagram
class SanctionsList {
+currentRoot() bytes32
+updateRoot(root, count)
+isKnownRoot(root) bool
}
class ComplianceGate {
+assertCompliant(proof, inputs, nullifier)
+checkCompliant(proof, inputs, nullifier) view
+usedNullifiers(nullifier) bool
}
class SubmissionRouter {
+submitCompliant(proof, inputs, nullifier)
address relayer
}
class CompliantVault {
+deposit(proof, inputs, nullifier) payable
+withdraw(amount)
}
class HonkVerifier {
+verify(proof, publicInputs) bool
}
SubmissionRouter --> ComplianceGate : calls
CompliantVault --> ComplianceGate : calls
ComplianceGate --> HonkVerifier : verifies
ComplianceGate --> SanctionsList : validates root
Test coverage: 83 Foundry tests across ComplianceGate, SanctionsList, SubmissionRouter, and CompliantVault.
The oracle keeps the on-chain root synchronized with the official OFAC SDN feed.
flowchart LR
subgraph Daily["GitHub Actions — 00:05 UTC"]
Cron["update-sanctions.yml"]
end
subgraph Pipeline["Oracle pipeline"]
Fetch["Fetch sdn.xml"]
Build["buildCircuitSnapshot\n=same as pnpm snapshot"]
Publish["publishRoot → Sepolia"]
Manifest["Write imt-manifest.json"]
end
subgraph Artifacts["Committed artifacts"]
IMT["sanctions-imt.json"]
MAN["imt-manifest.json"]
end
Cron --> Fetch --> Build --> Publish --> Manifest
Build --> IMT
Manifest --> MAN
Cron -->|"if changed"| Vercel["Redeploy Vercel"]
Critical invariant: the oracle runs the same snapshot builder as the frontend prover (frontend/scripts/build-snapshot.ts via CircuitIMT). A mismatch between the prover tree and the on-chain root would invalidate every proof.
For local development when treasury.gov is unreachable:
curl -L -o /tmp/sdn.xml https://www.treasury.gov/ofac/downloads/sdn.xml
OFAC_XML_PATH=/tmp/sdn.xml pnpm snapshot
OFAC_XML_PATH=/tmp/sdn.xml pnpm oracle:run| Tool | Version |
|---|---|
| Node.js | ≥ 20 |
| pnpm | ≥ 9 |
| Foundry | latest (foundryup) |
| Nargo | ≥ 1.0 beta (for circuit work) |
| Barretenberg CLI | for verifier generation |
git clone https://github.com/vamshiganesh/NullProof.git
cd NullProof
pnpm installCopy the example env files and fill in values:
cp frontend/.env.example frontend/.env.local
cp oracle/.env.example oracle/.env
cp contracts/.env.example contracts/.env| File | Purpose |
|---|---|
frontend/.env.local |
Contract addresses, RPC URL, relayer URL, WalletConnect project ID |
oracle/.env |
Oracle wallet, relayer key, Sepolia RPC, CORS origin |
contracts/.env |
Deployer key, oracle address, verifier address, Etherscan API key |
# Terminal 1 — relayer API
pnpm oracle:api
# Terminal 2 — oracle cron (optional locally; production uses GitHub Actions)
pnpm oracle:run
# Terminal 3 — frontend
pnpm devSet VITE_ORACLE_BASE_URL=http://localhost:3001 and CORS_ORIGIN=http://localhost:3003 (or your Vite port) so the browser can reach the relayer.
pnpm typecheck # TypeScript across frontend + oracle
pnpm build # Production frontend bundle
pnpm contracts:test # 83 Foundry tests
pnpm circuit:test # Noir unit tests
pnpm benchmark # Proof generation latency report (P50/P95/P99)flowchart TB
subgraph Users
Browser["End users"]
end
subgraph Vercel["Vercel"]
FE["Static frontend\nCOOP/COEP for WASM"]
end
subgraph Host["Railway / Fly / Render"]
REL["Relayer container\noracle/Dockerfile"]
end
subgraph GitHub["GitHub Actions"]
Deploy["deploy.yml\npush to main"]
Sanctions["update-sanctions.yml\ndaily cron"]
end
subgraph Chain["Sepolia"]
Contracts["NullProof contracts"]
end
Browser --> FE
FE -->|"VITE_ORACLE_BASE_URL"| REL
REL --> Contracts
Sanctions --> Contracts
Sanctions --> FE
Deploy --> FE
| Component | Platform | Key configuration |
|---|---|---|
| Frontend | Vercel | Root directory frontend; all VITE_* env vars; frontend/vercel.json sets COOP/COEP headers |
| Relayer | Docker host | RELAYER_PRIVATE_KEY, SUBMISSION_ROUTER_ADDRESS, CORS_ORIGIN = Vercel URL |
| Daily root update | GitHub Actions | ORACLE_PRIVATE_KEY, SEPOLIA_RPC_URL, SANCTIONS_LIST_ADDRESS, Vercel tokens |
| WalletConnect | cloud.reown.com | VITE_WALLETCONNECT_PROJECT_ID |
GitHub repository secrets for automated deploy:
| Secret | Source |
|---|---|
VERCEL_TOKEN |
vercel.com/account/tokens |
VERCEL_ORG_ID |
.vercel/project.json after vercel link |
VERCEL_PROJECT_ID |
.vercel/project.json after vercel link |
SEPOLIA_RPC_URL |
Alchemy or Infura Sepolia endpoint |
ORACLE_PRIVATE_KEY |
Hot wallet authorised on SanctionsList |
Fund the relayer and oracle wallets with Sepolia ETH before going live.
Contracts are deployed and verified on Ethereum Sepolia:
| Contract | Address |
|---|---|
| ComplianceGate | 0x1906B284ef0DA8Dc41b531bb08E2Ae9eEAAeEA5f |
| SanctionsList | 0xc4981582b3Fd662F825BcAA943F06F3E91cBb628 |
| HonkVerifier | 0x858993CbAF3F0e7a53e8a3d913cf38C1C7a2Bbc7 |
| SubmissionRouter | 0x094AC492023157c9e2F228e3620e31C249cd3035 |
| CompliantVault | 0x651304b0a0883C882Aa2a9a24E5AAC911AFc829f |
Explorer links: ComplianceGate · SanctionsList Sample verified compliance submission: Etherscan
Protocols integrate compliance by calling ComplianceGate.assertCompliant inside their gated function:
function deposit(
bytes calldata proof,
bytes32[] calldata publicInputs,
bytes32 nullifier
) external payable {
complianceGate.assertCompliant(proof, publicInputs, nullifier);
// ... protocol logic proceeds
}CompliantVault is a minimal reference implementation. Production integrations should:
- Call
checkCompliantoff-chain before asking the user to sign a transaction - Respect the proof validity window (default 24 hours)
- Use the relayer path when address privacy is required
- Never reuse a nullifier across multiple actions (each proof consumption is one-time)
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml |
Pull request | Lint, typecheck, Foundry tests, frontend build |
deploy.yml |
Push to main |
Pre-deploy guard, Vercel production deploy, smoke test |
update-sanctions.yml |
Daily 00:05 UTC | OFAC fetch, root publish, snapshot commit, conditional redeploy |
NullProof is a testnet demonstration and research artifact. It is not audited for mainnet production use.
| Topic | Status |
|---|---|
| Smart contract audit | Not conducted |
| ZK circuit audit | Not conducted |
| OFAC coverage | Ethereum addresses only (~90 entries); not a complete sanctions screening product |
| Relayer trust | Phase 1 relayer sees EIP-712 signer off-chain; use relayer auth disable only after understanding the tradeoff |
| Key management | Oracle and relayer hot wallets must be funded and rotated if exposed |
| Proof cost | In-browser proving takes tens of seconds depending on hardware; WASM requires COOP/COEP headers in production |
MIT