MCP server: One MCP server.

Utuh watcher

@pugarhudaRun by its publisher: served from their own domain, namespace checked by the registry.MCP server · External

Sweep bonded completeness claims on Creditcoin against Ethereum, and refute one that omits an event.

Blockchain datav0.4.0MITTypeScript
Freeno charge per call

Runs on your machine from its package; the steps are below.

The landing page at utuh.vercel.app: the thesis beside claim 5 read live from the Sepolia-sourced registry — three members verified by the Block Prover and the omitted in-scope event at Sepolia block 11,575,983 struck through and circled, with the refutation that took 1.0 of its 2.0 CTC bond
From the README · 1 of 7

What it does

  1. Reads
  2. Writes
  3. High impact

At most: moves money, runs code or commands, deletes data, or sends messages for you.

What it costs

Free

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).

npmutuh-mcpv0.4.0

Run

npx -y utuh-mcp

MCP client config

{
  "mcpServers": {
    "utuh-watcher": {
      "command": "npx",
      "args": [
        "-y",
        "utuh-mcp"
      ]
    }
  }
}
README48 min read

Utuh

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 at utuh.vercel.app: the thesis beside claim 5 read live from the Sepolia-sourced registry — three members verified by the Block Prover and the omitted in-scope event at Sepolia block 11,575,983 struck through and circled, with the refutation that took 1.0 of its 2.0 CTC bondThe console at utuh.vercel.app/app/ on the mainnet-sourced registry, claim 20: "Refuted by one proof of an in-scope event at source block 25640810 that the claim left out — 1.0 CTC of the 2.0 CTC bond paid to 0x5057…4748, the rest burned"
The landing page, claim 5 as a working paperThe console, claim 20 opened verdict first
The same landing page in the dark colour schemeClaim 5 in the console after a browser sweep: four Sepolia endpoints each answered 4 events, the union holds 4, and the verdict reads INCOMPLETE: 1 event(s) the claim does not contain
Dark schemeA sweep of claim 5 from the browser, with its provenance
  • Verify every number yourself: 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.
  • Hold the watcher role from an agent: 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).
  • Measured: 211 Foundry tests with 16 invariants, 100% line and branch coverage and 99.1% of 737 mutants killed · Slither 0 findings · halmos 5 of 5 · every contract verified on Blockscout and matched on Sourcify · 248 TransactionVerified events on CC3's own indexer.
  • Demo video (3 min): https://youtu.be/HwSnv3E4tzo · Whitepaper: PDF, from web/whitepaper.html · Deck: web/deck.pdf

What is on-chain, all verified on Blockscout and Sourcify (every address and read):

On CC3 Testnet (chain 102031)AddressWhat it holds
UtuhRegistry, Ethereum-mainnet-sourced0x8FA0…5Fac72 claims over real Ethereum mainnet history; 32 refuted, claim 20 among them
UtuhRegistry, Sepolia-sourced0x2688…347b13 claims, the completed borrow-and-repay loop; claims 3 and 5 refuted
UtuhCredit, Sepolia-sourced0x0177…24b6lines 1–3 opened, drawn, repaid on Sepolia and settled; lines 2 and 3 entirely from the console
SettlementLedger, on Sepolia0xC8C9…575Bthe source-chain repayments those lines settled on
Creditcoin's own oracle recordtransaction-verifications248 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.

Contents

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

The short version

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 problem

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.

What this uses of Creditcoin's, and what it deliberately does not

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.

What is deliberately not integrated

PenguinBridge, PenguinSwap and PenguinBase are Creditcoin's own consumer applications and none of them appears here. That is a design decision, not an oversight:

  • A bridge would contradict the product. The whole claim is that nothing bridges — history stays on Ethereum, credit is issued on Creditcoin, and only proof crosses. Wiring a bridge in would make the pitch untrue.
  • A DEX price would reintroduce the oracle. Crossing from a source asset to CTC is a price, and the lender states its own rate on-chain precisely so the protocol never pretends to know one. Reading a swap rate would replace a stated assumption with a hidden one.

An integration that weakens the thesis is not depth. Naming the two we refused is more honest than listing eleven we bolted on.

Where this sits next to Creditcoin's own example

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:

  • Nothing asks whether the set is complete. That is fine when the loan is already registered on chain and the contract knows exactly which events it is waiting for. It stops being fine the moment the question is "has this borrower ever been liquidated" — because that question is about events nobody submitted, and no number of inclusion proofs answers it.
  • Default is declared, not proven. 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.

What Utuh does

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)

What scales, and what does not

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:

CallGas (mean)% of a 75M block
open252,7500.33%
seal206,0100.27%
appendBatch (1 event)552,9560.73%
appendBatch (2 events)893,9031.19%
appendBatch (10 events)2,662,0453.54%
refute611,5560.81%
finalize209,2580.27%
withdraw205,8700.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.

The subtle part

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:

solidity
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.

Built on Ethereum mainnet, not Sepolia

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.

UtuhCredit

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.

The console's Borrow pane for the key that ran the browser test, reading only: the address is bound (controllerOf names this account), and line 3 was opened, drawn and settled from the page — limit 5.0 CTC, drawn 1.0 CTC

Underwriting rests on two claims that are adversarial in opposite directions, which is what makes the pair sound:

ClaimAssertionWho benefits from a lieDefence
Volumeproven Aave USDC repaymentsinflating itevery member verified by 0x0FD2 on append
Cleancomplete set of liquidations, normally emptyomitting onebond, 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.

Two places where units have to be taken seriously

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.

Reading a history is not the same as owning it

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.

One history, one line

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.

One line at a time, and why that is a rule rather than tidiness

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.

Whose books you take

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.

Default without proving a negative, and the way back

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.

Deployed on CC3 Testnet (chain id 102031)

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.

Mainnet-sourced deployment

npm run credit runs against these, on Ethereum mainnet data.

Sepolia-sourced deployment — the completed loop

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.

ReadAnswer
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.

Two demonstrations, and why there are two

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.

The watcher as an MCP server, and why an agent can hold the role

bash
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
json
{ "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.

The console, and why the watcher belongs in a browser

bash
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.

The landing page at 390 pixels wide: the navigation, the live chain id and CC3 block, and the thesis, with nothing scrolling sideways

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:

  • it rebuilds the claim's scope from what the registry stores, trusting the claimant for nothing;
  • it sweeps the source chain from the browser, across independent public endpoints, taking the union rather than a vote, and says how many answered — because "no gap found" from one endpoint is not the same claim as "no gap found" from two;
  • it checks each event against the claim with contains, on chain;
  • and if the claim is short, it fetches one proof from the Proof Builder and sends the refutation.

The Watch pane on claim 5: the scope rebuilt from the registry, source blocks 11573192..11576192 swept from four endpoints that each answered 4, a union of 4 in-scope events, and INCOMPLETE: 1 event(s) the claim does not contain — beside the refutation button for the event at block 11575983

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.

Both deployments, and a claim opened up

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 watcher that is always on, holding nothing

"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.

Borrowing from the page

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

Similar modules

Browse Blockchain data