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.
| 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.
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.
/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.
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.
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.
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
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.
| 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.mjs → web/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.
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.
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, attester0xa69A7e61…2464, validator0xaC7e5179C2C7f03f209136886c172eb34F161792 setTokenAllowed(USDC, true)—0xe88447a7180a8fbdc84c567b5cc01c9d43b048514c44be75820f06c566dd9927- Validator registration
0xa88afc038538be93c00295ab69b7effa08b4111f3d83628cc7ca4240cab4295e, rulemin_tier: 30set by0xf66b2fb6a7e359cd576768639f7911b57f00b2751e13599d60c52de1f5b41db7 - 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(tx0xc6fc9b27135ffd15a72270f5bae48723d02e93703ce2b9b0761f9df07e99eae8) and Unlink USDCm0x990E77bdff7A062a27f8666C0b3e6f5DdF0C24Fc(tx0xc442bcd0c2478deb447ccc60eca33eb076255a49dbec769f46bb99d3a4e55871) - 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…9821is the gate on both BSC and Sepolia;0x7b553a39…F0a6is 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.
| 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| 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.
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 # :5173Repeat 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/.env — tsx --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 currentContract 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.
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 → proveClean → depositWithApproval |
| 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.
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.