Sweep bonded completeness claims on Creditcoin against Ethereum, and refute one that omits an event.
Runs on your machine from its package; the steps are below.

At most: moves money, runs code or commands, deletes data, or sends messages for you.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
npx -y utuh-mcp
MCP client config
{
"mcpServers": {
"utuh-watcher": {
"command": "npx",
"args": [
"-y",
"utuh-mcp"
]
}
}
}Utuh bonds the claim that a set of events is complete, pays half the bond to anyone who proves one event was left out, and lends CTC undercollateralized against the claims that survive.
utuh — Indonesian: whole, intact, with nothing missing. A completeness layer for the Attestcoin Protocol, built for BUIDL CTC 2026 Fall and deployed on Creditcoin CC3 Testnet.
![]() | ![]() |
| The landing page, claim 5 as a working paper | The console, claim 20 opened verdict first |
![]() | ![]() |
| Dark scheme | A sweep of claim 5 from the browser, with its provenance |
npm run judge — no key. It re-measures every number this
repository and the submission quote against the live chain and exits non-zero on any that no
longer holds. On 2026-09-14, after the production deploy, it printed 25 of 25, two of them checks
that read the example gate below.npx -y utuh-mcp — 0.4.0 on npm and in the official
MCP Registry. For clients that
cannot run a command, the same server answers at https://utuh.vercel.app/api/mcp (below).TransactionVerified events on CC3's own indexer.web/whitepaper.html
· Deck: web/deck.pdfWhat is on-chain, all verified on Blockscout and Sourcify (every address and read):
| On CC3 Testnet (chain 102031) | Address | What it holds |
|---|---|---|
UtuhRegistry, Ethereum-mainnet-sourced | 0x8FA0…5Fac | 72 claims over real Ethereum mainnet history; 32 refuted, claim 20 among them |
UtuhRegistry, Sepolia-sourced | 0x2688…347b | 13 claims, the completed borrow-and-repay loop; claims 3 and 5 refuted |
UtuhCredit, Sepolia-sourced | 0x0177…24b6 | lines 1–3 opened, drawn, repaid on Sepolia and settled; lines 2 and 3 entirely from the console |
SettlementLedger, on Sepolia | 0xC8C9…575B | the source-chain repayments those lines settled on |
| Creditcoin's own oracle record | transaction-verifications | 248 TransactionVerified rows are Utuh's: 212 claim members, 34 refutations, the rest control bindings |
A contract that is not Utuh reads it: NeverLiquidatedGate
at 0xcA62…5D09
granted an allowance on claim 72
and refused refuted claim 20.
This project wrote and deployed it as the worked example, so it is not an outside user.
The short version · The problem · What this uses of Creditcoin's · Next to Creditcoin's own example · What Utuh does · Built on Ethereum mainnet · UtuhCredit · Deployed on CC3 Testnet · Two demonstrations · The watcher as an MCP server · The console · Layout · Running it · On testing · What the tools say · Before you build on this · Attestcoin surface used · Known limits · License
Elsewhere: docs/INTEGRATING.md (use the registry from your own contract) · docs/COMPLETENESS.md (does what you built have this gap?) · docs/ROADMAP.md (what is gated on whom) · docs/AUDIT.md · docs/MAINNET.md · SECURITY.md · 한국어 요약: docs/README.ko.md
Any registry on this protocol can tell you what it holds. Ask one whether a borrower has ever been liquidated and it can only answer with what someone chose to submit — and the borrower will not be submitting that. Utuh bonds the claim that a set is all of them, and pays half the bond to anyone who proves one event was left out.
The landing page reads both registries from Creditcoin as it draws — the two refuted claims with
their block-range strips, the tally (proven, sealed, broken, burned), the deployed addresses — and
each deep link above opens the console on the claim with its verdict first. The console reads the
chain from your own browser, lets anyone sweep Ethereum and break an incomplete claim, and lets a
borrower be underwritten end to end without cloning anything. npm run web serves both locally.
Every verification behind the two refuted claims is on Creditcoin's own oracle dashboard, which
nobody here can write to.
Two things a reader with a clone and no key can check first. npm run judge re-measures every
number this repository and the submission quote — the contracts and their verification, the tally,
both linked claims, the explorer counters, npm, the MCP Registry, the published build, the sixteen
protocol entry points, the test and commit counts — and exits non-zero on any that no longer
holds. And a bond here stands behind a specific line, not behind nothing: UtuhCredit.openLine
reaches a claim only through UtuhRegistry.isUsable(claimId, exposure), the finalized claim is
spent by the line it opens, underwrittenThrough consumes the history range, and the limit is
capped at ten times the enforceable loss. Exposure is gated by the bond and consumed by the draw.
The Attestcoin Protocol proves that a source-chain transaction happened. A Merkle proof places
the transaction in a block; a continuity proof anchors that block to an attestation stored on
Creditcoin. The Block Prover precompile at 0x0FD2 checks both natively, synchronously, inside a
single Creditcoin block.
What it cannot prove is that a set of events is complete.
Whoever submits proofs chooses which proofs to submit. Every one of them verifies. Nothing in the protocol notices the ones that were left out.
For credit, that gap is fatal. The sentence every on-chain credit system needs is:
This borrower has never been liquidated.
That is a statement about events which do not exist, and an inclusion proof can only ever speak about events which do. A borrower assembles their own history, submits the flattering half, and each proof checks out.
This is not hypothetical. Season 1 of this hackathon drew 76 submissions, and more than twenty of them were some form of on-chain credit score or reputation-based lending; one of those, CreditX, took a prize. Every one of them inherits this hole, the winner included — placing well is not the same as closing it, and an inclusion proof gives none of them a way to.
npm run doctor (no key needed; given one it also reports the balance) ends by making a live
request to every Creditcoin-owned surface this depends on, so the list below is
checkable rather than asserted. A run on 2026-09-10:
Creditcoin ecosystem surfaces
ok USC Oracle dashboard 200 — every verifyAndEmit here is listed by source height
ok testnet indexer 68,160 attestations of Ethereum, chain key 3
ok mainnet indexer 52,463 attestations of Ethereum, chain key 1
ok Creditcoin Mainnet RPC chain id 102030
ok Blockscout · registry UtuhRegistry verified
ok Sourcify · registry chain 102031: match match, creation match
ok Blockscout · credit UtuhCredit verified
ok Sourcify · credit chain 102031: match match, creation match
On top of the protocol itself — both precompiles, both verifyAndEmit overloads, EvmV1Decoder,
@gluwa/usc-contracts and @gluwa/usc-sdk, the hosted Proof Builder under both of its hostnames,
and RawProofBuilder as the path that needs no hosted service at all.
The pins are deliberate. Gluwa published @gluwa/asc-contracts 0.2.1 on 2026-08-31 under the new
ASC name, and the reference examples import from it; this tree stays on @gluwa/usc-contracts
0.1.2 and @gluwa/usc-sdk 0.18.0 (there is no newer SDK and no asc-sdk). 0.1.2's
EvmV1Decoder functions are public, which is why the decoder links as a deployed library at
0x5cab…df3F; 0.2.1 made them internal and dropped the type-specific decoders Utuh never calls.
The four it does call keep identical signatures in 0.2.1, and changing the pin changes bytecode
and breaks the Blockscout and Sourcify match of the live deployment, so the rename lands on the
merkle-claims branch, which redeploys anyway.
Two of those lines are worth reading twice. Every append and every refutation goes through
verifyAndEmit rather than its view twin, so Creditcoin's own oracle dashboard is the record
— the network logged this project's work, not the other way round. And the Creditcoin Mainnet
indexer is read even though nothing here is deployed there: the console audits mainnet's attestors
against Ethereum itself, because auditing only the network you deployed to leaves the production
oracle unchecked by the one page that can check it.
PenguinBridge, PenguinSwap and PenguinBase are Creditcoin's own consumer applications and none of them appears here. That is a design decision, not an oversight:
An integration that weakens the thesis is not depth. Naming the two we refused is more honest than listing eleven we bolted on.
Creditcoin ships a loan-flow tutorial
— ASCLoanManager on Creditcoin, an auxiliary contract on Sepolia, an offchain worker between
them. (Mid-season, gluwa reorganised that repo and began renaming USC to ASC — Attestcoin Smart
Contracts; the old USCLoanManager link now redirects to a 404, and everything below was re-read
against the renamed sources on 2026-09-04. The mechanics are unchanged.) It is a good tutorial and
it is the right shape for what it teaches. It is also a precise illustration of the gap, because it
is the reference every builder will start from.
Each event proves itself as it happens. _markLoanAsFunded and _noteLoanRepayment take one
proven transaction each; ASCBase.execute verifies it through 0x0FD2 and records the query so
it cannot be replayed. Every present fact is cryptographic, and that part is sound.
Two things follow from proving one event at a time, and neither is a defect in the tutorial:
markLoanAsExpired is onlyOwner. Somebody trusted says the
loan went bad. For a tutorial that is the honest simplification; for underwriting a stranger it
is the whole problem moved one layer up.Utuh is the layer that would sit under such a contract: a bonded claim that a set of events is all of them, refutable by anyone with one proof of one omitted event. Presence stays cryptographic exactly as above. Absence becomes economic, which is the most that can be had.
Other bonded-claim designs have appeared on the same board. One bonds a predicate over a block
window and gives the bond to whoever proves one transaction that breaks it, which is the same shape
as a refutation here and covers the empty case: nothing bad happened. Its predicate sees one
proven transaction at a time and, in its own documentation, cannot sum history or compare two
transactions. So it can bond that a set is empty but not that a non-empty set is all of them. That
second sentence is the one a volume history or a repayment record needs, and it is what
UtuhCredit lends against. Absence here is the empty-set case of completeness, not the other way
round. The other difference is who looks: a bond deters only if someone searches, and Utuh ships
the search, as the browser sweep and npx -y utuh-mcp.
Two halves, each sound on its own.
Nothing invented. Every event enters a claim through appendBatch, which runs the Attestcoin
Block Prover on it before it is recorded. Members must arrive in strictly ascending
(blockHeight, txIndex, logIndex) order, which the contract enforces rather than trusts. A claim
can only ever contain events that provably happened, so its aggregate cannot be inflated.
Nothing omitted. The claimant bonds the assertion that the set is complete. Anyone may break the claim by proving a single in-scope event the set does not contain. Absence is never proven — a claim of absence is refuted by presence, which Attestcoin does prove.
presence → cryptographic (Merkle + continuity, verified by 0x0FD2)
absence → economic (bonded assertion, refutable by one proof)
Settling a claim is O(1): the registry never verifies a whole set, so a claim spanning ten thousand events is broken by a single proof or by none at all.
Building one is not. npm run gas measures it rather than reasoning about it — it finds every
transaction a registry has ever seen from the registry's own logs, reads the receipts, and fits a
cost model. No explorer involved. Across the four registries deployed by 2026-08-28, when the fit
below was taken, 139 transactions:
| Call | Gas (mean) | % of a 75M block |
|---|---|---|
open | 252,750 | 0.33% |
seal | 206,010 | 0.27% |
appendBatch (1 event) | 552,956 | 0.73% |
appendBatch (2 events) | 893,903 | 1.19% |
appendBatch (10 events) | 2,662,045 | 3.54% |
refute | 611,556 | 0.81% |
finalize | 209,258 | 0.27% |
withdraw | 205,870 | 0.27% |
Member count alone does not explain those. One append of three events cost 541,464 gas while an append of two cost 878,903, because the cost follows the size of the transactions being proven, not how many events sit inside them. A least-squares fit over the 56 appends the published registries had seen by 2026-08-28, against the call's own calldata gas and its member count:
290,899 gas fixed
1.51 x the call's own calldata gas (1.00 would be exact)
81,427 gas per member on top of its bytes
worst residual 294,878 gas, 32% of the mean append
each append carried median 94, 2..394 continuity hashes and median 16, 8..78 Merkle siblings
charging the bytes at their own EVM price, 62 gas per continuity hash:
5,866 gas at the median, 0.6% of the mean append
The calldata term is the solid one, and it is the interesting one: a proven transaction costs
about twice its own calldata gas, because those bytes are not merely paid for at the door — they
are copied, RLP-decoded by EvmV1Decoder, and hashed by the Block Prover. Proving one in-scope
log inside a fat mainnet transaction means carrying all thirty kilobytes of it, and that is not a
choice the claimant has.
The per-member term is not well determined, and it is worth saying so rather than quoting it.
An earlier fit over 25 appends put it at 20,526 gas — almost exactly a cold SSTORE, which was a
satisfying number and the reason to distrust it. Eleven more appends moved it to 61,265, and twenty
more to 81,427 while the calldata multiplier fell from 2.3× to 1.5× — the two terms trading against
each other, which is what collinear regressors do. Members
and bytes are correlated in this data (more members generally means more bytes), so separating the
two needs appends this repo has not made: many members with small transactions, and few with large
ones. What the data does support is the shape — fixed cost, a dominant per-byte cost, and some
per-member cost on top — not a precise value for the last of those.
The obvious missing term is the continuity proof, and it is instructive that it stays out. The
protocol prices verification almost entirely by it: the published figure is 2.3e-5 + 2.9e-7 × hashes CTC, so at the median proof here the hashes are more than half of what the precompile
charges. A fourth regressor for them fits better — worst residual 32% of a mean append down to
16% — and reports minus 860 gas a hash. A hashing operation cannot pay you. Every hash is 32
bytes of the same calldata the second term already charges for, so given nothing of its own to
explain it trades against that term instead, dragging it to 2.21×; reparameterised until both
coefficients are positive it prices a root byte at 0.53×, under the floor a byte can cost. So the
hashes are measured rather than fitted: hold every byte at its own EVM price, fit only what is
left, and the hashing comes out at 62 gas each — three specifications agree to within a gas,
and a keccak of two words is 42, so the number is the operation rather than an artifact. The
median append carries 94 of them and spends 5,866 gas on them, 0.6% of itself. The term that
dominates what the precompile charges is not the term that prices the claim.
The practical ceiling is therefore set by bytes:
a 100-event claim: 10 batches, ~24.5M gas, 0.3 full blocks of it
a 1,000-event claim: 100 batches, ~245.2M gas, 3.3 full blocks
a 10,000-event claim: 1000 batches, ~2451.6M gas, 32.7 full blocks
The asymmetry is still the point — challenging is one proof and a binary search, whatever the claim holds — but a claim of ten thousand events is about thirty-three full blocks of gas, and that is the number that caps this rather than any argument about storage.
Re-run on 2026-09-13 with the default GAS_LOOKBACK of 100,000 CC3 blocks — the mainnet
registry's last 85 transactions, 23 of them appends — the fit reads 317,593 fixed, 1.40× the
call's own calldata gas, 88,066 per member, worst residual 14% of the mean append, and 185 gas
per continuity hash with the bytes held at their own price; the ceiling comes out at 24.9M,
248.6M and 2,485.5M gas for 100, 1,000 and 10,000 events. The shape and the calldata term hold.
The per-member and per-hash terms move with the sample, which is what the paragraph above says
they do, and npm run gas prints whichever sample it was given rather than this one.
A challenge window is only meaningful if a watcher could actually have acted inside it. So a claim
may not open until its entire block range is already attested on Creditcoin — checked against the
ChainInfo precompile at 0x0FD3:
if (!CHAIN_INFO.is_height_attested(scope.chainKey, toBlock)) revert RangeNotAttested(...);
Without that gate, a claimant could cover a range whose tail is not yet attested, and the window
would expire on a claim nobody was able to refute. Attestation heights only advance, so once
toBlock is attested the whole range stays provable for the life of the claim.
A second detail: a refuter receives half the slashed bond, not all of it. If they took the whole bond, a claimant caught lying could refute their own claim and walk away whole, which would make a false claim free to attempt. The burned remainder is what puts a price on being wrong.
That price is smaller than the bond, and the difference matters. A claimant knows which event they omitted from the moment they seal, so they can watch for an incoming refutation and send their own from a second address, taking the refuter's share back. No ordering scheme closes this — an earlier draft of these notes claimed commit-reveal would, which was wrong: the claimant holds the private knowledge, so they simply commit first. What survives is the burn, which nobody can recover.
So the registry reports enforceableLoss rather than the bond, and isUsable measures exposure
against that. Sizing a line against the whole bond, as UtuhCredit did at first, carried twice
the exposure the deterrent actually covered.
CC3 Testnet attests Ethereum mainnet (chainKey 3) alongside Sepolia (chainKey 1), from
genesis height 0. Verified live:
$ npm run balance
attested sepolia chainKey 1 height 11530210
attested mainnet chainKey 3 height 25797540
The mainnet frontier tracks a few dozen blocks behind the real chain head — 34 measured on 2026-09-13, about 70 and about 100 on earlier days. So contracts on a free testnet can be underwritten on real Aave positions, real USDC flows, and real borrowers, with no capital at risk and nothing simulated.
Budget for the attestation lag when running anything live: a freshly mined block takes on the order of ten minutes to become provable on either chain. Historical blocks are immediate, and a proof for one 210,000 blocks back still resolves in about seven seconds — it is only the tip that you wait on.
Every number in the demos comes from Ethereum mainnet.
An undercollateralized credit line on Creditcoin, underwritten on Ethereum. Nothing bridges: the history stays on Ethereum, the credit is issued in CTC on Creditcoin, repayment happens back on Ethereum. The only thing that crosses is proof.

