One-page summary

The document the judging panel scores. Every figure here is checked against register.json before publishing, so a stale number cannot survive a rebuild.

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 measured317 across 6 EVM chains
Permissioned (graded)283
Distinct implementations behind them70
Reached Stage 022
Reached Stage 1 or 20
Could not be observed261 — and the table says which kind
Out of scope, listed and ungraded34

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:

CVI · CVA integration points

Track 1 requires both, from the issuance stage. Both are in the core path, on chain.

ContractPrimitiveWhat it does
Eligibility.solCVIReads A-Pass identity directly from the registry (0xbA82D189…58B9), so every refusal names the credential that was missing rather than denying generically.
CvaDesk.solCVARoutes 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.solCCPConsults the rules engine and preserves the distinction between a refusal and an unanswerable question.
TransferDesk.solRecords then acts; does not revert. The mechanism the other three feed.
RefusalLedger.solThe 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-Token0xf885f753175D1f9C6e7A7e1C39336A80D4dbE29B (Base Sepolia)
Name · symbol · decimalsPlumb Verified Receivable · PVRWB · 6
Issuance tx0xc2b45fa947122af11c2626979a061f2e511cdaf6ecda2218dc3781eac5cc21bd
RequestIA20260808223456233580 · applyStatus: ISSUED
Compliance rulemin_tier: 10, read back from Cleanverse's own /atoken/rules
Admin0x0FBDC3209F52bcc999039d67F03F5c888400cFb2, holds DEFAULT_ADMIN_ROLE on chain
MINTER_ROLE granted0x0098b1cb01bab2a2a601d942248f1a8d84b0e2126310863abd13fb71140cb9d8
Minted0x78254411879cc09ba2682f28269f9cec98d231e8e66a2eefccb5e956f93d5138 — 1.000000 PVRWB to the tier-20 holder
Supply nowtotalSupply 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.

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:

applicationsISSUEDISSUE_FAILEDfailure rate
Monad, all time127873326%
Base, all time116842622%
Monad, today4028922%
Base, today504612%

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.

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