Skip to content

Repository files navigation

Clean Privacy

Verified-identity-gated private transfers and swaps for ERC-20s, with compliance verifiable to authorized parties — live on Monad Testnet (primary), BNB Chain Testnet, and Ethereum Sepolia, plus a pool-to-pool bridge from Monad to Arc Testnet over Circle CCTP V2. Built on Cleanverse A-Pass / CVI for identity and Unlink zk-notes for privacy.

Privacy for users. Transparency for regulators. Clean money only.

Website https://cleanprivacy.org
X / Twitter https://x.com/CleanPrivacy
Demo Video https://youtu.be/oxADWEJtSWc
Presentation https://docs.google.com/presentation/d/1B1zeM9EtP5QEGNI_oBJK3z4fpL2eai2n5LSrsYohJlM/edit?usp=sharing

Everything below is testnet. There is no mainnet deployment, no audit, and no real value at stake.

What this actually hides

Flow Sender Recipient Token Amount
Deposit (public → pool) public public public public
Unlink → Unlink transfer private private private private
Withdraw (pool → 0x…) private public public public
Unlink-funded swap private (funding note only) public (ExecutionAccount) public public
Bridge Monad → Arc, end to end private note → public wallet public wallet → private Arc note public public

Read this literally. A deposit is a public, linkable on-chain event by design — that is what makes the entry gate auditable. A withdrawal hides which note paid and its history, nothing else. And an Unlink-funded swap is not a confidential swap: only the pool account that funded it is hidden. The ExecutionAccount address, the pair, the amounts, the router and the calldata are all public. The app's own screen copy says exactly this, and so does this README.

The bridge row is a composition of the rows above it: it is a Withdraw, then a fully public CCTP hop, then a Deposit on Arc. So which Monad note paid stays private, and once the funds are back inside the Arc pool they are private again — but everything between, wallet, token and amount, is public, and the same wallet appears on both sides. The per-leg breakdown is in the bridge section.

Proof of privacy — check it on the explorers

The table above is a claim. This one is the receipt: the same operator wallet 0xbd4f381d9CCA69f3f38570B5c3b31370E29F00A1 ran deposit, private transfer and private swap on all three chains, and you can open every transaction below.

Flow Monad Testnet BNB Testnet Ethereum Sepolia Operator wallet on the explorer
Deposit — gate 0xcf0760…e5dc 0xbd4d06…1f86 0x7e86d6…6d03 visible — it is the from
Deposit — into the pool 0x062272…8da0 0x9daeca…3269 0xb94193…b81a visible — in calldata and logs
Private transfer 0xa8de4f…2ee2 0x67f905…e003 0xa3e426…ba1fc absent
Private swap — phase B 0x19822a…6b2b 0xbd4dba…f61b 0xba85c8…282e absent

Read down the last column and the design falls out of it. The two deposit legs are supposed to expose the wallet — that is the auditable entry gate, and proveClean is the only transaction in the whole system the user's own EOA signs. After that the wallet stops appearing: the private transfer and the swap are submitted by an Unlink relayer, the pool sees notes rather than accounts, and the swap executes from a throwaway ERC-4337 ExecutionAccount that has no link to the depositor.

Reproduce it in one pass — for each transaction, fetch it plus its receipt and search from, to, the calldata and every log topic and data blob for the wallet address:

const tx = await client.getTransaction({ hash });
const receipt = await client.getTransactionReceipt({ hash });
const bare = wallet.slice(2).toLowerCase();   // no 0x, addresses are padded in logs

tx.from.toLowerCase() === wallet.toLowerCase();
tx.input.toLowerCase().includes(bare);
receipt.logs.some((l) => l.topics.some((t) => t.toLowerCase().includes(bare))
                      || l.data.toLowerCase().includes(bare));

Both deposit legs return true. All six transfer and swap transactions return false on every one of those checks, on all three chains.