Underwriting rests on two claims that are adversarial in opposite directions, which is what makes the pair sound:
| Claim | Assertion | Who benefits from a lie | Defence |
|---|---|---|---|
| Volume | proven Aave USDC repayments | inflating it | every member verified by 0x0FD2 on append |
| Clean | complete set of liquidations, normally empty | omitting one | bond, refutable by one liquidation proof |
limit = min( 20% of proven volume × the lender's rate , 10 × the bond behind the clean claim )
The second term is the consumer half of the mechanism. The registry cannot size a bond, because it does not know what the claim will be used for — only the party about to lend knows its own exposure. A line never risks more than a liar stood to lose.
Aave's Repay carries amount in the reserve asset's own decimals. Scoping a volume claim to the
event alone would sum WETH's 18 decimals into USDC's 6 and call the total a credit history. So the
volume scope pins the reserve — Repay puts it in topic 1 — and a claim is denominated in
exactly one asset.
That leaves a second gap: the claim aggregates USDC at 1e6, a line is CTC at 1e18, and crossing
between them is a price. This contract has no oracle and does not pretend to: the lender fixes
VOLUME_UNIT_IN_CTC at deployment, in the open, where anyone can judge it. A lender wanting a live
price puts a feed in front of this contract rather than having the protocol invent one.
Underwriting reads a public chain. Nothing about reading it proves the reader holds the key that wrote it, so before a line opens the borrower must bind their Ethereum address to their Creditcoin account:
calldata = bytes12("utuh:control") || <creditcoin account>
One ordinary transaction from the subject address carrying exactly that. proveControl verifies
it through the Block Prover and reads the sender out of the decoded transaction — no signature
scheme of our own, no trusted relayer. The tag stops the commitment colliding with real calldata,
and naming the account inside it stops anyone binding a stranger's address to their own account.
Each commitment may be applied once, and that is not bookkeeping. A subject can move their
binding by sending a second commitment naming a different account — which is how anyone rotates
away from a Creditcoin account they no longer control. The proof of the first commitment stays
valid forever, and anyone may submit it. Without a used-marker the binding is therefore whichever
proof was replayed most recently, not whichever the subject meant: an attacker holding the
rotated-away account puts it back at will, including in front of the subject's own openLine.
Creditcoin's own USCBase records processed queries for exactly this reason, and this did not
until it was found. controlIdOf keys on the chain and the encoded transaction — which carries
its own signature — and deliberately not on the block height, so a reorg that moved the same
transaction cannot make the same commitment usable twice. npm run control and npm run full
both replay the commitment they just used and require ControlProofAlreadyUsed.
Any supported source chain will do: an EOA address derives from its public key and is identical on all of them, so Sepolia gas proves exactly as much as mainnet gas.
The terms of a draw are the lender's, never the borrower's. draw takes an amount and nothing
else; what must come back and by when are computed from policy, converting CTC back through the
same rate that produced the limit and rounding up so no draw is small enough to owe nothing.
Two things about the money are worth stating because both were wrong once. Every rounding in the
contract lands against the party carrying the risk: _repaymentFor rounds up so no draw is small
enough to owe nothing, and backingFor rounds up so no limit is backed by less than a
BOND_MULTIPLEth of itself. That second one is asked at both ends of a line — when it opens and
when it settles — and it was a bare division at both until it was not.
And a lender can name where its own capital goes. LENDER is msg.sender at construction and
immutable, so a lender that is a contract without a payable fallback could fund this and never
get the money back out: withdraw would revert with TransferFailed forever. withdrawTo is the
way out. The authority check is unchanged — only the lender may call it.
Three smaller rules close the same class of hole. A finalized claim is spent when it opens a
line, so one underwriting funds one line and the cap bounds aggregate exposure rather than each
line separately. A line's deadline is fixed by its first draw and never moves — otherwise a
borrower who owes money could buy an unlimited extension by drawing one more wei. And each
settlement consumes the source-chain range it rests on, tracked per subject in settledThrough,
because marking a claim spent does not stop a payment being spent twice: two lines, two claims
over overlapping ranges, one transfer inside both.
A finalized claim can only open one line: openLine marks it spent. That is not enough on its own,
and for a while nothing else was.
The registry will hold any number of claims over the same range with the same scope, and finalizing
one gives the bond back. So a borrower could build a second claim over the same three repayments,
finalize it, open a second line, and draw the limit again — every guard in openLine passing each
time, because each of them looks at one line in isolation. The bond cap bounded each line and
nothing bounded the total.
underwrittenThrough[subject] is the fix, and it is the same shape as the settledThrough
watermark that already stopped one payment discharging two debts. Opening a line consumes the range
it rests on. Borrowing again means new history: a range starting after the last one, still
MIN_HISTORY_BLOCKS long, still inside MAX_STALENESS_BLOCKS of the frontier. A credit line that
renews on performance, rather than a number that can be spent twice.
markDefault is permissionless and nobody is paid to call it. That was fine while it only wrote a
status, and stopped being fine the moment a standing default started blocking new lines: a borrower
whose deadline passed could wait, accumulate a fresh month of history, satisfy
underwrittenThrough, and open the next line with the first one still sitting there overdue and
unmarked. The guard was resting on a transaction nobody was obliged to send.
activeLineOf[subject] removes the dependency. A subject has one line at a time; an overdue line is
still Active, so it blocks by itself, and markDefault goes back to being bookkeeping. Each guard
then has exactly one job — the slot says you have a line open, the count says you failed one.
One consequence of bookkeeping is worth knowing: draw checks the limit and the slot, not the
deadline, so an overdue line nobody has marked can still be drawn up to its limit. Exposure stays
bounded by the limit, which is what the cap is for.
The rule needs an exit, or it is a trap. An undrawn line cannot be settled (nothing was borrowed)
and cannot be defaulted (markDefault refuses a drawn of zero, correctly — no money went out, so
nothing was missed), so closeLine gives the slot back. It does not give the history back:
underwrittenThrough has already moved, and it should have.
defaultsOf belongs to one deployment. A borrower who walks away from a line here opens one at the
lender next door with nothing in the way, and that is the gap a credit bureau fills.
The tempting shape is a shared contract everyone reports to. It did not survive being designed. Reports have to be trusted, and a registry anyone may write to is a blacklist with extra steps — deploy a contract, report a rival's borrower as a defaulter, done. Every fix for that is a permission, and a permissioned bureau is the centralised thing this whole repository exists to avoid.
So there is no bureau. A lender names the peers whose word it takes, in its constructor, and the answer is pulled from the peer's own storage — where the fact was recorded by the contract that actually extended the credit. No reports, no writes, nothing to forge: a peer can only ever say what happened on its own books, and the worst a hostile one can do is refuse credit it was never going to extend. A lender that names nobody is unaffected by everyone, which is the safe default and has to be a choice rather than an accident.
A drawn line is settled by the borrower proving repayment landed at the lender's Ethereum address. If no finalized claim arrives before the deadline, the line defaults. The contract never establishes that a payment was missed — the burden sits with the only party who could discharge it. Silence is the default condition, not an inference.
A default that costs nothing but the line it happened on is not a credit event, though, and that is
what it used to be: markDefault set a status and the borrower opened the next line the same block
on a later slice of history. defaultsOf[subject] counts defaults that still stand, and openLine
refuses while any do.
cure is the way back. The borrower proves the repayment late, on exactly the terms it was owed —
same scope, same watermark, same backing, same amount, every check settle makes, sharing one
function with it so the cheaper path cannot drift into existence. The line becomes Settled, the
count comes down, and the subject can borrow again on history it has not already spent. Nothing is
forgiven for being late; the deadline has already done its work, which was to record the default
while it stood.
That distinction is what separates a credit protocol from a blacklist, and it costs one counter.
Every contract below is verified on Blockscout — source, ABI and decoded constructor arguments
are readable at its address. An unverified address is a wall of bytecode, and "the source is on
GitHub" is a different claim from "this address runs that source". npm run verify republishes
them after a redeploy, reading whichever record DEPLOYMENTS names — so
DEPLOYMENTS=deployments.full.json npm run verify covers the Sepolia-sourced set including its
ledger, with nothing reconstructed by hand.
Deploying refuses to overwrite an existing record without REDEPLOY=1. The addresses below are
the ones in it, all verified; npm run demo used to begin by replacing them, so following this
file to record a demonstration quietly made everything published about them false.
Blockscout reports a partial match: the runtime bytecode agrees and the trailing metadata hash does not, which is what happens when the compilation environment is not reproduced byte for byte. The code is readable and the functions are callable.
Sourcify disagrees, in the right direction. It compares the metadata hash
too, and reports every contract below as a full match — exact_match for the decoder and the
Sepolia ledger, match for the registry and both credits — from a tree it read itself. npm run verify now submits to both, because two verifiers that do not share a backend agreeing on the same
source is a stronger sentence than one, and because Blockscout forwarding to Sourcify is a thing it
usually does rather than a thing to rely on: the Sepolia ledger had not arrived until it was sent.
repo.sourcify.dev/102031/<address> has the sources.
| Contract | Address |
|---|---|
UtuhRegistry | 0x8FA0BD5301D998Be873E31453E53d114929a5Fac |
UtuhCredit | 0x89FB81b1e453b7Bd18ac1A6AF03C84A40Ce10C57 |
EvmV1Decoder | 0x5cab00c032D7d4436f312Dd51ef59Dc5b860df3F |
npm run credit runs against these, on Ethereum mainnet data.
| Contract | Address |
|---|---|
UtuhRegistry | 0x26880c8980Cd54827543bD34c6c613253c69347b |
UtuhCredit | 0x0177aDb82152c8673a85271F7F06336B820324b6 |
EvmV1Decoder | 0x084c45552A6c45C7269F4a7041E757ABf4Bcc008 |
SettlementLedger (Sepolia) | 0xC8C9053C4E2c0590df684c12e5f2610EFeC9575B |
Everything below is readable at those addresses rather than taken on trust — claim(id),
memberCount(id), keyAt(id, i), enforceableLoss(id), line(1), underwrittenThrough(subject)
and settledThrough(subject) all answer to anyone, and the console at npm run web shows them
without a terminal.
| Read | Answer |
|---|---|
claim(1) | Finalized, 3 members, aggregate 0.003 ETH of proven volume |
keyAt(1, 0..2) | Sepolia blocks 11575883, 11575885, 11575886 |
claim(2) | Finalized, 0 members — the clean claim, and there is nothing to show |
claim(3) | Refuted, enforceableLoss collapsed to 0 |
claim(4) | Finalized, repayment of 0.000525 ETH |
claim(5) | Refuted — planted short by one, and broken from a browser |
burned() | 2 CTC, two refuted claimants' halves that nobody collected |
line(1) | Settled, limit 10 CTC, drawn 10 CTC, repayRequired 525000000000000 |
underwrittenThrough(borrower) | 11575891 — one past the range that opened the line |
settledThrough(borrower) | 11575986 — one past the range that discharged it |
defaultsOf(borrower) | 0 |
activeLineOf(borrower) | 0 — the line settled, so the slot is back |
claim(6), claim(7) | Finalized — 3 members and 0, built from the browser by a second borrower |
line(2) | Settled — opened, drawn, repaid and settled entirely from the console |
claim(8) | Finalized, 1 member — the repayment; opened by one run, resumed by the next |
claim(9), claim(10) | Finalized, unspent, useless — a 4-block range against a 5-block floor; the |
| page now refuses that before a bond is posted | |
claim(11), claim(12), line(3) | The second underwriting of the same key, on later history — line 3 Settled — the whole loop, twice, from the page (claim 13 is its repayment) |
peerCount() | 0 — this lender takes nobody else's books, which is the safe default |
The borrower's sweep read publicnode=3 tenderly=3: two independent endpoints agreeing, and the
claim built on the union rather than on whichever answered first.
Lines 2 and 3 belong to 0x0C2ffE823f1b64c975D768c9822F31eFED6f6a83, a key that has never run a
script here. It paid the lender three times on Sepolia and then did everything else through the
page — sent its control commitment (the wallet switched to Sepolia and back), proved it, built
claims 6 and 7, waited out their windows, finalized, opened line 2, drew 1 CTC, paid the lender back
through the ledger with the same wallet, built claim 8 over that payment, waited out that window,
and settled — as web/tests/borrow.live.spec.ts, against these contracts. Then it did it again on
later history, for line 3. The limit is 5 CTC and not the 12 the volume would justify, because it
posted the 1 CTC minimum bond and a 1 CTC bond guarantees a 0.5 CTC loss.
Claim 5 is the interesting one. npm run bait sealed it deliberately short by one event and told
nobody. It was found and broken from the console, in a browser: the page swept Sepolia across
two endpoints (publicnode=4 tenderly=4), checked all four against the claim on-chain, found the
one it omitted, fetched a proof from the hosted builder and sent the refutation itself. That path
is a Playwright test — UTUH_LIVE_UI=1 npm run web:test -- refute.live — so it is a thing that is
checked rather than a thing that was done once.
Three figures there are the mechanism, not decoration. The limit is 10 CTC —
enforceableLoss of 1 CTC times a BOND_MULTIPLE of 10 — and not the 12 CTC the 0.003 ETH of
volume alone would justify, because a 2 CTC bond only guarantees a 1 CTC loss and it is the
guarantee that lends. 0.000525 ETH is what drawing 10 CTC obliges at the lender's rate and 105%
terms; the borrower had no say in the figure. And the two watermarks now read one past the ranges
they consumed, which is what stops the same history opening a second line and the same payment
discharging a second debt.
test/Lifecycle.t.sol reproduces all three of those numbers locally, from the fixture's own
amount and the deployed policy, so they are a property of the code rather than of that afternoon.
A default, and the way back. DEPLOYMENTS=deployments.full.json npm run cure deploys a second
UtuhCredit over the same registry with a five-minute repayment window — the recorded run left one
at 0x509fab6a2Fd8C1a50dAB8C05cD7C7e53cB29868f — and underwrites the same
borrower on the same finalized claims, draws, lets the deadline pass, is marked in default — and
then makes it good with the repayment claim the loop already finalized. Claims belong to the
registry and claimSpent belongs to the credit contract, which is why that costs one deployment
and one Sepolia transaction rather than a second loop.
An earlier run, at earlier addresses, did not finish in one go — twice — and both interruptions
are worth recording because the chain absorbed them. The first: the process died during the long
wait for Sepolia's attestation frontier to reach the repayment block. Nothing was lost, because
nothing was being held in the script; npm run finish -- <registry> <credit> 1 read the line's
state off the chain and closed it. The second was mine. I stopped a resume that looked stuck and
started another, and the first was still running: two processes built the same repayment claim, both
holding the identical event. Nothing broke, because a settlement consumes both the claim and the
source-chain range it rests on — the duplicate could not settle the same line, and the watermark
meant that payment could not settle any other. It cost its author a bond locked until its own window
closed, and nothing else. Two guards written for a lying claimant turned out to cover a clumsy
honest one too.
npm run credit reads Ethereum mainnet: real Aave positions, real liquidations, real
borrowers. It underwrites them, refutes a genuinely liquidated address that claims it was never
liquidated — and then stops, because nobody can prove control of a stranger's address. That
refusal is the honest end of that flow.
npm run full closes the loop instead. A borrower we control acts on Sepolia: they pay a
lender through SettlementLedger, bind their address with a control commitment, get underwritten
on what they actually did, draw CTC on Creditcoin, repay on Sepolia, and settle. Two parties, both
transacting for themselves.
The source-chain contract is not a stand-in for anything under test. The payments are real transfers, the events are real logs in real blocks, and Creditcoin attests them exactly as it attests Aave's. A scope is a scope — the registry cannot tell the difference, and does not need to.
npx utuh-mcp # published on npm — no clone, no key, no build
npm run mcp # the same server from this repo, for hacking on it
{ "mcpServers": { "utuh": { "command": "npx", "args": ["-y", "utuh-mcp"] } } }
That entry is Claude Desktop's claude_desktop_config.json and Cursor's .cursor/mcp.json. VS
Code's .vscode/mcp.json wants { "servers": { "utuh": { "type": "stdio", "command": "npx", "args": ["-y", "utuh-mcp"] } } }, and Claude Code is claude mcp add utuh -- npx -y utuh-mcp.
Add "env": { "PRIVATE_KEY": "0x…" } to the entry only if refute_claim should be able to send;
the other four tools spend nothing. npm serves 0.4.0 (published 2026-09-13; a clean-cache
npx -y utuh-mcp reports it, with logging and completions capabilities and instructions), and
the same server is attached to the GitHub release
v0.4.0 as utuh-mcp.mcpb for Claude
Desktop's one-click install. In 0.4.0 every tool answers with structuredContent against an
outputSchema, sweeps and audits narrate progress over notifications/progress and log over
notifications/message, argument completion works for the resource templates, the server sends
instructions at initialize, and a missing claim or a bad cursor comes back as an isError
result rather than a thrown exception. 0.3.0 — five tools, one prompt, the resources — was what
npm served for most of that day, and the earlier releases stay installable by version.
A URL instead of npx. Claude.ai connectors, ChatGPT and most
hosted agents take a remote MCP URL and cannot run a local command. api/mcp.ts (3241629) serves
the same createServer over Streamable HTTP, stateless, with every tool, resource and prompt
defined once in offchain/mcp.ts. It holds no key. Over HTTP, refute_claim never sends: it
returns the unsigned transaction, whose calldata decodes to refute(claimId, proof, continuity),
for the caller's own wallet to sign. npm run mcp:http-test passes 33 of 33 against the built
function locally, where the same sweep now has to finish complete under the default budget, and it
passed 30 of 30 against a non-production preview, before that check was added. A 216,002-block
mainnet sweep took 17.0 s from a local machine and 8.1 s from the CI runner, both against a preview,
and 15.4–17.8 s over five local runs of the built function. The stdio suite stays at 66 of 66. It is live at
https://utuh.vercel.app/api/mcp: on 2026-09-14, after master deployed, initialize answered 200
from server utuh 0.4.0, and tools/list returned tally, list_claims, sweep_claim,
refute_claim and audit_attestors. server.json lists it beside the npm package for the next
tagged registry publish (d2265bd).
Listed in the official MCP Registry
as io.github.PugarHuda/utuh-mcp, so a client that does not know the package name can still find
it. The registry hosts metadata only; it proves ownership by fetching the published tarball and
requiring its mcpName to equal the server name being claimed, which offchain/build-mcp.ts
writes. .github/workflows/mcp-registry.yml publishes the listing from CI over GitHub OIDC — no
secret to store — after checking that npm really serves the version being announced.
Every guarantee here rests on anyone may refute a claim by proving one in-scope event it left
out, and until now "anyone" meant a person: at a console, or at the published page. offchain/mcp.ts
puts the same three verbs — look, sweep, refute — behind the Model Context Protocol, which makes
the watcher a role an AI agent can hold. An agent needs no account and no capital to look, and the
one that finds an omission is paid half the bond for proving it: a business model that fits inside
a tool call.
Five tools: tally, list_claims, sweep_claim, refute_claim, audit_attestors. None of them
is new machinery — each is the same offchain/lib function the daemon and the browser console
already run, because an MCP server with its own logic would be a third implementation waiting to
drift, and "agents can enforce completeness" is only credible if agents run the code that
demonstrably does.
Tools are only one of the protocol's three primitives, and a server that stops there hands a model
prose it has to re-read every turn. Claims are also resources — utuh://tally,
utuh://claims/{deployment}, utuh://claim/{deployment}/{id} — live JSON a client can attach and
re-read, carrying the scope a refuter needs and a refutable flag that answers the only question
worth asking first. The watcher's job is a prompt, hold_the_watcher_role: sweep everything
still inside its window, treat "no gap found" as provenance rather than proof, and bring a finding
back rather than spending; a second prompt in 0.4.0, weigh_a_refutation, takes a deployment and
a claim id, confirms the gap, lays out bond and reward, and sends only after an explicit yes. And
every tool carries its annotations, so a client can tell the four
that only read from the one that sends a transaction and slashes somebody's bond — the confirmation
belongs on exactly one of the five, and now the server says which.
npm run mcp:test builds that bundle and speaks the protocol to it — the three listings, a prompt
fetched, the tally read live off Creditcoin, a sweep of mainnet claim 1 (refuted on chain, so the
sweep has to find the same gap), and refute_claim asked to spend with confirm withheld, which
must refuse. Sixty-six assertions over the wire in about sixteen seconds, no key, and CI runs it
on every push: what
reaches a user is an esbuild bundle with the registry ABI and both deployment records baked in, and
every one of those is a thing that can quietly stop being included.
Two things about it were earned rather than designed. The first client ever connected to this
server found the gap in a standing claim and refuted it — a real transaction, a real slashed bond,
1 CTC of reward — during its own smoke test. That is the pitch demonstrating itself, and it is
also why refute_claim now demands confirm: true before sending: an agent's "let me just try
the tool" must cost a deliberate second call, not a bond. And stdout is the protocol channel, so
the server reroutes every stray print to stderr up front — the SDK's attestation waiter logs
progress, and one such line in the middle of a JSON-RPC stream is a corrupted session.
npm run web # landing page on http://127.0.0.1:5173, console at /app/
Two pages, one set of reads. The landing page at / is what a visitor meets: the two refuted
claims as working-paper cards with a strip of their block range, the tally, "sweep claim N
yourself", how it works in three steps, the three roles, npx -y utuh-mcp, a legend of the marks,
the deployed addresses, and links to the whitepaper, the deck, GitHub, npm, Creditcoin's
verification dashboard and llms.txt. The console at /app/ is the tool — Watch, Claims, Borrow,
Audit the attestors, Credit and Deployment, with a switch between the two published deployments.
Both read through web/reads.ts, so the two pages cannot disagree about what the registries hold.
The visual world, in DESIGN.md, is the auditor's working paper: paper and ink, red
pencil for exceptions, blue for links, light and dark.
Everything on either page is read from CC3 Testnet as it draws. No server holds a key, no indexer stands in between, and there is no seeded state to fall back on — if the chain is unreachable the page says so rather than showing the last thing it knew. The ABIs come out of forge's own artifacts, so a field the contract stopped having is a load failure rather than a plausible-looking zero.
The console shows four things: what Creditcoin says it can attest, read straight off 0x0FD3;
every claim in the registry with its bond, its enforceable loss and its remaining window; the
lender's policy and every line; and a watcher.
The watcher is the part that had to exist. Every guarantee here rests on one sentence — anyone may
refute a claim by proving one in-scope event it left out — and until something is actually
watching, that sentence describes a possibility rather than a fact. npm run watch is that
sentence made real for whoever runs a daemon with a funded key, which is a small number of people.
The console makes it true for whoever opens a page:
contains, on chain;
That is possible only because the pieces are CORS-open and public: rpc.cc3-testnet.creditcoin.network,
the source-chain endpoints, and the hosted Proof Builder all answer a browser directly. Nothing
needed to be built to make it work, and it means enforcement does not depend on anyone deploying
infrastructure. Creditcoin publishes exactly one RPC hostname, though, and it has had bad
afternoons — on 2026-09-03 its nginx spent half a day refusing large eth_call bodies with 413,
which the daily CI probe caught. So the page's reads fail over to Blockscout's eth-rpc proxy for
the same chain, the one second way onto CC3 that exists; its batch ceiling and burst rationing were
measured before being coded around (web/chain.ts has the numbers).
The scripts fail over the same way (e726fd8). In node,
eth_call, eth_getLogs and eth_blockNumber retry against the proxy when the primary does not
answer, and writes and reverts never retry. The proxy was measured before it was trusted: it takes
5-call batches and answers 413 to six, rejects the finalized tag the SDK reads ChainInfo at, and
omits logs for a null-then-set topic filter, so those filters stay on the primary and a 1000-log
answer counts as truncated. With the primary pointed at a dead port, npm run probe verified 24
mainnet events through the proxy alone. The rule and the proxy's measured limits live in offchain/lib/failover.ts
(ddadb10), which imports nothing but ethers types, and the console's CC3 fallback now uses the same rule (210d727). npm run doctor also asks both attestation indexers for reverted attestations on the Ethereum chain keys,
because a reverted attestation is the one event that could unsettle a claim it calls checkpointed.
Both answered 0 on 2026-09-14. The watcher uses the same provider (440c95f): discovery, deadlines and membership
reads fall back to Blockscout, a refutation is still sent only through the primary, and each sealed
claim it sweeps says whether its last block is checkpointed or only attested.
The sweep is the daemon's own function, imported rather than reimplemented — scanScopeUnion in
offchain/lib/scope.ts, bundled into the page. A browser cannot conclude that a claim is complete
on different reasoning than the daemon would.
The console reads either published deployment — ?deployment=mainnet for the one that underwrites
real Aave history, where the claims about real borrowers live, dozens of them, built by the daily
live suite and the mainnet demo. It pages through them twenty-five at a time, and a sweep of a
mainnet claim runs from the browser against Ethereum mainnet in ten-thousand-block pieces, the
size both default endpoints serve; Sepolia's are swept in five-hundred-block pieces because
publicnode stops answering past that. The daemon uses the same table, so the page and the daemon
cannot reach different verdicts by asking in different pieces.
What a mainnet sweep costs, measured 2026-09-13: one subject's 216,000-block window, 400 in-scope
Aave repayments, both default endpoints — 320 s at the 10,000-block chunk, 582 s at 500. The
eth_getLogs calls are not where the time goes; the per-event receipt lookups that pin each log to
its position inside its transaction are.
A claim has an address. /app/?claim=N opens claim N on arrival — what a post, a document or a
refuter's message points at — reaching past the first page if it has to, and the address bar
follows the picker, so the URL always says what is on screen. /?claim=N on the landing page
forwards to the console, so every link this repository ever published still lands. A linked claim
opens with its verdict first: Refuted by one proof of an in-scope event at source block … — 1.0
CTC of the 2.0 CTC bond paid to …, the rest burned. And a visitor with two wallets
installed is asked which: the page listens for EIP-6963 announcements and names every wallet that
answers, instead of taking whichever one grabbed window.ethereum last. A wallet that only does
the old thing still connects.
Selecting a claim opens it: every member decoded from the ordering key the registry stores — source block, transaction index, log index — with the block linked into the source chain's explorer and a pointer to Creditcoin's own oracle dashboard, where the verification the precompile emitted for it sits by source height. The registry keeps only the key, so the block is what can be linked; the transaction at that index inside it is the one. "Read it back yourself" is a click now.
Seventy-odd links sit between the top of that page and the one button that matters to a keyboard user, so the first Tab lands on a skip link and Enter puts focus on the sweep itself.
"A page open in a tab is not a daemon" is under Known limits, and it stayed true after the console
shipped: the page makes refuting available to anyone and makes nobody do it. The smallest thing
that does it without being paid is .github/workflows/watch.yml — every hour, a dry sweep of both
published registries, the same union across independent endpoints the daemon and the console run,
and a red run if a sealed claim is short of an event. The same hour, the same workflow opens the
published console in a real browser and asks what a visitor would: it loads, it says which chain it
is on, the block it shows is this hour's, both deployments read, no pane is broken, and the page
the console asked its host for nothing but its own four files. Pages can serve a stale or broken build with
nothing else in CI noticing; this notices.
Each hourly run picks up where the last one stopped. The watcher records how far it has read in
WATCH_STATE, and a fresh runner has no such file, so it is carried between runs in the Actions
cache — saved under the run's own id, restored by prefix — and the run's summary says whether it
resumed or started over. Without that, a claim sealed and short-changed between two runs would
have been swept only if it happened to fall inside the lookback.
It holds no key. npm run watch -- --dry reads and never signs, so it no longer asks for one, and a
public repository can run it with nothing in its secrets. The red run is the alert: somebody sealed
a lie and nobody has taken the bond yet, and whoever reads that with a key and a few minutes is
paid half the bond to act on it. Or give it the key: a fork that sets a WATCH_PRIVATE_KEY secret
(any account with a little CTC for gas) runs the same hourly sweep without --dry, refutes what it
finds, and is paid the refuter's share — a watcher that costs a repository and nothing else.
The scripts could always do this, and that was the problem: being underwritten meant cloning a
repository, filling in a .env and running TypeScript. The Borrow pane is the same flow with the
visitor's own wallet — bind your address (it will send the control commitment on the source chain
for you, switching the wallet there and back), build the volume and clean claims, wait out their
challenge window, open the line, draw.
Nothing there is a shortcut around the protocol. The claims it builds are ordinary claims: swept
across independent endpoints, proven event by event through 0x0FD2, bonded, sealed, refutable by
the watcher in the pane above, and finalized only once