Cleanverse Build: Trusted Assets · Track 1 (RWA) · Monad Live: https://plumb.vercel.app · The Register: https://plumb.vercel.app/register
The problem
Every compliance system on-chain can prove what it permitted. None can prove what it stopped.
A blocked transfer reverts, and a revert discards state. So the refusal — the single event a regulator, an auditor or a counterparty most needs — is the one event that leaves no trace. This is not inferred. Chainlink's own ACE documentation concedes its compliance index covers "only successful transactions."
The consequence is concrete: an issuer cannot show a control ever fired, and an auditor cannot distinguish a gate that works from a gate that was never tested.
The solution
1. Make the refusal survive. Plumb's TransferDesk records the decision and then acts, and it does not revert on refusal — because a reverting refusal erases its own record. The transfer does not happen; the fact that it was refused does, permanently, with the rule named as a typed 4-byte selector rather than a string. Insufficient balance is recorded as mechanical and never inflates a compliance count.
2. Then measure whether anyone else's refusals survive. The Register measures every token Cleanverse lists — read from query_chain_config with our own hackathon credentials, never a list we curated — against one versioned 27-vector ERC-3643 suite, at one block, reproducibly by anyone.
3. And keep the dates. Every measurement here is reproducible, which means today's table is worth nothing a competitor could not rebuild tomorrow. What cannot be rebuilt is yesterday. Each snapshot is stored whole, content-addressed, anchored to the head block of all six chains, in an append-only index.
What it measured
| Tokens measured | 317 across 6 EVM chains |
| Permissioned (graded) | 283 |
| Distinct implementations behind them | 70 |
| Reached Stage 0 | 22 |
| Reached Stage 1 or 2 | 0 |
| Could not be observed | 261 — and the table says which kind |
| Out of scope, listed and ungraded | 34 |
Chains: Monad Testnet (10143) · Base Sepolia (84532) · Ethereum Sepolia (11155111) · Polygon Amoy (80002) · BNB Smart Chain Testnet (97) · HashKey Chain Testnet (133). Solana is listed, counted and excluded with a stated reason — the vector set is EVM calls, so running it there would measure our instrument, not those tokens.
"Could not be observed" is not one thing, and the table no longer pretends it is. Of the 235 without a stage: 159 because the token has no supply, so nothing exists to move; 63 where actors existed and the gate still produced no verdict; and only 13 because no credentialed holder is known to us. Only the middle group is a statement about the token, and it is now the second largest rather than a rounding error hidden inside a vague total.
We shrank the excuse from 161 to 13, honestly. Most tokens were unobservable only because nobody happens to hold them. On a local fork that is fixable: a balance is fabricated for an address that already holds a real A-Pass, so the gate can be driven. The credential is never fabricated.
Finding where each token keeps its balances meant discovering the mapping slot rather than assuming slot 0 — most A-Tokens are ERC-1967 proxies using
erc7201:openzeppelin.storage.ERC20. That resolved 84 of 84 Monad tokens across 4 implementations and 157 of 157 Base tokens across 36. The fork is snapshotted before and reverted after, so fabricated state can never be mistaken for real state by a later run, and all 240 affected rows say so on their face.
What it bought: on Monad, interface verdicts licensed 43 → 84 of 84 and behaviour vectors exercisable 42 → 83; on Base, 157 of 157 and 143 of 157. Unobservable vector results on Monad fell 1,580 → 924. Nothing was assumed to get there, and a reader who distrusts the method can discard exactly the rows that used it.
Four findings that only fell out of measuring everything:
- 283 permissioned tokens run on 70 distinct implementations. One factory carries 81 of them byte-for-byte. 22 passing rows are not 22 findings — they are a few contracts observed repeatedly. Every row names its implementation, and proxies are resolved to the logic they delegate to, so identical shells pointing at different code are never merged.
- We found our own coverage gap this way. We had been measuring 157 tokens on 2 chains from a hand-maintained list. The authenticated API returned 222 on 7 — 65 tokens and four entire chains missing, including every tokenized equity on Monad (hNVDA, hAAPL, hTSLA, hMSFT, hAMZN, hGOOGL, hMETA, hAMD, hNFLX, hCOIN). The universe is now derived from Cleanverse every run. A curated list cannot report what it omits.
- The ecosystem grew 74 tokens in 20 hours — 222 → 330 between our first and latest snapshot, as other teams deployed. The archive caught it because it was already watching.
- A live credential was edited underneath us, and nothing recorded it. The A-Pass at
0x8888…E2bdis the address this project cited as proof that Cleanverse's gate admits a credential with nothing behind it: active, tier 5, no expiry, andcurrentKycHash == 0x0. It now returns0x01followed by 31 zero bytes — not a digest, and not the shape of the hashes on credentials that have real ones. Tier and expiry are unchanged. An A-Pass carries no version and we found no event announcing the edit, so an issuer who read that credential, recorded "no KYC behind it" and acted on it has no way to learn it is no longer true. This is the project's own thesis arriving uninvited, and it is the reason the fixture is now discovered at run time instead of named: the finding survives, and the specific address does not get to be load-bearing. The hole itself is still open on a different live credential (0x7f70…4CF5— active, tier 5, no expiry, zero KYC hash), which is how we know the claim stands rather than assuming it.
CVI · CVA integration points
Track 1 requires both, from the issuance stage. Both are in the core path, on chain.
| Contract | Primitive | What it does |
|---|---|---|
Eligibility.sol | CVI | Reads A-Pass identity directly from the registry (0xbA82D189…58B9), so every refusal names the credential that was missing rather than denying generically. |
CvaDesk.sol | CVA | Routes Cleanverse's own registered A-Tokens through the refusal ledger, so a genuine CVA asset gains durable refusals without the asset changing. Keeps NotRegistered and Unreachable as first-class outcomes — never folded into "refused". |
PolicyGate.sol | CCP | Consults the rules engine and preserves the distinction between a refusal and an unanswerable question. |
TransferDesk.sol | — | Records then acts; does not revert. The mechanism the other three feed. |
RefusalLedger.sol | — | The durable record. Typed selectors across three kinds: compliance, mechanical, registration. |
Deployed and live on Monad Testnet (10143):
RefusalLedger 0x85d4fb97f61756c1934ce7c0d2a26b51fa7813a2 ·
TransferDesk 0x57fcf5ccfbe85846d74dddd7b4d84a07a2f359a7 ·
CompliantAsset 0x88cc48fd13cdd707cbed8c5d77c4ee2ea61340e5
Two on-chain CVA facts, and a conclusion we got wrong. Policy.canTransfer returns TokenNotRegistered() for a third-party token, and a contract holding no A-Pass is refused with NoAPass(address). Both are real and re-runnable. What was wrong is what we inferred from them — that CVA was therefore out of reach. It is not. See the retraction two sections below.
What the API actually gives us — and a finding we had to retract
POST /verify_apass {atoken, chain, address} is Cleanverse's own pre-transaction eligibility check, and it discriminates: code 2 no A-Pass, 3 A-Pass exists but is expired or frozen, 4 valid and allowed. Measured live on aUSDC/Monad it returns 4 for credentialed addresses and 2 for uncredentialed ones, agreeing exactly with the on-chain A-Pass check. Code 3 is the Stage 1 substance case — the difference between holding a credential and holding a usable one — answered by the issuer's own gate.
POST /validator/is_register confirms from Cleanverse's side what we had only inferred from on-chain reverts: Plumb's TransferDesk is not a registered validator pool.
The CVA retraction — the write surface was never closed
We published that the CVA write path was "closed by a role we were not granted". That was false, and the mistake was entirely ours.
21 cooperate endpoints do not accept plain JSON. The v5.6 reference is explicit: they require the plaintext request to be AES-encrypted (AES/CBC/PKCS5Padding, key = the Base64-decoded api-key, fixed 16-zero-byte IV) and sent as
{"data": "<base64 ciphertext>"}. Every A-Token write, every Validator write,
generate_apass, update_status, blacklist/add — and, unusually, one GET — are on that list. We were posting plain JSON to all of them, so the 403s and 500s we collected said nothing whatever about our permissions.
With the documented envelope, GET /atoken/list_my_atokens returns **HTTP 200,
code 0000, and 251 A-Token applications — 178 ISSUED, 60 ISSUE_FAILED, 9 FROZEN, 4 PENDING across Monad (127), Base (116), Ethereum (7) and Arbitrum (1), split 177 LAUNCH, 40 LAUNCH_WRAPPED, 34 REGISTER_ATOKEN**. Other teams were launching minutes before we read it. The "Issue Member" role these endpoints require is one these credentials evidently have.
We got these counts wrong first, twice over. We published "241 applications, 247 ISSUED" — a figure that contradicts itself, since 247 exceeds 241. The list endpoint takes page and page_size as query-string parameters on a GET; we sent
pageSize in the encrypted body, so paging was silently ignored and a single page of 20 was counted repeatedly. That is the third time one of our malformed requests became a published number, and the first two were the same mistake in a different place.
A third trap sat behind the second: a request with no User-Agent is rejected by the gateway with HTTP 403 and a bare error code: 1010, which is indistinguishable from a permission failure until you send one. Our own client always did; the probe written to test this did not, and cost an afternoon.
This is the second time a wrong request shape became a confident claim about someone else's system. The first was a wrong base path, retracted earlier on this page. The lesson we actually take from it: an absence produced by our own malformed request is never a finding about the thing we were probing, and "we could not reach it" must be held to the same standard of proof as "it does not exist".
We retracted a finding to get here. An earlier version of our research stated that only 2 of ~40 endpoints answered to hackathon credentials. That was false: we had guessed endpoint names and used the wrong base path. Against the real
/api/cooperate/ surface with the documented api-id header, 43 of 43 endpoints answer — 9 with data immediately, 16 with a validation error naming the missing field, and 18 with 403 because they are writes needing an Issue Member role. The correction is in the repo next to the original claim rather than quietly replacing it, because an absence you produced by looking in the wrong place is not a finding about the thing you were looking for.
CVA, done rather than described — we issued a real A-Token
Track 1 asks for CVA in the core flow from issuance. We issued one.
| A-Token | 0xf885f753175D1f9C6e7A7e1C39336A80D4dbE29B (Base Sepolia) |
| Name · symbol · decimals | Plumb Verified Receivable · PVRWB · 6 |
| Issuance tx | 0xc2b45fa947122af11c2626979a061f2e511cdaf6ecda2218dc3781eac5cc21bd |
| Request | IA20260808223456233580 · applyStatus: ISSUED |
| Compliance rule | min_tier: 10, read back from Cleanverse's own /atoken/rules |
| Admin | 0x0FBDC3209F52bcc999039d67F03F5c888400cFb2, holds DEFAULT_ADMIN_ROLE on chain |
MINTER_ROLE granted | 0x0098b1cb01bab2a2a601d942248f1a8d84b0e2126310863abd13fb71140cb9d8 |
| Minted | 0x78254411879cc09ba2682f28269f9cec98d231e8e66a2eefccb5e956f93d5138 — 1.000000 PVRWB to the tier-20 holder |
| Supply now | totalSupply 1000000, all of it held by a credentialed address |
Every step is verified rather than assumed: the contract answers name, symbol and
decimals on Base Sepolia; the admin wallet's hasRole(DEFAULT_ADMIN_ROLE) returns true; the rule we submitted reads back from Cleanverse's side as min_tier: 10; and their own CCP engine rules on it — verify_apass returns 4 (allowed) for a credentialed address and 2 (no A-Pass) for an uncredentialed one.
The min_tier: 10 floor is the point. It is the substance requirement Cleanverse's deployed gate does not itself make, and the one the Register records as unmeasured across all 283 permissioned tokens. Our asset enforces it.
The asset is not an empty shell: MINTER_ROLE was granted and 1.000000 PVRWB minted to the credentialed holder, so the token has real supply held by a real A-Pass. That also made its gate driveable — the conformance sweep now finds a genuine holder for it with no fabricated balance, and its conformance rose from 4 passed to 6.
It still reads "No stage" in our own Register, and we left it that way. The gate prober's holder discovery did not find the mint, so no gate verdict exists for it, and "we could not look" is never Stage 0 — including for us. Editing our own row would cost the table the only thing that makes it worth reading.
Running that prober beyond Monad was itself a finding, twice over. It supports six chains and had only ever been run on one, so 201 A-Tokens on Base, Ethereum, BSC, HashKey and Polygon were reported as gates we could not observe when the truth was that we had never pointed the instrument at them. All six chains are now probed.
Adding that evidence made Stage 0 fall, from 25 to 20 — which is impossible if the data is being joined correctly, and that impossibility was the bug. scan_index keyed the prober's verdicts by token address alone, and the same A-Token address is deployed on five chains: aUSDC lives at 0xaC0893…f20D on Monad, Base, Ethereum, Polygon and HashKey. Whichever scan file loaded last decided the verdict for all five. It stayed invisible for as long as only one chain had ever been scanned.
Keyed by (chain, address) the same token now carries five separate verdicts — ENFORCING on Monad and Base, unobservable on the other three — and the honest count is 22 at Stage 0, not the 24 or 25 we published while the collision was silently merging chains. Five regression tests pin it, including the invariant it broke: adding a chain must never change another chain's verdict.
The admin wallet was generated fresh for this. The deployer key used elsewhere in this repo was exposed in a working transcript, and an exposed key must never hold a permanent admin role.
And a live defect we found in Cleanverse's Monad issuer, with on-chain proof
We tried Monad first, three times, and every attempt failed with ISSUE_FAILED. The cause is not our payload.
query_apply_statusgives the reason and a transaction hash:launchAToken tx failed, tx_hash=0x0f1783995e130c83a794cf2c9bd0faef3482fe9cf9d947964b786821e0ee5d63- That transaction was sent with a gas limit of exactly 300,000 and reverted.
eth_estimateGason the identical calldata returns 506,422, and replaying it at 600,000 gas succeeds. The payload is valid; the gas limit was ~40% short.- Every successful Monad launch we sampled was sent with a real estimate — 486,182 / 506,300 / 506,361 / 506,374. Every failed one was sent with exactly 300,000.
- It is not specific to us:
BHC26AandaJPY, from other teams, failed the same way at the same 300,000. - The controlled comparison closes it: the same payload, minutes later, on Base, issued in seven seconds.
So Cleanverse's Monad issuer falls back to a hardcoded 300,000 gas limit when its estimate is unavailable, and every application that hits the fallback dies on chain. We have reported the addresses, hashes and reproduction above.
How widely it bites, from the full set of 251 applications. Once paging worked, the scale of it is visible:
| applications | ISSUED | ISSUE_FAILED | failure rate | |
|---|---|---|---|---|
| Monad, all time | 127 | 87 | 33 | 26% |
| Base, all time | 116 | 84 | 26 | 22% |
| Monad, today | 40 | 28 | 9 | 22% |
| Base, today | 50 | 46 | 1 | 2% |
Today, a launch on Monad is eleven times more likely to fail than the same launch on Base. SCPN, another team's token, failed on Monad nine minutes after our last attempt — this is live and still costing teams their assets.
We also found three further API defects worth naming: a failed launch permanently burns its token symbol (resubmitting returns code 12002), /atoken/rules answers HTTP 500 rather than a validation error when given contract_address instead of
atoken_address, and page/page_size are query-string parameters whose body equivalents are silently ignored rather than rejected — which is what produced our own bad count above.
Why this is trustworthy
Every rule that could flatter us is a failing test.
- The instrument is graded before anything else. Each of the 27 vectors is scored on whether it has ever been observed producing both a pass and a fail. On live data most cannot be, so a fixture is deployed and deliberately broken: 22 of 27 proven, 5 not inducible — labelled on the page, never hidden.
- A positive control gates every negative claim. Nothing is called decorative until a transfer the same contract does allow has passed first.
- Unknown blocks a stage exactly as a failure does, and is reported separately. A token that could not be observed gets No stage — never Stage 0, because "we could not look" is not a grade.
- Networks are named from their chain id. These are testnets and the table says so.
- Our own token is in the table, under the same rules, graded by the same vectors.
- Where we fall short, the page says so. No token reaches Stage 1. No refusal ledger is wired in the wild. Both are printed on the index.
- We corrected our own argument. An earlier version claimed a ladder is better because "a score is opaque". S&P Global publishes an ordinal 1–5 stablecoin assessment that moves markets, so that was wrong. The honest reason for a ladder is that S&P's number is credible because it is S&P's; we have no such standing, so every requirement here is individually re-runnable instead. Verifiability replaces brand.
Enforced by 329 Python unit tests across 12 suites, 143 user-acceptance checks across 5 suites, 102 Foundry tests — 77 against mocks and 25 forked against live Cleanverse — and an automated visual QA pass over every page in two themes and two widths, 0 defects. Each honesty rule was deliberately broken to confirm its test fails: 28 mutations, 28 caught. A test that cannot fail protects nothing.
And a 48-check judge walkthrough, because rendering is not working. Visual QA passed every page while the flagship feature was broken for everyone but us: the index shipped an RPC default of 127.0.0.1:8546 and a ledger address that was never deployed, so a visitor's first click reached their own localhost and the table read "No decisions recorded" — an unanswered question dressed as an answer, on the project that exists to say those differ. qa/judge.py clicks what a judge clicks and asserts what came back: live chain reads, filters that narrow (317 → 283 → 34), search that narrows (317 → 6), headers that reorder, a tooltip that opens, a theme that repaints, and a wrong address that reports a failure instead of an empty ledger. It counts neither HTTP 200 nor an unchanged result as a pass. Every step is screenshotted.
Then we used it with real money. Five transactions from three wallets on Monad testnet, each predicted with the view function before being sent: an issuance to a live tier-20 credential, an attested permit between two real holders, an attested refusal to an uncredentialed recipient, and two send() calls from throwaway wallets. All five succeeded — including every refusal, which is the thesis in one column. The ledger went 4 → 8 decisions and the site reads them back translated into English. Full receipts in qa/end-to-end-transactions.md.
That run also corrected us. Two refusals we predicted as compliance failures were recorded as mechanical, because those wallets held no tokens and would have failed whatever their credentials said. TransferDesk._classify does that on purpose, and the invariant the README states — insufficient balance never inflates a refusal count — caught our own prediction.
And we found 11 of ours that could not fail. The Foundry count was 88 until the fork suites were actually run. One used modifier onlyFork { if (block.chainid != 10143) return; _; }, so off-fork every test in it returned immediately and forge recorded PASS — 11 green tests executing nothing, which is worse than a skip because it reads as cover. The other suite skipped in setUp, so an entire file of live-state assertions never ran at all. Both now skip visibly, both run forked in
./run.sh test, and a skip there fails the gate. The honest count is 102 passing, not 88.
The artifact an issuer can hand upward
The table answers a reader's question. A 292-record audit set answers a supervisor's: a dated, content-addressed record per token stating what was checked, what the answer was, and what could not be checked and why — in the same voice as the findings, because a gap an auditor has to discover is a gap that was hidden. Each record carries the sha256 of its own findings, names the block and RPC so it can be re-run rather than believed, and carries the grade of every check that produced it.
Example: https://plumb.vercel.app/register/audit/monad-0x8b446f35763a56ca84168e31603cb46f08540821.json
Scalability
The tooling is chain-agnostic: every sweep records the RPC and chain id it read from, so adding a network is configuration, not code — which is how six chains happened in one afternoon. The universe refreshes from Cleanverse's own API each run. Daily capture runs unattended and the history compounds permanently from the first day.
With production CVI, Stage 1 becomes measurable: credential substance — KYC hash, expiry, jurisdiction — needs the identity registry, and today no deployed token exposes one, which is why that rung is honestly empty rather than quietly skipped.
Reproduce any of it
python cleanverse/universe.py # derive the universe from the API python conformance/sweep.py --chain monad # measure every token on a chain python register/snapshots.py verify # re-hash every archived snapshot ./run.sh test # the whole gate
Every row on the site prints the exact command that reproduces it, with the real RPC.
— Prateek, author and developer