What this does not prove. Absence of the address is unlinkability at the transaction level, not anonymity in general. These are testnets with thin traffic and, for most of these runs, one active user — amount and timing correlation would still narrow things down for an observer who cared. The claim here is exactly the one in the table above: the pool operations do not carry the depositor's address. Anything stronger needs a real anonymity set.

The bridge: Monad pool → Arc pool

/bridge in the app (and a CLI relayer in the private tree) moves USDC held privately in the Unlink pool on Monad Testnet into the Unlink pool on Arc Testnet, over Circle CCTP V2 with the Crosschain Forwarding Service. Say it precisely: private pool → public CCTP hop → private pool. This is not a privacy bridge; the two ends are private and the middle is a normal, fully visible Circle transfer.

What each leg reveals:

Leg What is visible on-chain
1. Gated withdraw (Monad pool → wallet) recipient wallet, token, amount — the funding note and its history stay private
2. depositForBurnWithHook burn (Monad) the wallet, the amount, the Arc mint recipient — all in the event
3. Circle attestation + forwarder mint (Arc) the same amount minus Circle's fee, credited to the same wallet; Circle's relayer submits and pays for the mint
4. depositWithApproval (wallet → Arc pool) the wallet and the amount entering the pool

What this does not hide — read it literally. The same wallet exits on Monad and re-enters on Arc, minutes apart, with a matching amount. Anyone can link the two sides by address, amount and timing; the anonymity set is one. What the bridge demonstrates is the mechanism — a compliance-gated exit from one shielded pool, a canonical Circle transfer, and re-entry into another shielded pool — not cross-chain unlinkability. Getting the latter needs a shared relayer pool, batching, and randomized amounts/timing, none of which are built.

Two more honest asymmetries:

  • The compliance gate applies to the Monad exit only. The withdraw refuses unless the wallet holds an active A-Pass (POST /me/exit-check). Arc re-entry has no CleanGate and no attester — the legs are not symmetric.
  • Circle sees everything. The attestation service reads every CCTP message by design; its forwarder fee (~0.02 USDC on this route, quoted live) is deducted from the bridged amount.

On Arc the gas token is USDC (one balance, 18-decimal native and 6-decimal ERC-20 views), so the final deposit self-funds from the bridged amount — the user needs no separate gas on Arc. The flow is crash-safe end to end: every step is persisted before submission, a burn with a recorded hash is never re-submitted, and reloading the page resumes where it stopped.

Live proof (operator wallet, 2026-08-08): burn 0x1e19d0…58c28 on Monad → mint 0x3b990f…c675e on Arc, deposited into the Arc pool in the same run. The destination account is the Unlink account the connected wallet derives on Arc (app id clean-privacy-arc) — each wallet gets its own.

Why only Arc? The destination set is the intersection of chains Unlink hosts and chains CCTP V2 serves. Ethereum Sepolia qualifies and is a candidate; BNB Chain does not — Circle has never deployed CCTP or native USDC there, so a Monad → BNB bridge is not possible on this stack until Circle ships it.

The compliance model

Three layers, in increasing strength. Only the first two are live on every chain.

Layer 1 — off-chain eligibility. The attester queries Cleanverse query_apass for the wallet and, only if the A-Pass is active, signs a short-lived EIP-712 Attestation(user, token, amount, issuedAt, expiry, nonce) (TTL ≤ 15 minutes, single-use nonce).

Layer 2 — on-chain attestation. The user submits that signature to CleanGate.proveClean(...), which validates it, burns the nonce and emits CleanAttestation.

Layer 3 — on-chain compliance verification (Monad only). CleanGate v2 holds an immutable complianceValidator pointer at the Cleanverse CVI validator. When the gate is registered, every attestation additionally calls complianceValidator.complianceVerify(gate, user) on-chain and reverts NotCompliant if it fails. Registered rule: min_tier ≥ 30. Freezing a wallet in Cleanverse flips complianceVerify to false within seconds and the gate stops accepting it — proven by a live freeze probe (validator_freeze_probe_monad-testnet.json, see Evidence). BSC Testnet and Sepolia still run the v1 gate and have no Layer 3.

The deposit is attestation-RECORDED, not deposit-enforced

This is the single most important honest caveat in the project.

CleanGate.proveClean records eligibility. The user then submits a separate Unlink depositWithApproval transaction. Nothing on-chain forces the second to follow the first, and nothing forces the amounts to match. The contract source says so itself (contracts/src/CleanGate.sol:146): "This function does not transfer tokens and does not prove or claim that a deposit occurred."

A truly enforced path exists in the contract — gatedDeposit(...), which pulls the tokens and forwards them to a pool adapter atomically — but it requires setPool(...) to point at an IDepositTarget implementation. pool is 0x0000…0000 on every chain, no such adapter is deployed, and gatedDeposit has never executed on-chain. Wiring it needs a pool-side adapter that Unlink does not currently expose.

Exit is gated too. Before building a withdrawal, the app calls POST /me/exit-check and refuses unless the recipient address holds an active A-Pass. That gate is this project's policy, enforced in the app — Unlink itself does not enforce it, and a determined user with the SDK could bypass it. Stated plainly rather than dressed up.

Architecture

flowchart LR
    OP(["Wallet operator\n(browser, non-custodial)"])

    subgraph WEBAPP["web/ — Vite + React SPA"]
        ACC["/verify\nA-Pass status + unlink1 address"]
        DEP["/deposit"]
        XFR["/transfer\nprivate transfer or gated exit"]
        SWP["/swap"]
        EVD["/evidence\nuser history + regulator console"]
    end

    ATT["backend/ attester\none process per chain\n8787 / 8788 / 8789"]
    CV["Cleanverse API\nA-Pass · faucet · Travel Rule"]
    SDK["@unlink-xyz/sdk\nspending key derived in-tab"]

    GATE["CleanGate v2\nEIP-712 attestation"]
    CVI["Cleanverse CVI validator\n(on-chain, Monad only)\nrule: min_tier ≥ 30"]
    POOL[("Unlink Pool\nzk-notes")]
    EA["ExecutionAccount\n(ERC-4337, per swap)"]
    DEX["UniswapV2 Router02\nself-hosted, official bytecode"]
    OUT(["Public 0x address\n(withdrawals)"])
    CHAIN[["Monad 10143 · BSC 97 · Sepolia 11155111"]]

    OP --> ACC --> ATT
    ATT -->|"Layer 1: query_apass\n(all chains, off-chain)"| CV
    OP --> DEP
    OP --> XFR
    OP --> SWP
    OP --> EVD --> ATT

    DEP -->|"1. POST /attest"| ATT
    DEP -->|"2. Layer 2: proveClean\n(only EOA tx)"| GATE
    DEP -->|"3. depositWithApproval"| SDK
    XFR -->|"POST /me/exit-check"| ATT
    XFR --> SDK
    SWP --> SDK

    GATE -->|"Layer 3: complianceVerify\nreverts NotCompliant"| CVI
    GATE --> CHAIN
    CVI --> CHAIN
    SDK -->|deposit / transfer| POOL
    SDK -->|"Phase A: withdrawFromPool"| EA
    EA -->|"Phase B: swapExactTokensForTokens"| DEX
    DEX -->|returnToPool: re-shield| POOL
    SDK -->|withdraw| OUT
    POOL --> CHAIN
    EA --> CHAIN
Loading

Reading the diagram: the attester never signs for the user and never holds user funds. It signs EIP-712 eligibility attestations with its own dedicated key and nothing else. Every state change — proveClean, deposit, transfer, swap, withdraw — is signed by the user's own wallet in the browser.

The Unlink spending key is never stored. It is re-derived per tab from a personal_sign over a fixed message (appId: "clean-privacy", chain id, index 0), so each chain yields a different unlink1… account and closing the tab takes the key with it.

Swaps are two sponsored UserOperations: Phase A privately withdraws into a fresh ERC-4337 ExecutionAccount, Phase B executes the public swap from that account and sweeps the output back into the pool (returnToPool). Quote bounds (90/110) are persisted before submission, a cross-tab lease prevents a second tab from racing, and Phase B is submitted at most once. Gas is sponsored — the user pays 0 native.

Supported chains

Chain Chain ID Deposit token DEX pair CleanGate CVI Layer 3
Monad Testnet 10143 USDC (6 dec) USDC / WMON v2 ✅ registered
BNB Chain Testnet 97 USDCm (18 dec) USDCm / WBNB v1
Ethereum Sepolia 11155111 USDC (6 dec) USDC / WETH v1

Arc Testnet (5042002) is a bridge destination only, not a fourth app chain: it enters the web bundle as a separate BRIDGE_CHAINS registry (so the three full chains above are provably untouched), has no CleanGate, no attester and no swap, and appears in the app exclusively on the /bridge screen. The Monad attester additionally issues Unlink registrations and auth tokens for the Arc environment (/me/unlink-arc/*, session-authenticated, separate admin key and address store).

Deposit, private transfer, private two-phase swap and gated withdrawal have all been executed live on all three chains, with a JSONL run log per flow (see Evidence). A chain is only reported as supported when the full set passes; deposit plus transfer without a successful swap is partial, not supported.

On BSC, the deposit token is not Circle USDC. It is Unlink's 18-decimal mock USDCm, used because the Cleanverse BSC faucet wallet was empty (reported). Cleanverse's real BSC USDC stays allowlisted on the gate for when that is fixed. The code refuses to label the mock "USDC" anywhere in the UI (scripts/generate-web-chains.mjs:41), and it likewise never calls the self-hosted DEX "PancakeSwap" — no PancakeSwap contract is involved anywhere in this project.

Chain and address data is generated, never hand-copied: config/chains.json + deployments/scripts/generate-web-chains.mjsweb/src/generated/chains.ts. npm run check:chains re-renders and byte-compares, and it is wired into the web build script — so every build proves the registry is current.

Deployed contracts — full transparency

Everything in this section is taken from config/chains.json and the JSON records in deployments/.

One testnet EOA does everything: 0xbd4f381d9CCA69f3f38570B5c3b31370E29F00A1 is the deployer of every contract below, the Ownable2Step owner of every CleanGate, the UniswapV2 feeToSetter, and the LP holder of all three seeded pairs. No multisig, no timelock, no proxy, no audit. renounceOwnership() is disabled by design; the owner's full admin surface is setAttester, setPool, setTokenAllowed, rescueTokens, and the four Cleanverse rule passthroughs.

The attester is a separate dedicated key, 0xa69A7e617aD3E438380fF8AB71cd963E512e2464, the same on all three chains, rotatable by the owner.

Deployed by this repo

Two things: CleanGate, the only original contract in this repository (contracts/src/CleanGate.sol); and a self-hosted UniswapV2 deployment using official unmodified @uniswap/v2-core@1.0.1 / @uniswap/v2-periphery build artifacts — deployed because a getCode sweep proved the documented Uniswap deployments do not exist on Monad Testnet (phase3_uniswap_probe.json). There is no Uniswap source in this repo; the deploy script hard-asserts the pair init-code hash equals the canonical 0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f before deploying, so Router.pairFor works unmodified.

Monad Testnet (10143)deployments/monad-testnet.json, deployments/univ2-mini.monad-testnet.json

Contract Address Deploy tx
CleanGate v2 0xf338b39A1fAd753AFDB089049fF0D63FE48458D5 0x626435884c890c8c64a5233d81417657c95671fe302026cd5a5f1e1f9a534349 (block 50523360)
UniswapV2Factory 0x3177B59723358bF681710a68EC183A0470425be9 not recorded — deployed by an earlier bespoke script
UniswapV2Router02 0x7b553a394c1329AF18B1D64f32AAc080da5Ff0a6 not recorded
UniswapV2Pair USDC/WMON 0xeD4fe03E2b0129A5726D127C70a789Aa3acc8992 created by addLiquidity 0xe4cce43568ee7f415229fafe309159b8d98bd107c5e9c4599ba3b0dd268e6422
  • CleanGate v2 constructor: owner 0xbd4f381d…00A1, attester 0xa69A7e61…2464, validator 0xaC7e5179C2C7f03f209136886c172eb34F161792
  • setTokenAllowed(USDC, true)0xe88447a7180a8fbdc84c567b5cc01c9d43b048514c44be75820f06c566dd9927
  • Validator registration 0xa88afc038538be93c00295ab69b7effa08b4111f3d83628cc7ca4240cab4295e, rule min_tier: 30 set by 0xf66b2fb6a7e359cd576768639f7911b57f00b2751e13599d60c52de1f5b41db7
  • Both-layers-enforced smoke proveClean: 0x759f3d66144b84eb5fdb39bdada347108bbfb8a94b59562193975cbc18a6ea4d (block 50525812)
  • Seed liquidity: 20 USDC + 2 WMON → reserves [20000000, 2000000000000000000], token0 = USDC

A superseded CleanGate v1 at 0x49333EfDc08d3C6246E4731cbD5a09e5b0C4afC9 is still on chain, left as-is (pool() = 0, same attester); it is no longer in the config and is only referenced by an EIP-712 digest test fixture.

BNB Chain Testnet (97)deployments/clean-gate.bsc-testnet.json, deployments/univ2-mini.bsc-testnet.json

Contract Address Deploy tx
CleanGate v1 0xD1c8D682fDA1F6B1CB54deBD3a0ab77DAc8B9821 0x476e220ff61d1a7b4e53b28e423bec4a57a2e54712af598e56fefc30a0253098
UniswapV2Factory 0x7b553a394c1329AF18B1D64f32AAc080da5Ff0a6 0x8da4b591018d68443f9101482a53f69c4adf9d0b7f8178e6d17b3108f6f976b2
UniswapV2Router02 0x78Cfd80bBAb058159701C56ccF5d5f67AbA6cD63 0x574a82561cbc7874a8931538cb77283505ecde49b34778af0905ce5f561359cc
UniswapV2Pair USDCm/WBNB 0x4edCDBAd986D67Aed1616BB734212b80882845e3 created by addLiquidity 0xffd75598f411621bc76a90fca514233dfca1dd8919f3cbd72ac7d416b32a7547
  • Allowlisted tokens (two): Cleanverse USDC 0x6f4A0EFe923fD35434fBC564654550467613EE5F (tx 0xc6fc9b27135ffd15a72270f5bae48723d02e93703ce2b9b0761f9df07e99eae8) and Unlink USDCm 0x990E77bdff7A062a27f8666C0b3e6f5DdF0C24Fc (tx 0xc442bcd0c2478deb447ccc60eca33eb076255a49dbec769f46bb99d3a4e55871)
  • Seed liquidity: 1 USDCm + 0.002 WBNB

Ethereum Sepolia (11155111)deployments/clean-gate.ethereum-sepolia.json, deployments/univ2-mini.ethereum-sepolia.json

Contract Address Deploy tx
CleanGate v1 0xD1c8D682fDA1F6B1CB54deBD3a0ab77DAc8B9821 0x069416cf4bc4ab5abb879317a385155cee15c837b310f352c4c911211a10566c
UniswapV2Factory 0x3177B59723358bF681710a68EC183A0470425be9 0x8632da9259e3b7be34310b6276828369b57097d80dbdae5a42cb851ace6c7dfd
UniswapV2Router02 0x7b553a394c1329AF18B1D64f32AAc080da5Ff0a6 0x388f96d17b2371fb3246aa0c7477b492d706ce07e890c8013797327795fde485
UniswapV2Pair USDC/WETH 0xC1f8e1e50ddB4c06BAFc05409Efe66b2220f3fa5 created by addLiquidity 0x19ecc11b1e0753b9f271c69e27cb35397862ff07f350d4ad710cf00f211474b5
  • setTokenAllowed(USDC, true)0x49d5f51ca52947e2fba6557579c9417ce8cbe34c6d8c3973baf959f6dff1ba89
  • Seed liquidity: 1 USDC + 0.002 WETH

Addresses repeat across chains because the same EOA at the same nonce produces the same CREATE address. 0xD1c8D682…9821 is the gate on both BSC and Sepolia; 0x7b553a39…F0a6 is the router on Monad and Sepolia but the factory on BSC. That is expected, not a copy-paste error.

Source verification: all three CleanGate deployments are Sourcify-verified (Monad v2 is an exact_match, matchId 43242640). The UniswapV2 contracts are not verified on any chain — Sourcify failed and no explorer API key was configured, recorded honestly as skipped_no_api_key in the deploy evidence.

These are testnet DEX pools with trivial liquidity controlled by one operator key. They are not audited and are not meant to hold value.

Referenced, not deployed by this repo

Contract Address Chain Owner
Cleanverse AccessCore 0x8F118338a1fa41E7Fa86Be19A4e8B99Ed58A6EcC all three Cleanverse
Cleanverse A-Pass Registry 0xbA82D189540CaC9DC6FF46B6837CaC1BFdEC58B9 all three Cleanverse
CVI Compliance Validator (EIP-1967 proxy) 0xaC7e5179C2C7f03f209136886c172eb34F161792 Monad Cleanverse
USDC (Cleanverse test issuance) 0x534b2f3A21130d7a60830c2Df862319e593943A3 Monad Cleanverse
USDC (Cleanverse test issuance) 0xF3b9fa48aA249A4032D0B2A4899048CCB92d1A96 Sepolia Cleanverse
USDC (Cleanverse, 18 dec, allowlisted but unused) 0x6f4A0EFe923fD35434fBC564654550467613EE5F BSC Cleanverse
USDCm (Unlink mock, 18 dec) 0x990E77bdff7A062a27f8666C0b3e6f5DdF0C24Fc BSC Unlink
aUSDC / CVA 0xaC0893567D43C3E7e6e35a72803df05416C1f20D Monad, Sepolia Cleanverse
aUSDC / CVA 0x8e74D3E3ab086152ba58276F6376f7b9fBA54d27 BSC Cleanverse
WMON 0xFb8bf4c1CC7a94c73D209a149eA2AbEa852BC541 Monad canonical
WBNB 0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd BSC canonical
WETH9 0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14 Sepolia canonical
Unlink Pool 0x27bf69463f066e1de5ecbd0ca54f7e01b0871abc Monad Unlink
Unlink Pool 0x53effd6eb74538de0a148f4e4ef26df84b9a36b0 BSC Unlink
Unlink Pool 0xf6163c29f38f3682103e39a59ff98b521eba4816 Sepolia Unlink
ExecutionAccount factory 0x12eed73d41964c5ee5d90bba668936c1ed93f061 Monad Unlink
ExecutionAccount factory 0x55b67555d6d7fa980ed43903cb45745c901652f5 BSC Unlink
ExecutionAccount factory 0x36ccdb5d0c254cb681467a267265c75ea62a84d7 Sepolia Unlink
ExecutionAccount implementation 0x706ee3ec5e4b0563f64392095b0edae14be9b9f4 Monad Unlink
ExecutionAccount implementation 0xe3364db2d66e831407352dc4b0775ffeab1acbfb BSC Unlink
ExecutionAccount implementation 0xddb1229d336d1f6a5e354c9d9f6acdccfe5278b6 Sepolia Unlink
Paymaster 0x5AD169d29ad62a3B7e06a9b8e00Fee4a984407db Monad Unlink
Paymaster 0x98AdBd91b5C0E7aA21D3f816E02BDDDCe9611F3D BSC Unlink
Paymaster 0x70f206A9aC2d27372309fb2B7c4C6825E10Ce5F0 Sepolia Unlink
EntryPoint (ERC-4337 v0.7) 0x0000000071727de22e5e9d8baf0edac6f37da032 all three canonical
Permit2 0x000000000022d473030f116ddee9f6b43ac78ba3 Monad canonical

None of the wrapped natives are self-deployed — all three are canonical. A WETH9 fallback deployer exists for a chain without one and has never been run (no deployments/wrapped-native.*.json exists).

Before any real flow runs, the preflight CLI calls getCode on the pool, EntryPoint, ExecutionAccount factory and implementation and refuses to proceed if any is empty; the smoke CLI additionally verifies the wrapped native's bytecode, symbol and decimals against config and runs a real deposit→withdraw round trip. The backend's /ready endpoint does the same for CleanGate: bytecode present, on-chain attester() matches config, USDC allowlisted — and it is live, so you can check it yourself:

curl https://attester-monad-production.up.railway.app/ready

Repository structure

Path Role
web/ Vite + React SPA — non-custodial browser UI. Live mode only; there is no demo/mock service.
backend/ Hono attester, one process per chain. Issues EIP-712 attestations, proxies Cleanverse, gates exits and the regulator console. Never signs for the user, never holds funds.
contracts/ Foundry package — CleanGate.sol, the Cleanverse validator interface, and 51 tests.
config/chains.json Single validated chain/token/address registry — source of truth for the generated web config.
deployments/ clean-gate.*.json / univ2-mini.*.json are deployment records. mc-budget.*.json (spend ledger) and mc-checkpoint.*.json (crash-resume state) live here too but are runtime state, not deployments.
scripts/ Only what the web build needs: generate-web-chains.mjs (chain registry generator) and its config loader.
railway.json One config for both deploy roles — static web build, or an attester process selected by SERVICE_ROLE.

This is a trimmed public snapshot. The working repository also holds the operational CLI (deploy scripts, live-run flows, probes, diagnostics), the JSONL evidence logs those runs produce, the internal planning docs and the brand and marketing assets. Those are deliberately not published; everything the app needs to build, deploy and run is here. Sections below that describe the CLI and the evidence logs describe that private tree, and say so where it matters.

Local-only and gitignored: .env.local.txt (Cleanverse + Unlink admin keys), backend/.env, test_wallet.json, backend/state/, contracts/out|cache|broadcast. The landing page's hero video is the one marketing asset that is committed, because the deployed site serves it.

Running it

The fastest way is not to run it at all — the whole stack is deployed:

App https://cleanprivacy.org
Monad attester https://attester-monad-production.up.railway.app
BSC attester https://attester-bsc-production.up.railway.app
Sepolia attester https://attester-sepolia-production.up.railway.app

Web and the three attesters each run as a Railway service off this repository, one process per chain, and railway.json picks the role from SERVICE_ROLE. The browser reaches its attester through the build-time VITE_ATTESTER_URL_<CHAIN> variables; with none set, the generated registry falls back to 127.0.0.1:8787/8788/8789 for local development.

To run it locally you need Node 22+, a backend/.env (copy backend/.env.example) and one attester process per chain:

npm ci
CHAIN=monad-testnet npm run dev --workspace backend    # :8787
npm run dev --workspace web                            # :5173

Repeat the backend line with CHAIN=bsc-testnet (:8788) and CHAIN=ethereum-sepolia (:8789) in their own terminals to exercise all three chains. Do not set CHAIN or PORT inside backend/.envtsx --env-file values override the shell environment, which would force every instance onto the same chain and port. Required values are CLEANVERSE_API_ID, CLEANVERSE_API_KEY and ATTESTER_PRIVATE_KEY; the Unlink admin key is read from the repository-root .env.local.txt and never reaches the browser. The one-command launcher that starts all four processes together lives in the private tree.

Live mode only. The app needs a browser wallet extension holding a testnet wallet and talks directly to the attester, the chain and the Unlink pool. There is no simulated mode.

Routes: / landing, /verify (labelled Account — A-Pass status, on-chain validator status, your unlink1… receive address), /deposit, /transfer (recipient decides the protocol: unlink1… = private transfer, 0x… = gated exit), /swap, /evidence (your attestation history and pool history, plus a regulator console for allowlisted addresses).

Checks:

npm test                    # attester + frontend unit tests
npm run typecheck           # backend and web
npm run web:check-chains    # proves the generated chain registry is current

Contract tests: forge test from contracts/ — 41 CleanGate tests (replay across paths, chains and deployments; expiry boundaries; fee-on-transfer and reentrancy rejection; owner guards; a fuzz happy path) and 10 validator tests (including revocation cutting access even with a live attestation).

Every one of those commands, plus forge fmt --check and forge test, runs on push and pull request through .github/workflows/ci.yml, so the byte-compare on the generated registry and the adversarial contract tests are enforced by the repository rather than by local habit.

CLI reference flows

Every flow was first proven as a CLI script, and the web app was ported from those scripts. They move real testnet funds with the operator key, so they stay in the private tree — but their design is the reason the live-run claims above can be trusted, and it is worth stating:

Flow What it does
smoke read-only: RPC, Cleanverse auth, Unlink env, wrapped-native sanity
preflight bytecode + paymaster prefund gate before any real swap
deposit attest → proveCleandepositWithApproval
transfer private Unlink → Unlink transfer
swap two-phase private swap
recover sweep value stranded in an ExecutionAccount
diagnose read-only swap forensics, submits nothing
bridge-arc gated withdraw → CCTP burn → Iris attestation → Arc mint → Arc pool deposit, one crash-safe command

Each takes a --chain flag and defaults to monad-testnet. Every script persists a checkpoint to deployments/mc-checkpoint.<chain>.<flow>.json before submitting and, on rerun, polls the recorded operation id instead of resubmitting. An interrupted EOA broadcast is reconciled without guessing — a known receipt is authoritative, an unchanged pending nonce means "not broadcast", and anything genuinely ambiguous hard-stops rather than double-spending. A spend budget in deployments/mc-budget.<chain>.json caps native and token usage at 50% of the starting balance and is checked before every write.

The JavaScript testnet deployer still passes the two-argument v1 constructor and is stale against CleanGate v2; the Foundry script contracts/script/DeployCleanGate.s.sol — which is in this repository — is what new gate deployments should use.

Evidence

Every live run writes a self-reported JSONL log, produced by the same script that performs the action — an integration-test trail, not an independent audit. Those logs stay in the private tree with the scripts. Two things offset the self-reporting, and both are worth knowing:

  • The scripts assert what they log. Balance deltas are re-read on-chain and a run hard-fails rather than logging a success it cannot prove.
  • The evidence writer enforces a field allowlist and throws on anything outside it, so credentials cannot leak into a log.

One verifier exists specifically to not trust a report: it re-queries the Unlink Engine with the admin key and re-derives the on-chain custody move independently, rather than believing the run log.

Independent of any log, the load-bearing claims are checkable by anyone: Proof of privacy links the deposit, transfer and swap transactions on all three chains so the privacy boundary can be inspected directly, every address and hash in Deployed contracts is on a public explorer, and all three CleanGate deployments are Sourcify-verified.

Where the project cannot prove something, it says so. The Travel Rule button returns an honest report-unavailable when Cleanverse has no report for a hash, and the fallback summary is labelled an attestation evidence summary — never presented as a Cleanverse Travel Rule document.

About

Privacy layer on Monad, BNB, Ethereum, built on the CleanVerse and Unlink.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages