# README.md # 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. - **Live:** [utuh.vercel.app](https://utuh.vercel.app/) (landing, read from Creditcoin as it draws) and the console at [utuh.vercel.app/app/](https://utuh.vercel.app/app/) (sweep, refute, borrow; no server, no clone). - **Two refuted claims, one click each:** [claim 5, sealed one event short and broken from a browser](https://utuh.vercel.app/?claim=5) · [claim 20, a false _"never liquidated"_ over 216,000 Ethereum mainnet blocks, refuted by one liquidation proof](https://utuh.vercel.app/?deployment=mainnet&claim=20) | | | | --- | --- | | ![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](docs/img/landing-light.png) | ![The 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"](docs/img/console-mainnet-20.png) | | The landing page, claim 5 as a working paper | The console, claim 20 opened verdict first | | ![The same landing page in the dark colour scheme](docs/img/landing-dark.png) | ![Claim 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](docs/img/console-claim-5-sweep.png) | | Dark scheme | A 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](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.PugarHuda/utuh-mcp). For clients that cannot run a command, the same server answers at `https://utuh.vercel.app/api/mcp` ([below](#the-watcher-as-an-mcp-server-and-why-an-agent-can-hold-the-role)). - **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](https://utuh.vercel.app/whitepaper.pdf), from [`web/whitepaper.html`](web/whitepaper.html) · **Deck:** [`web/deck.pdf`](web/deck.pdf) What is on-chain, all verified on Blockscout and Sourcify ([every address and read](#deployed-on-cc3-testnet-chain-id-102031)): | On CC3 Testnet (chain 102031) | Address | What it holds | | --- | --- | --- | | `UtuhRegistry`, Ethereum-mainnet-sourced | [`0x8FA0…5Fac`](https://creditcoin-testnet.blockscout.com/address/0x8FA0BD5301D998Be873E31453E53d114929a5Fac?tab=contract) | 72 claims over real Ethereum mainnet history; 32 refuted, claim 20 among them | | `UtuhRegistry`, Sepolia-sourced | [`0x2688…347b`](https://creditcoin-testnet.blockscout.com/address/0x26880c8980Cd54827543bD34c6c613253c69347b?tab=contract) | 13 claims, the completed borrow-and-repay loop; claims 3 and 5 refuted | | `UtuhCredit`, Sepolia-sourced | [`0x0177…24b6`](https://creditcoin-testnet.blockscout.com/address/0x0177aDb82152c8673a85271F7F06336B820324b6?tab=contract) | lines 1–3 opened, drawn, repaid on Sepolia and settled; lines 2 and 3 entirely from the console | | `SettlementLedger`, on Sepolia | [`0xC8C9…575B`](https://eth-sepolia.blockscout.com/address/0xC8C9053C4E2c0590df684c12e5f2610EFeC9575B?tab=contract) | the source-chain repayments those lines settled on | | Creditcoin's own oracle record | [transaction-verifications](https://dashboard.cc3-testnet.creditcoin.network/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`](examples/completeness-gate/README.md) at [`0xcA62…5D09`](https://creditcoin-testnet.blockscout.com/address/0xcA6228C30607F26253Fffc2A4013a801DEEB5D09?tab=contract) [granted an allowance on claim 72](https://creditcoin-testnet.blockscout.com/tx/0x82fe073a1de45d11e644ec1147630303851d87253a3c276ced6ec9d151e01a53) and [refused refuted claim 20](https://creditcoin-testnet.blockscout.com/tx/0x89561d2e9b9bd96cc4df97010d78b0f8723fb6d1cea782463a0af3ac09ba46b9). This project wrote and deployed it as the worked example, so it is not an outside user. ## Contents [The short version](#the-short-version) · [The problem](#the-problem) · [What this uses of Creditcoin's](#what-this-uses-of-creditcoins-and-what-it-deliberately-does-not) · [Next to Creditcoin's own example](#where-this-sits-next-to-creditcoins-own-example) · [What Utuh does](#what-utuh-does) · [Built on Ethereum mainnet](#built-on-ethereum-mainnet-not-sepolia) · [UtuhCredit](#utuhcredit) · [Deployed on CC3 Testnet](#deployed-on-cc3-testnet-chain-id-102031) · [Two demonstrations](#two-demonstrations-and-why-there-are-two) · [The watcher as an MCP server](#the-watcher-as-an-mcp-server-and-why-an-agent-can-hold-the-role) · [The console](#the-console-and-why-the-watcher-belongs-in-a-browser) · [Layout](#layout) · [Running it](#running-it) · [On testing](#on-testing) · [What the tools say](#what-the-tools-say) · [Before you build on this](#one-thing-worth-knowing-before-you-build-on-this) · [Attestcoin surface used](#attestcoin-surface-used) · [Known limits](#known-limits) · [License](#license) Elsewhere: **[docs/INTEGRATING.md](docs/INTEGRATING.md)** (use the registry from your own contract) · **[docs/COMPLETENESS.md](docs/COMPLETENESS.md)** (does what you built have this gap?) · **[docs/ROADMAP.md](docs/ROADMAP.md)** (what is gated on whom) · [docs/AUDIT.md](docs/AUDIT.md) · [docs/MAINNET.md](docs/MAINNET.md) · [SECURITY.md](SECURITY.md) · 한국어 요약: **[docs/README.ko.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](https://github.com/gluwa/attestcoin-protocol-examples/tree/main/loan) — `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: | 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. ### 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](docs/img/borrow.png) 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. ### 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") || ``` 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_MULTIPLE`th 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](https://sourcify.dev) 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/
` has the sources. ### Mainnet-sourced deployment | Contract | Address | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UtuhRegistry` | [`0x8FA0BD5301D998Be873E31453E53d114929a5Fac`](https://creditcoin-testnet.blockscout.com/address/0x8FA0BD5301D998Be873E31453E53d114929a5Fac?tab=contract) | | `UtuhCredit` | [`0x89FB81b1e453b7Bd18ac1A6AF03C84A40Ce10C57`](https://creditcoin-testnet.blockscout.com/address/0x89FB81b1e453b7Bd18ac1A6AF03C84A40Ce10C57?tab=contract) | | `EvmV1Decoder` | [`0x5cab00c032D7d4436f312Dd51ef59Dc5b860df3F`](https://creditcoin-testnet.blockscout.com/address/0x5cab00c032D7d4436f312Dd51ef59Dc5b860df3F?tab=contract) | `npm run credit` runs against these, on Ethereum mainnet data. ### Sepolia-sourced deployment — the completed loop | Contract | Address | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UtuhRegistry` | [`0x26880c8980Cd54827543bD34c6c613253c69347b`](https://creditcoin-testnet.blockscout.com/address/0x26880c8980Cd54827543bD34c6c613253c69347b?tab=contract) | | `UtuhCredit` | [`0x0177aDb82152c8673a85271F7F06336B820324b6`](https://creditcoin-testnet.blockscout.com/address/0x0177aDb82152c8673a85271F7F06336B820324b6?tab=contract) | | `EvmV1Decoder` | [`0x084c45552A6c45C7269F4a7041E757ABf4Bcc008`](https://creditcoin-testnet.blockscout.com/address/0x084c45552A6c45C7269F4a7041E757ABf4Bcc008?tab=contract) | | `SettlementLedger` (Sepolia) | [`0xC8C9053C4E2c0590df684c12e5f2610EFeC9575B`](https://eth-sepolia.blockscout.com/address/0xC8C9053C4E2c0590df684c12e5f2610EFeC9575B?tab=contract) | 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`](https://creditcoin-testnet.blockscout.com/address/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 -- 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`](https://github.com/PugarHuda/utuh/releases/tag/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](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.PugarHuda/utuh-mcp) 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](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](docs/img/watch.png) 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 the window has actually elapsed. The claim ids are kept in the browser's own storage, so closing the tab during a window costs nothing — which matters, because a window is measured in blocks and nobody is going to sit and watch one. The loop closes in the page too. Step 5 reads what a drawn line owes off the contract — source units, deadline, the exact event and payee a repayment claim must contain — pays the lender through the source-chain ledger with the same wallet when that is how the lender is paid, builds the repayment claim, waits out its window, and settles. A line in default is cured the same way, on the terms it was owed. A line never drawn on is given back. And a claim that was opened and never sealed — a closed tab, a rejected signature — shows up under Claims with one button that abandons it and returns the bond, because the alternative is a borrower who does not know the money is there. Two more things the live test taught the page. The precompile saying a block is attested and the hosted builder having _indexed_ it are different moments, and a proof request in the gap comes back 422 — measured on a payment the precompile had attested a minute earlier, and answered 200 by the same builder a minute later. The page now waits on both, the way the SDK does, and treats a 422 as "ask again" rather than as a failed build. And a build that dies after `open` — that 422 did it once — leaves a claim Open with a bond in it; the page finds that claim again on the next press, by its scope and claimant rather than by anything the browser remembered, and resumes it from the last key the registry recorded. Claim 8 on the published registry is the receipt: opened by one run, appended and sealed by the next, one bond. The lender has controls too, shown only to the lender: fund, withdraw undrawn. And an overdue line carries a _mark default_ button for anyone, because recording a default is permissionless, unpaid, and — since an overdue line blocks the next one by itself — no longer something the guards depend on. It is still the record peers read, so whoever notices may write it. ### Published without a server `npm run web:static` bakes the ABIs and the deployment record into one bundle and writes the two pages around it — `index.html` at the root, `app/index.html` for the console. There is no server in the published build at all, and the browser tests assert exactly that: the console boots, reads the live chain, and asks its host for nothing but its `index.html`, `main.js`, `style.css` and the font they use. A GitHub Actions workflow builds it from each commit's own artifacts, so the ABI the page carries is the ABI the contracts were compiled with. `vercel.json` makes the canonical host send HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, a `Permissions-Policy` that denies camera, microphone and geolocation, `X-Frame-Options: DENY`. The page now fits a strict `Content-Security-Policy` (210d727): the static build bakes its record into a JSON data block, so the only inline script left is the landing's hashed redirect, and `web/tests/csp.spec.ts` injects the policy onto the static build and fails on any violation while the page boots, audits, sweeps and falls back to Blockscout. `vercel.json` sends that policy on every document (40cd6fa), though not on PDFs, images, fonts, scripts or styles: `default-src 'none'`; scripts only from the site plus the one hash, which matches the committed landing page; styles and fonts only from the site; images from the site and `data:`; `connect-src` limited to the twelve origins the page calls; `base-uri`, `form-action`, `object-src` and `frame-ancestors` all `'none'`; no `'unsafe-inline'` or `'unsafe-eval'`. Measured on a dev preview built from ce47503: `/` and `/app/?claim=5` answer with that policy byte for byte plus the five headers; `llms.txt`, `security.txt`, `whitepaper.pdf` and `main.js` carry the five and no CSP, as scoped; and in Chromium the landing loaded, `/?claim=5` forwarded to the console, and a sweep completed with zero `securitypolicyviolation` events. The production deploy in `pages.yml` uploads `vercel.json` beside the build and compares the live headers with it. Production today, deployed from master, sends only the HSTS header Vercel adds itself. More files are written beside those and never requested by the page, because they are for other readers. `.well-known/security.txt` is RFC 9116, for a researcher who found the deployment rather than the repository. `whitepaper.pdf` and `deck.pdf` are the documents. `robots.txt` and `sitemap.xml` are for a crawler. `.well-known/agent-registration.json` is the agent card ERC-8004 expects at that path, describing the watcher role; nothing is registered on-chain for it. And `llms.txt` is the [convention](https://llmstxt.org) an agent reads on arrival — Creditcoin's own Attestcoin docs publish one, which is where this project found it. That last one is not a summary of the page: the page is for a person, and an agent landing here needs three things in the order it needs them — that the watcher role exists, that holding it costs nothing while it is only looking, and that `npx utuh-mcp` lets it hold the role without a browser. A project whose argument is that a watcher need not be a person, leaving nothing at its own URL for a machine to read, is asking one to parse a screenshot. `static.spec.ts` checks that every tool it advertises is one the server actually has, and that the package it points at is really published under the name it gives. The build also writes [`llms-full.txt`](https://utuh.vercel.app/llms-full.txt) (adae297), this README and every `docs/*.md` in one file, for an agent that wants the whole argument without following links. And the landing's ["Check it yourself"](https://utuh.vercel.app/#check) section (`/#check`, where the hero's "Check every claim yourself" link points) replaces "On the record" and keeps its sentence about who has used this. It lists each contract with its Blockscout page, Sourcify full match and role; the claim 5 and claim 20 refutation transactions and their refuter; the oracle dashboard; the live count of refuted claims and how many distinct addresses sent them; and `npx -y utuh-mcp` and `npm run judge`, each with a copy button. `web/tests/borrow.live.spec.ts` is the test that makes the Borrow pane a claim rather than a hope. A fresh key — derived from the operator's, holding nothing but a little Sepolia ETH and a little CTC — pays a lender three times on Sepolia, then, **through the page**: sends the control commitment (the wallet is switched to Sepolia and back), proves it, builds the volume and clean claims, waits out the challenge window, finalizes, opens a line and draws — then pays the lender back through the ledger with the same wallet, builds the repayment claim, waits out that window too, and settles. Every step is a real transaction against the published contracts, and the assertions read the registry and the credit back rather than the page. It resumes: a borrower with a drawn line open picks up at repayment, the way a person would. What stands in for MetaMask is `web/tests/wallet.ts`: a real key in the test process, an EIP-1193 provider on the page that routes reads to the real RPC of whichever chain it is on and hands every `eth_sendTransaction` back to be signed. It spends money and takes twenty minutes, so it is off unless asked for — `UTUH_LIVE_UI=1 npm run web:test -- borrow.live`. `web/tests/angles.spec.ts` is the console from the angles nobody demos, and two of them found things. In dark mode, with contrast checked: clean. At 375px: nothing sideways, everything reachable. From the keyboard alone: Tab to the sweep, Enter, and it sweeps. With a wallet whose owner presses _Reject_ on every signature — a real EIP-1193 provider answering 4001 — every write path reports the refusal and stays usable. And with `rpc.cc3-testnet.creditcoin.network` unreachable from the browser, the page used to sit on _loading_ for as long as anyone cared to wait, because a provider pointed at a dead endpoint retries rather than failing; it now says Creditcoin is not answering, inside twenty seconds, and shows no number it did not just read. `web/tests/a11y.spec.ts` runs axe over the rendered page — the real DOM with the chain's answers in it — against the WCAG 2.x A and AA rules, and any violation fails the build by name. The console exists so that people who would never run a daemon can still refute or borrow, and "people" is not "sighted people with a mouse". It reports zero. Lighthouse 12 agrees, in a single mobile run with default throttling against production on 2026-09-14, after the 9e4261f deploy. It is a measurement from that day, not a CI budget. `https://utuh.vercel.app/` scored performance 95, accessibility 100, best practices 100 and SEO 100, with LCP 2.3 s, CLS 0.001, TBT 40 ms, FCP 1.9 s and Speed Index 4.0 s. The console at `/app/` scored 97, 100, 100 and 100, with LCP 1.4 s, CLS 0.031, TBT 20 ms, FCP 1.0 s and Speed Index 4.7 s. Neither page has an accessibility audit below 100. The build before that deploy measured an LCP of 3.55 s and accessibility of 98 on `/`. `npm run web:test` drives it in a real browser against the live chain: the chain id it reports has to match an independent RPC call, the attestation frontier has to be past genesis, the claims it lists have to be the ones the registry holds, and the sweep has to produce a verdict with its provenance attached. With `UTUH_LIVE_UI=1` a further test connects a real wallet, finds a claim that is genuinely short, and refutes it — a real transaction, verified by the real precompile, slashing a real bond. The suites run as three Playwright projects (b6194c2), Chromium, Firefox and WebKit, and `ci.yml` is set to run all three on every push to master and dev, on pull requests and daily. CI run 34805675016 on 9e4261f (2026-09-14, against a local static server) passed with every job green: 272 browser tests passed across the three engines, 33 were skipped, and 1 was flaky. The flaky one is WebKit's `/?claim=5` deep-link test in `web/tests/judge.spec.ts`, which failed once and passed on retry. Its log line is `Origin http://127.0.0.1:5173 is not allowed by Access-Control-Allow-Origin. Status code: 502`: an upstream endpoint answered 502 without CORS headers. web-qa2's analysis puts that on `https://rpc.sepolia.ethpandaops.io`, which the log line itself does not name. The run before it, on 7460f7a, had two WebKit failures in `flows.spec.ts` (the keyboard-only sweep and back/forward), fixed on the test side in 9e4261f. Firefox and WebKit skip the tests that only mean something on Chromium, and there are two kinds: the two slow-3G LCP and CLS tests, which throttle the network through the Chrome DevTools Protocol, and six screenshot comparisons whose baselines Chromium renders. ## Layout ``` .github/workflows/pages.yml the published console, rebuilt from each commit's own artifacts .github/workflows/watch.yml hourly: a keyless sweep of both registries, and a browser smoke of the published console .github/workflows/codeql.yml CodeQL over the TypeScript that builds, proves, refutes and signs .github/dependabot.yml weekly bumps for npm and the actions; foundry stays pinned by hand SECURITY.md how to report a way to make a false claim stand .github/workflows/ci.yml fmt, build, tests, gas snapshot, typecheck, slither — and a daily job that proves real mainnet events against the live precompile, checks the hosted and local provers still agree, and reports what the registry has cost. All three need no key and write nothing. slither.config.json which detectors are off, with the reasons next to the code knip.json what counts as reachable; @gluwa/usc-contracts is imported from Solidity, which a TypeScript analyser cannot see .prettierrc.json TypeScript formatting, enforced in CI the way forge fmt is .gas-snapshot committed, and CI fails if gas moves more than 5% src/ UtuhRegistry.sol the completeness layer source/SettlementLedger.sol deployed on the *source* chain: payments and adverse events UtuhCredit.sol undercollateralized credit built on it lib/EventScope.sol which events a claim covers, and how each one counts interfaces/IBlockProver.sol 0x0FD2 — Merkle + continuity verification interfaces/IChainInfo.sol 0x0FD3 — attestation frontier and coverage docs/ INTEGRATING.md using UtuhRegistry from someone else's contract — the registry is infrastructure, UtuhCredit is one application of it COMPLETENESS.md three questions that find this gap in a contract, worked against Creditcoin's own reference loan flow rather than against anyone's hackathon entry README.ko.md a Korean summary, marked as a summary — this file stays canonical ROADMAP.md shipped / specified / gated / refused, and for each one the thing you would observe from outside if it happened AUDIT.md what an external reviewer needs on day one: scope, trust model, the invariants, where to look first, what is already known MAINNET.md the runbook for chain 102030 — what is the same, the three things that differ, and the tripwire that fails if the table is wrong test/ EventScope.t.sol the matcher, ordering key, metrics and leaf identity Consumer.t.sol a thirty-line consumer that is not Utuh, compiled and tested, so the claim that the registry is reusable is checked rather than asserted RegistryInvariant.t.sol four actors, random sequences, and the books have to balance after every move — every wei escrowed, credited or burned, nothing else CreditInvariant.t.sol the same for the lender's money: balance equals `available`, funding minus withdrawals equals what is available plus what is out, one active line per subject, watermarks only advance Audit.t.sol every refusal that had no test, and the questions a reviewer asks first — reentrancy on each CTC path, exact window boundaries, one claim pair underwriting a line at every lender UtuhCredit.t.sol deployment floors, control binding, scope identity, terms, liquidity Lifecycle.t.sol the whole loop locally — claim, refute, finalize, underwrite, draw, settle, default, cure — on real Sepolia transaction bytes, with only the two precompiles' answers substituted fixtures/ two real Sepolia transactions from a recorded run, and one real mainnet transaction that reverted — inclusion is not success, and the check that says so had no test until there were bytes to fail it with SettlementLedger.t.sol what the source-chain ledger will and will not record as a payment EventScopeKey.symbolic.t.sol halmos proofs of the ordering key, over every input rather than 256 samples — `npm run symbolic` CreditRounding.symbolic.t.sol proofs of the money roundings, and a note on the one the solver could not decide — `npm run symbolic:deep` offchain/ deploy.ts deploy decoder, registry, credit e2e.ts honest claim finalized; dishonest claim refuted and slashed creditDemo.ts underwrite a real Aave borrower; refute a real liquidated one cureDemo.ts draw, miss the deadline, be marked in default, prove the repayment late — on chain, against claims the full loop already finalized watch.ts the watcher — follows ClaimSealed, sweeps, refutes what is short badClaim.ts files a deliberately incomplete claim, so the watcher has prey liveTest.ts the guards unit tests cannot reach, asserted against CC3 fullFlow.ts the whole loop on Sepolia, borrower and lender both acting finishLine.ts resume an interrupted run — the state lives on-chain, not in the script doctor.ts preflight: endpoints, both provers, precompiles, balance verify.ts publish sources to Blockscout, constructor args and all proveControl.ts bind a source-chain address to a Creditcoin account provers.ts the same proof hosted and locally, compared and timed gas.ts what the registry has cost, fitted from its own receipts balance.ts wallet, chain and attestation status probe.ts verify real mainnet events through 0x0FD2 over eth_call, no key config.ts endpoints, chain keys, timeouts — everything the env can override lib/scope.ts independent source-chain sweep lib/proofs.ts hosted and local proof building, batched within Attestcoin's limits lib/gasLimit.ts eth_call first, then estimate, then the measured model lib/chain.ts the two precompiles, through the SDK's own clients lib/claims.ts open, append, seal, find omissions, refute lib/specs.ts a UtuhCredit HistorySpec becomes a Scope, and scope equality lib/policy.ts the lender's deployment configuration, read by deploy and verify lib/contracts.ts artifacts, library linking, deployments.json lib/networks.ts chain keys, precompile addresses and default endpoints — the facts the browser console shares with the scripts lib/proofApi.ts one proof, or a batch, from the hosted builder over plain fetch, across both of its published hostnames lib/batches.ts how a claim is cut into batches the prover will accept — pure, so the browser can plan one the same way lib/revert.ts what a call reverted with, by the contract's own name — CC3's RPC hides the data inside the message text web/ index.html the landing page — both registries read live: the two refuted claims, the tally, the addresses; nothing on it is written down app.html the console — everything on it is read from CC3 as the page draws it landing.ts the landing page's reads and the range strips, from the chain's members reads.ts the eth_calls both pages make, so they cannot disagree about a registry main.ts panes: what Creditcoin attests, claims, the watcher, borrowing, credit chain.ts providers, ABIs out of forge's artifacts, wallet connection watch.ts the watcher in the browser, importing the daemon's own sweep borrow.ts sweep, open, append, seal, finalize, open a line — from the page borrowPane.ts the steps, each one reading back what the chain says rather than what the page thinks serve.ts a static server, and nothing else — no key, no indexer, no cache og.png the page, photographed by tests/shots.ts in the same run as the screenshots — what a link preview shows, and what it used to be a week behind because the copy was made by hand build-static.ts the published build: two pages on one bundle, no server, ABIs baked in — plus security.txt, the whitepaper and llms.txt, which the page never asks for because they are for other readers whitepaper.html the whitepaper, and the source the submitted PDF is rendered from build-pdf.ts renders it with the Chromium the browser suite already installs tests/console.spec.ts Playwright, against the live chain: no fixtures, no stubs tests/static.spec.ts the published build asks its host for nothing but its own files tests/a11y.spec.ts axe over the rendered page; WCAG A/AA, zero violations tests/angles.spec.ts dark mode, a phone, the keyboard, a wallet that refuses, a dead RPC tests/deployments.spec.ts the mainnet-sourced deployment: switch, page, open a claim to its members, sweep one from the browser against Ethereum mainnet tests/wallet.ts what stands in for MetaMask: a real key, real chains, chain switching, EIP-6963 tests/wallets.spec.ts two wallets announce themselves; the page names both and connects with the pick tests/deeplink.spec.ts ?claim=N opens a claim on arrival; the address bar follows the picker tests/refute.live.spec.ts refuting through the page with a real wallet — off unless asked tests/borrow.live.spec.ts the whole underwriting through the page, a stranger's key, real money playwright.config.ts one worker, generous timeouts, because the sweeps are real ``` ## Running it ```bash npm install forge build forge test cp .env.example .env # then fill in PRIVATE_KEY npm run doctor # are the endpoints, both provers and the precompiles reachable? npm run verify # publish contract sources to the block explorer npm run balance # prints the faucet command if the account is empty npm run probe # verifies real mainnet events on-chain — needs no CTC at all npm run check # everything CI runs, in one command npm run build # forge build npm run test # 211 forge tests, sixteen of them invariants (`forge test --list` counts # 224 functions, one per invariant; the summary counts each invariant contract once) npm run lint # forge lint over src/ npm run fmt # forge fmt npm run format # prettier over offchain/ (--check variant: npm run format:check) npm run typecheck # tsc, ten strictness flags past `strict` npm run deadcode # knip: unused files, exports, dependencies npm run symbolic # halmos proofs of the ordering key (needs `pip install halmos`) npm run symbolic:deep # the rounding proofs; minutes, so CI runs these daily not per push npm run judge # every claim the submission makes, measured again — no key, exits 1 on any that fails npm run provers # prove one transaction hosted and locally, and compare npm run gas # what the registry has really cost, fitted from its own receipts npm run slither # static analysis; the config says which detectors are off and why npm run check # everything CI runs: fmt, tests, typecheck, slither npm run deploy # refuses without REDEPLOY=1 once a deployment is recorded npm run redeploy:credit # replace only the UtuhCredit a record points at, keeping its registry npm run e2e # the registry, both outcomes npm run credit # the credit line, on a real Aave borrower npm run control # bind your own address (needs a little source-chain gas) npm run full # the entire loop, two parties, on Sepolia npm run cure # a default recorded on chain, then made good — needs a finished loop npm run finish -- # resume an interrupted run npm run watch # the watcher; --once to sweep and exit, --dry to look without acting npm run mcp # the watcher as an MCP server, so an agent can hold the role # (--dry needs no PRIVATE_KEY at all — it is what CI runs hourly) npm run bait # seal a deliberately short claim for the watcher to find npm run livetest # 121 guards asserted against the live chain, refunds included npm run puretest # 93 assertions that need no key and no chain — what CI runs npm run web # landing page on http://127.0.0.1:5173, console at /app/ — read-only without a wallet npm run web:build # bundle it; the server serves ABIs straight out of out/ npm run web:static # the published build: landing page and console, one bundle, no server npm run web:pdf # web/whitepaper.html -> whitepaper.pdf, the submitted document npm run web:test # Playwright, in a real browser, against the live chain npm run demo # e2e then credit, against the deployment already recorded ``` Run one of the chain-writing ones at a time. They all sign with `PRIVATE_KEY`, so two at once means two transactions competing for one nonce, and the second is refused with `replacement transaction underpriced` — from the node, partway through, after the first few steps have already spent CTC. The long ones overlap easily: `npm run livetest` waits out two challenge windows and `npm run full` waits on attestation, which is plenty of time to start something else by mistake. CTC for CC3 Testnet comes from the Creditcoin Discord `#token-faucet` channel: ``` /faucet address: ``` ### Configuration | Variable | Default | Notes | | ------------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `PRIVATE_KEY` | — | required | | `CC3_RPC` | `https://rpc.cc3-testnet.creditcoin.network` | chain id 102031 | | `PROVER_URL` | `https://prover.cc3-testnet.creditcoin.network` | hosted Proof Builder | | `MAINNET_RPC` | `https://gateway.tenderly.co/public/mainnet` | see below | | `MIN_CHALLENGE_WINDOW` | `25` | Creditcoin blocks, deploy-time floor | | `VOLUME_UNIT_IN_CTC` | `15000000000000` | CTC wei per USDC unit; the lender's stated rate | | `CONTROL_CHAIN_KEY` | `1` (Sepolia) | which source chain to send the control commitment on | | `MAINNET_RPCS` / `SEPOLIA_RPCS` | bundled list | comma-separated; **replaces** the defaults | | `*_RPCS_EXTRA` | — | comma-separated; adds to whatever is in use | | `SOURCE_TIMEOUT_MS` | `25000` | how long one endpoint gets before it counts as absent | | `PROBE_DEPTH` | `60000` | how far back `doctor` asks; deep enough to cross an archive cutoff | | `ALLOW_SINGLE_SOURCE` | unset | let `npm run full` seal on one endpoint when two will not answer | | `MIN_HISTORY_BLOCKS` | `216000` | lender policy: how much history an underwriting must cover | | `MAX_STALENESS_BLOCKS` | `50400` | lender policy: how recently it must end | | `REPAYMENT_BPS` | `10500` | lender policy: what a draw must repay, in basis points | | `REPAY_WINDOW_BLOCKS` | `5760` | lender policy: how long the borrower has | | `LENDER_MAINNET` | Binance hot wallet | where repayment must land on Ethereum | | `RESUME_SCAN` | `200` | how many claims back `npm run finish` looks for one this line already built | | `FORCE_MODELLED_GAS` | unset | skip estimation entirely, so the fallback gas model is the one under test | | `WAIT_ATTESTED_MS` | `900000` | how long to wait for the attestation frontier to reach a block | | `GAS_LOOKBACK` / `GAS_LOG_CHUNK` | `100000` / `2000` | how far back `npm run gas` reads, and its chunk size | | `PROVER_TIMEOUT_MS` | `30000` | `doctor` only; the prover is a separate service with its own latency | | `LIVE_SUBJECT` / `LIVE_FROM` / `LIVE_SPAN` | discovered / head−3040 / 400 | pin `livetest` to one address or window instead of letting it find a busy one | | `WATCH_POLL_MS` / `WATCH_LOOKBACK` / `WATCH_LOG_CHUNK` | `20000` / `5000` / `2000` | watcher cadence, how far back it looks with no saved state, and its log-scan chunk | | `WATCH_STATE` | `.watch-state.json` | where the watcher records how far it has read and what is still unresolved — one file per watcher, or two will overwrite each other's mark | | `BOND` / `BAIT_FROM` | `2` CTC / `toBlock−3000` | what `npm run bait` stakes, and where it looks for an event to hide | | `SUBJECT` / `RANGE_BLOCKS` / `MAX_MEMBERS` | derived / `60` / `12` | who and how much `npm run e2e` builds a claim over | | `DEPLOYMENTS` | `deployments.json` | which deployment record to read and write — `deployments.full.json` holds the one `npm run full` made | | `REDEPLOY` | unset | required to overwrite an existing deployment record | | `LEDGER` | — | a source-chain ledger to verify, when the record does not name one | | `EXPLORER_URL` / `SEPOLIA_EXPLORER_URL` | Blockscout | where `npm run verify` submits | Two endpoints per chain is the floor for sealing a claim, not a comfortable margin — lose one and claims stop being sealable until it returns. The bundled defaults are ones checked to actually answer; an earlier list carried three per chain of which two were dead, which meant the two-source minimum could never be met and nothing could be built at all. A safety default that makes the system unusable is not a safety default. Anyone running this for real should add endpoints they pay for through `*_RPCS_EXTRA`. They were verified on the day they were written, and endpoints rot — "I checked once" is an assumption wearing the clothes of a fact. `npm run doctor` is how anyone finds out before a bond is on the line. It asks each endpoint the question the sweeps actually ask, which took three tries to get right. A query for every log on the chain is one no endpoint should serve and condemns all of them. Every USDC transfer across four hundred blocks times out the good ones. And a probe whose right answer is "nothing" cannot tell a working endpoint from a broken one that returns nothing — which is the failure it exists to catch. Each chain now gets two questions: a narrow one that must come back with results, and a wide filtered one that must come back at all. A watcher's whole job is sweeping a claim's range independently, so the source-chain RPC has to serve a wide `eth_getLogs`. Most free endpoints cap the range at 50–1000 blocks; Tenderly's public gateway returns a filtered 216,000-block sweep in one call, which is why it heads the default list. The `_RPCS` variables replace that list rather than extending it. Widening a trust set has to come with the ability to narrow it: an operator who knows one of the bundled endpoints is rate-limited — or is the claimant's — needs to be able to drop it, and an append-only setting cannot. ### A refutation must not depend on a node's willingness to do arithmetic `pallet-evm` does not always propagate revert reasons in estimation mode, so `eth_estimateGas` on a call that reaches a precompile can fail on a call that would have succeeded. Gluwa's own SDK ships a workaround, which is how this is known rather than guessed at. Unhandled, it means a claimant cannot append and — much worse — **a refuter cannot refute**: a liar keeps a bond because a node declined to estimate. Every registry write now goes through `sendRegistryCall`, in this order: 1. `eth_call` first. Free, not subject to the estimation-mode problem, and it settles whether the call would actually succeed. If it reverts, that error is the real one and is surfaced. 2. Then `eth_estimateGas`, with the usual 35% buffer, when the node will answer. 3. If it will not, a limit from the measured cost model — and only because step 1 already proved the call good, so this can never send a doomed transaction. Step 1 has a consequence worth spelling out. `buildClaim` drops an event the registry rejects, because one the registry will not take is one no refuter could use against the claim either — and adding `eth_call` in front of every append meant a _timeout_ could now reach that same code path. An RPC that failed to answer has said nothing about the event, and dropping it there seals a claim short of a real member and forfeits the bond for it. So only a decoded revert counts as a rejection; anything else aborts. It is the prover's 404 problem again, one layer down, and it arrived as a side effect of the fix above rather than as anything anyone wrote on purpose. The fallback is deliberately **not** the SDK's heuristic. That one is `21000 + roots*5000 + 20000`, which for a ten-member append comes to about 146,000 gas against a measured 2,150,000: it would run out of gas and lose the transaction it exists to save. The constants here are the fit `npm run gas` produces, rounded up with the worst observed residual folded in and a third on top, and refused outright above Creditcoin's 75M block cap. A fallback nothing ever takes is a fallback nobody has tested, so `FORCE_MODELLED_GAS=1` makes every call take it. Under it, a real ten-event append sent with a modelled 4,704,709 gas limit against ~2.5M actual, a one-event append with 689,749, and a real refutation — all mined: ``` $ FORCE_MODELLED_GAS=1 npm run e2e estimation skipped by FORCE_MODELLED_GAS — the call succeeds under eth_call, sending appendBatch with 4704709 gas from the measured model batch 1/2: 10 events verified on-chain ... refuted with one proof. reward 1.0 CTC ``` ### A chain key is not a global constant `CHAIN_KEY` says Sepolia is 1 and Ethereum mainnet is 3. That is true of CC3 Testnet and it is not a property of Creditcoin: gluwa's own [networks.json](https://github.com/gluwa/creditcoin-usc-networks/blob/master/networks.json) has chain key 3 meaning **Sepolia** on `usc-devnet`. Three things were hardcoded here that the chain itself will tell you — which key means which chain, its EVM chain id, and the transaction encoding its proofs use. Point `CC3_RPC` at a different Creditcoin network and a build trusting its own constants would underwrite one chain while reporting another. Nothing would catch it, because every proof would still verify — they would be perfectly valid proofs about the chain they actually came from. `get_supported_chains` is read now, once per process, on the path every bond goes through, and `npm run doctor` prints what the network says: ``` ok chain key 3 is "Ethereum" (EVM 1), encoding v1 ok chain key 1 is "Sepolia ethereum" (EVM 11155111), encoding v1 ``` A network reporting an encoding other than v1 is refused outright rather than read with the wrong decoder — `EvmV1Decoder` and the local proof builder both read v1, and supporting an encoding nobody here has seen would be a guess dressed as a feature. ### The prover was the last single point of failure Everything above refuses to trust one endpoint for anything — and until recently the one thing that mattered most came from exactly one place. Merkle and continuity proofs were fetched only from Gluwa's hosted Proof Builder, so an outage there meant no claim could be built and, far worse, **no claim could be refuted**. The whole enforcement mechanism sat behind one hosted service, which is the same assumption the registry exists to reject. The SDK also ships `RawProofBuilder`, which constructs the identical proofs from a source-chain RPC and the ChainInfo precompile with no hosted service involved. Every prover here now asks the hosted service first and falls back to that. `npm run provers` proves the same transaction both ways and compares them, so the claim rests on evidence rather than on the code path existing: ``` sepolia block 11566420, 126 transaction(s) hosted ok 1.9s 1 continuity roots local ok 50.4s 1 continuity roots Both proofs carry the same continuity roots. The local path is 27x slower. Size the challenge window for it, not for the fast one. ``` (That run is from August. On 2026-09-13, after the builder stopped re-fetching sibling transactions, the same comparison reads 0.9 s hosted against 20.0 s local on Sepolia and 29.9 s on mainnet — see Known limits for the wrong number that sat in between.) The test that matters is not the comparison but the outage. Plant an incomplete claim, then run the watcher with `PROVER_URL` pointed at a dead port: ``` $ PROVER_URL=http://127.0.0.1:1 npm run watch -- --once claim 5: sealed with 3 member(s), bond 2.0 CTC window closes at CC3 block 5373599 (now 5373580, 19 to go) swept independently: publicnode=4 tenderly=4 union: 4 in-scope event(s) INCOMPLETE: 1 event(s) missing first gap at block 11565480 tx#109 log#0 refuted with one proof. key 916311728995473911151381154883436544 reward 1.0 CTC ``` A real bond, taken on chain, with no hosted proof service reachable at all. That ratio is the operational fact. The local builder re-fetches every sibling transaction in the block and every block in the continuity range, so it costs tens of seconds where the hosted one costs one — 84s against a block with 112 transactions and a hundred-block continuity range. Against `RECOMMENDED_CHALLENGE_WINDOW` (5760 blocks, about a day) that is nothing. Against the absolute floor of 20 blocks — under four minutes — a refuter driving the local path alone is working with very little room. The floor exists so a demonstration can watch a window elapse; it is not a setting to underwrite against. Two details decide whether the fallback is real or decorative. It needs whole blocks _with receipts_, and `eth_getBlockReceipts` is a method plenty of public endpoints decline — so the local builder reads through every configured endpoint rather than one, and `npm run doctor` asks each of them for that method by name, every run. Every run, because the answer moves. An earlier version of this paragraph said publicnode does not serve it on Sepolia and Tenderly does, which is what `doctor` reported that morning; the same command a few hours later had both serving it. That is the same endpoint whose deep `eth_getLogs` returns 0 where Tenderly returns 22, intermittently. The useful fact is not which endpoint is good — it is that an endpoint's capabilities are not a property you can write down once, which is precisely why `doctor` asks rather than remembering. The second is that absence now arrives in two dialects. A claimant may drop a candidate only when the chain definitely does not have it, and the hosted service says that with a `404` while the local builder says `Transaction 0x… not found`. Two of the local builder's messages read like absence and are not — `Transaction 0x… not found in block N` and `Block N not found for transaction 0x…` are a _sibling_ transaction or the block itself failing to load from the source endpoints. Reading either as absence would let a claimant drop an event that is really there and forfeit the bond, so only the exact form counts, and only when **every** prover consulted agrees. One answering while the other is unreachable is not agreement. `MIN_CHALLENGE_WINDOW` is a deployment parameter rather than a constant so a demonstration can watch a window actually elapse instead of asserting that it would have. The recommended production value is `UtuhRegistry.RECOMMENDED_CHALLENGE_WINDOW` — 5760 blocks, about 24 hours. The contract enforces an absolute floor of 20 blocks regardless. ## On testing 211 tests, 10 of them fuzzed and 16 of them invariants over random sequences. Everything below runs with `forge test`, no key and no network. (`forge test --list` counts 224 functions; the run summary prints 211 because forge reports each invariant contract as a single test.) Most of them cover the part that runs in a plain EVM: ordering and scope matching in `EventScope.t.sol`; in `SettlementLedger.t.sol` what the source-chain ledger will and will not record as a payment; in `UtuhCredit.t.sol` the guards that decide whose history a line may be opened against — deployment floors, scope identity, the lender's liquidity, and the control commitment; and in `UtuhRegistry.t.sol` the guards that run before any precompile is reached, which is what `open` refuses and in what order, and the fact that an unknown claim id does not answer one thing — `seal`, `abandon` and `appendBatch` check the caller first and say `NotClaimant`, while `finalize` and `refute` check the status first and say `WrongStatus`. Neither contract's constructor touches a precompile, so both deploy locally; what cannot run locally is anything that reaches one. `_readCommitment` is `internal` rather than `private` so a test can reach it, and that is worth the widened visibility. It is the check that decides whether an address may be bound to a Creditcoin account, and reading it too permissively lets a stranger claim someone else's history — the one failure that would make every other claim in this repo meaningless. Reaching it through `proveControl` means going through `0x0FD2`, so without a way in it would be exercised only by the demo's happy path. Eight tests now cover it, including a fuzzed round-trip asserting that what `controlCommitment` tells a borrower to send is exactly what the parser accepts, and a tag bent by a single byte. ### Every wei, whatever order anyone does things in `test/RegistryInvariant.t.sol` is the suite the unit tests cannot be. Four actors open, append, seal, abandon, refute, finalize and withdraw in random order, with the clock rolled between moves — sixty-four sequences of forty-eight calls on every push — and after every step five things must still be true: the registry's balance is exactly the bonds still escrowed plus what is credited for withdrawal plus what was burned, with no other bucket; a refuted or abandoned claim holds nothing and promises nothing; what a standing claim guarantees is the burned share of what was posted and never more; members stay strictly ascending; burned only grows. The first of those is the sentence the registry cannot afford to break — a bond that leaks is a deterrent that quietly stopped deterring — and it is now checked three thousand times a run rather than once per hand-written path. Its gas is random and is excluded from the snapshot for the same reason the fuzz tests are. `test/CreditInvariant.t.sol` does the same to the credit contract with seven more: its balance is exactly `available`; `funded − withdrawn == available + Σ drawn`; `drawn <= limit` on every line; one `Active` line per subject at most, and `activeLineOf` names it; `defaultsOf` equals the count of `Defaulted` lines; a drawn line has a deadline and owes something while an undrawn one has neither; and the two watermarks only advance. `test/UtuhProperties.t.sol` drives both contracts at once, so the borrower may offer any claim that exists to `openLine`, `settle` and `cure`, and after every move it checks four properties neither suite can check alone: bonds are conserved, no line exceeds ten times the enforceable loss behind it, no refuted claim backs a line, and the watermarks only advance. It also walks every move once, so the harness cannot pass vacuously. The same four are properties in `test/medusa/UtuhProperties.sol` for the medusa fuzzer, and a 30-minute medusa 1.5.1 campaign (`medusa fuzz --config test/medusa/medusa.json --timeout 1800 --workers 3`) passed all 22 checks, the 4 properties and 18 assertion tests, over 639,295 calls and 6,391 sequences, reaching 2,730 branches. They were also checked against bugs planted on purpose in scratch copies of `src/`, never committed: medusa caught both, shrunk to 7 and 16 calls. `isUsable` without its Finalized check broke `property_noRefutedClaimBacksALine`, and counting the whole bond as burned while still paying the refuter broke `property_bondsAreConserved`. CI also refuses a push that drops line coverage under 90% or branch coverage under 70%; they read 100% and 100% on 2026-09-14, and the table below is those numbers. The floors stay where they are as regression guards, not as a description of the coverage. The branch floor was added the day it was needed. Lines had been the only gate, and lines are easy to satisfy: 97.69% of them were covered while barely half the *decisions* had ever been taken, and the missing half was almost entirely the refusals on `openLine` — the function that turns two claims into money. Ten guards, none of them with a test anywhere, including the one that refuses a clean claim holding a liquidation and the one that refuses a clean window pasted beside a longer volume history. A guard is not covered by a test that never makes it fire; a floor on lines says it is. ### The precompiles, and what a local test may and may not say about them The Attestcoin precompiles at `0x0FD2` and `0x0FD3` are Creditcoin runtime natives — `eth_getCode` returns `0x` for both: ``` $ eth_getCode 0x...0fD2 → 0x $ eth_getCode 0x...0fD3 → 0x ``` A forked EVM cannot execute them, and **a stub that answered "this proof is valid" would only ever test the stub**. That is true, and it is the reason the verification half runs against the live chain: `npm run probe` proves real Ethereum mainnet transactions through `0x0FD2` over `eth_call`, CI runs it daily, and no local test can substitute for it. For a long time that argument was also doing a second job it could not carry. Because `open` asks `0x0FD3` whether a range is attested before anything else happens, _every_ path past that line was untested locally — appending, ordering, sealing, refuting, finalizing, underwriting, drawing, settling. A hundred tests passed without one of them opening a line, and the registry read **32%** covered. `test/Lifecycle.t.sol` closes that. It substitutes exactly two answers — the Block Prover's verdict on a proof and the transaction index it reads out of the Merkle path, and the ChainInfo precompile's attestation heights — and nothing else. The bytes it feeds in are a **real Sepolia transaction**, captured from a recorded full-flow run and stored in `test/fixtures`. Everything downstream of the substituted answers is the real code on real bytes: `EvmV1Decoder` decodes the transaction, the receipt status is read, the log is matched against the scope field by field, the metric is pulled out of the log's data, the ordering key is packed, membership is binary-searched, and the money is divided. The distinction is the whole point. A stub cannot tell you whether a proof is valid. It can tell you what your contract does with a valid one, and that was the half nothing was checking. So the suite reproduces the published run's arithmetic from first principles — three settlements of 0.001 ETH, a headline limit of 12 CTC, a bond cap that cuts it to 10, and 0.000525 ETH owed back — and if any of those stops falling out of the code, a test fails on a laptop rather than a demonstration failing on a chain. The live scripts still run and still matter: `npm run e2e`, `npm run credit` and `npm run livetest` either pass on the real chain or they do not pass at all. Of the 58 errors declared under `src/`, 57 are named by a unit test as of 2026-09-13 — on 09-08 it was 21 of 52, with 21 named nowhere, because most sit behind `openLine`, which is behind `proveControl`, which is behind `0x0FD2`. The Lifecycle fixture is what made them reachable on a laptop; `test/Audit.t.sol` is what reached them. The live suite still asserts what it can on the real chain — a settled line refuses a draw, a second settlement and a default; an unopened line refuses a draw. The 58th is not reachable at all: `EventScope.TopicOutOfRange` is declared and never thrown. The range check it was written for lives in `UtuhCredit._requireTopic`, which reverts `BadSubjectTopic` and names the offending value. It stays declared rather than being deleted, because removing it changes 61 characters of the solc metadata CBOR — the executable code is identical, measured — and the contracts already verified on Blockscout were built from a source tree that has this line in it. `forge coverage --no-match-test invariant --no-match-coverage "test|script" --report summary` — the CI command, forge 1.8.0 — read this on 2026-09-14, after the mutation pass below (the two interface files are left out of the rows but counted in forge's total: they declare the precompile ABIs and hold no logic): | File | Lines | Branches | Functions | | --------------------------------- | ---------------- | -------------- | --------------- | | `src/UtuhCredit.sol` | 100.00% (238/238) | 100.00% (59/59) | 100.00% (35/35) | | `src/UtuhRegistry.sol` | 100.00% (157/157) | 100.00% (35/35) | 100.00% (21/21) | | `src/lib/EventScope.sol` | 100.00% (25/25) | 100.00% (7/7) | 100.00% (6/6) | | `src/source/SettlementLedger.sol` | 100.00% (8/8) | 100.00% (3/3) | 100.00% (2/2) | | **Total** | **100.00% (432/432)** | **100.00% (104/104)** | **100.00% (66/66)** | On 2026-09-13 it read 99.77% of lines and 98.08% of branches; the last two branches in `UtuhCredit` closed on 2026-09-14, one of them the `claimSpent` check at `src/UtuhCredit.sol:820`, which a single subject never reaches because the watermark refuses first. Before the 2026-09-13 audit pass the same command read 99.54% of lines and 75.96% of branches, with `UtuhCredit` at 67.80% and `UtuhRegistry` at 82.86%. `test/Audit.t.sol` closed the gap: every refusal in `openLine`, `draw`, `settle`, `cure`, `closeLine`, `markDefault`, `appendBatch` and `refute` now has a test that makes it fire. It read 9.6%, then 47%, then 96%, and the sentence that followed the first of those — that everything reachable without a precompile was covered — was not true when it was written. Branches were 58%, and the previous version of this paragraph said that was the honest number to look at next. Looking at it found nineteen guards with no test anywhere: ten on `openLine`, where a clean claim holding a liquidation, a clean window pasted beside a longer volume history, a stale underwriting and a double-spent claim were all refused by code nothing had ever made fire; and nine on the registry, including both arms where the Block Prover says no — the answer the entire design rests on. The one that needed more than a test was `TransactionFailedOnSource`. Inclusion is not success, and saying so needs bytes that fail: `test/fixtures` now carries a real Ethereum mainnet transaction that reverted, block 25,926,178 index 96, fetched from the same hosted Proof Builder a claimant uses. What is still uncovered is one line: `revert ClaimAlreadySpent` in `_applyRepayment`, which cannot be reached because the `settledThrough` watermark refuses any spent repayment claim first — defence in depth, and `test_aCuredRepaymentClaimCannotBeSpentAgain` documents that it is. `npm run puretest` is the half of that suite which needs neither: 71 assertions about classifiers, the payload reader, the gas model, and the difference between a prover saying "absent" and a prover saying nothing, plus 22 on the watcher's four retirement rules — 93 in all. They were written beside the live checks because that is where their callers are, and the cost was that CI never ran one of them — the whole file needed a funded wallet. It runs on every push now, in the job that holds no secrets at all, which is also the proof that it needs none. `npm run livetest` is the one that reaches furthest: 121 guards, most of them `staticCall`s that prove a revert without spending gas, plus the steps that have to be real for the later ones to mean anything. It underwrites whichever address the source chain says was busiest in its window, which is a deliberate change — it used to underwrite a wallet derived from the operator's key, an address that has never repaid a loan on Ethereum and never will, so the suite could not run standalone at all. A hardcoded borrower would only move the problem, since addresses go quiet and a fixture that rots fails the suite for reasons that have nothing to do with the registry. ## What the tools say `npm run check` is what CI runs: `forge fmt --check`, the 211 tests, `tsc --noEmit`, and Slither. Slither reports **0 findings** across 10 contracts and 97 detectors, which is only worth stating alongside what it was allowed to look for. Five detectors are off in `slither.config.json`, and none of them are off because they were inconvenient: | Detector | Why | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `naming-convention` | The ChainInfo precompile's ABI is snake_case; the interface has to match it or the calls do not resolve. Immutables are SCREAMING_CASE by house style. | | `assembly` | One block, in `_readCommitment`, reading a 32-byte word out of calldata. | | `low-level-calls` | `_pay` uses `.call{value:}` because that is the recommended way to send ether. | | `calls-loop` | Both loops are bounded — `MAX_BATCH` is 10, and the clean-claim loop runs once per class the lender configured at deployment. | | `solc-version` | Pinned at 0.8.28. | Everything else stays on, and `fail_on: medium` means a new medium-or-worse finding fails the build. The four findings that were real judgments rather than categories — two `arbitrary-send-eth` on `_pay`, and the reentrancy detectors on the three functions that call `0x0FD2` — are suppressed at the line, with the reasoning in the source next to them rather than in a config file: - `_pay` is flagged for sending to an arbitrary destination. Every call site passes `msg.sender`, and each sets its state before calling, so a payee that reenters finds the work already done. - `appendBatch`, `refute` and `proveControl` write state after calling the Block Prover. `0x0FD2` is a Substrate runtime native with no bytecode — `eth_getCode` returns `0x` — so it cannot call back into the EVM at all. One finding was not a false positive and is fixed: `SettlementLedger.settle` took a payee without checking for the zero address. A call to the zero address _succeeds_ and burns the value, so the ledger would have stood behind a `Settled` event for ether nobody received — and a lender whose `HistorySpec` leaves the counterparty unpinned would have been counting burns as proven volume. `forge lint` is the same story one toolchain over. Foundry 1.8.0 promoted seven lints from note to warning — bounded loops that revert, ok-flags returned as boolean constants, `uint64(block.number)`, events after a call to a precompile that has no bytecode — and CI's `stable` picked it up on a push that changed no Solidity. Every one of the seven is off in `foundry.toml`, each with the sentence saying what it fires on here and why that is deliberate; most are the same findings Slither already reports and the same reasoning. The sources were not touched to satisfy it, on purpose: every published contract is a full match on Sourcify against this exact tree, and a comment changes the metadata hash. CI is pinned to 1.8.0 now, because a toolchain that moves under the repository is a review that happens on its own schedule. Halmos is the third opinion and the only one that is not sampling. `npm run symbolic` proves the three properties of the ordering key over *every* input rather than 256 of them — that the height comes back out of a key, that two distinct positions cannot collide into one, and that key order is chronological order — and `npm run symbolic:deep` does the same for the money roundings. All five checks pass with no counterexample: the three key properties in under a second, the two rounding proofs in about five minutes (`check_backingIsNeverShortOfTheLimit` alone took 303 s on 2026-09-13), which is why CI runs the deep suite daily rather than on every push. The one rounding property the solver could not decide — that `backingFor` never overshoots by more than a unit — is written down as undecided in `CreditRounding.symbolic.t.sol` and left to the fuzzer, rather than quietly dropped. **Mutation testing.** Certora gambit 0.2.1 wrote 738 mutants of `UtuhRegistry`, `UtuhCredit` and `EventScope` (243, 410 and 85; one did not compile). Against the suite as it stood that morning, 692 of 737 died, 93.9%. Each of the 45 survivors was either a test that did not assert enough or a mutant no execution can tell apart from the original. The 38 of the first kind now each have a test that passes on the contract and fails on the mutant, and the score is 730 of 737, 99.1%, which is every killable mutant. The 7 left are equivalent, each with the argument in `test/MUTATION.md`: four of them exist only because two comments in `src/` describe states the deployed contract cannot reach (`docs/AUDIT.md` names both). `SOLC= WORKERS=4 bash test/mutation/run.sh ` reproduces it. CI does not run it, because it takes hours. It found one thing about the toolchain as well: under forge 1.8.0, `vm.expectRevert` followed by a `new` whose constructor reverts ends the test at that line, so three constructor tests written as several such pairs had been checking only their first case. They now deploy through an external call. solc also suggests two functions could be `pure`. They could not: both read through a `storage` pointer parameter, which the mutability checker does not track. Accepting the suggestion compiles and makes the signature a lie, so both say so in a comment. ## One thing worth knowing before you build on this The Block Prover has two `verifyAndEmit` overloads. The Proof Builder's batch endpoint returns a single continuity proof spanning the batch's block range, and it is the **array** overload that proof is shaped for: ```solidity verifyAndEmit(uint64 chainKey, uint64[] heights, bytes[] txs, MerkleProof[] proofs, ContinuityProof shared) ``` Feeding that same shared proof to the single-query overload, once per event, verifies only while every query sits in the same block. The moment one is in a later block it reverts with `Merkle root mismatch`, because the roots array is read relative to the queried height. The cap of 10 is on **queries, not transactions**. A transaction carrying three in-scope logs spends three slots even though it needs one proof. Batching by transaction count earns `heights: Value is too large for length`. Both were found by running `npm run probe` against the live testnet, which is why it exists as a script: it exercises the entire proving path through `eth_call`, so an empty wallet is enough. ## Attestcoin surface used | Piece | Where | | ------------------------------------------------ | ------------------------------------------------------------------------------- | | Batch `verifyAndEmit` on `0x0FD2` | `appendBatch` — up to 10 queries, one shared continuity proof | | Single `verifyAndEmit` on `0x0FD2` | `refute` — a refutation only ever needs one | | `calculateTxIndex` on `0x0FD2` | ordering key, taken from the proof rather than the caller | | `EvmV1Decoder` receipt + log decoding | scope matching against verified bytes | | receipt status check | inclusion is not success; a reverted transaction is still in its block | | Single `verifyAndEmit` on `0x0FD2` | `proveControl` — binding an address to an account | | `decodeCommonTxFields` | reading a control commitment's sender and calldata | | `is_height_attested` on `0x0FD3` | the gate that makes challenge windows sound | | `get_latest_attestation_height_and_hash` | underwriting staleness bound | | `get_attestation_genesis_height` | lower bound on claimable ranges | | `get_supported_chains` on `0x0FD3` | checking the chain keys this build assumes against the ones the network attests | | `get_chain_by_key` on `0x0FD3` | the EVM chain id behind a scope's key, checked against every endpoint swept | | `get_attestation_bounds` on `0x0FD3` | whether one source height can be proven yet, and the window covering it | | `find_highest_attested_before` on `0x0FD3` | the newest settled attestation — where a claim's range ends | | `find_lowest_attested_after` on `0x0FD3` | the height a not-yet-provable event becomes provable at | | `get_latest_checkpoint_height_and_hash` | how far behind the settled view is, which only checkpoints answer | | `get_checkpoint_for_height` on `0x0FD3` | confirming a reported checkpoint is one, by digest; also classifying a claim's end as checkpointed, attested only or unattested (`npm run doctor`) | | `get_attestation_height_for_digest` on `0x0FD3` | the leg that checks the attestation indexer against the chain itself | | `PrecompileChainInfoProvider` | waiting for attestation without asking a hosted service | | `RawProofBuilder` over source RPCs | proofs built locally when the hosted Proof Builder is down | | `PrecompileBlockProver` | `npm run probe` — the batch `view` twin of `verifyAndEmit`, over `eth_call` | | `utils.gas.MAX_GAS_CAP` / `gasAsPercentageOfMax` | `npm run gas` — what a call costs against a 75M block | | Ethereum mainnet as source chain (`chainKey 3`) | all demos | | Hosted Proof Builder, both published hostnames | `prover.` and `proof-gen-api.` are tried in turn before the local builder | All sixteen entry points the protocol exposes are in that table — five on the Block Prover, eleven on ChainInfo — and each is there because something needed it, which is the only reason worth having. Seven of the ChainInfo eleven were added late, when a stocktake found them unused; what they bought is below, and none of it is a call made to be counted. Grep `src/` and you find six of the sixteen: both `verifyAndEmit` overloads, `calculateTxIndex`, `is_height_attested`, `get_attestation_genesis_height` and `get_latest_attestation_height_and_hash` — the ones a contract has to ask on-chain, at the lines the table in [docs/AUDIT.md](docs/AUDIT.md) and the submission name. The other ten are asked off-chain, by the scripts, the daemon, the MCP server and the browser, through the SDK's own precompile clients: the `view` prover over `eth_call`, `RawProofBuilder`, and the eight ChainInfo methods that decide where a claim may end, when a height becomes provable, and whether an indexer's row is real. Nothing on-chain needs them; everything that builds or checks a claim does. ### What Creditcoin itself says about the source chain Three things were being assumed that the network will answer, and the answers are undocumented enough that finding them out took probing the live chain rather than reading anything. **Which chain an endpoint is really serving.** Every provider in this repository is built with ethers' `staticNetwork`, which makes its chain id an assertion nobody checks. An endpoint that quietly serves a different chain — a URL edited by hand, a gateway repointed, a testnet path on a mainnet host — returns zero in-scope logs, and zero logs is indistinguishable from a claim with nothing left out. That is a false *complete*, on the one verdict this project exists to make. So `get_chain_by_key` gives the EVM chain id the scope's key means on this Creditcoin network, every endpoint is asked `eth_chainId` before a sweep, and one that names another chain is dropped by the browser, the daemon and `npm run doctor` alike. One that does not answer at all is kept: unreachable is not wrong, and the union sweep already refuses to call a claim complete on an endpoint that errored. **Whether a height can be proven yet, and when.** `get_attestation_bounds` answers for a single height what the frontier only implies — attested or not, and the attestation points either side — and `find_lowest_attested_after` names the height that will cover it. A borrower waiting on their own repayment is now told which attestation they are waiting for rather than how far a moving edge has left to travel. `find_highest_attested_before` sets where a claim's range ends; its bound is **exclusive**, which nothing says out loud — asking it about the frontier steps back one full attestation interval, which is exactly right for a claimant and exactly wrong for anyone using it to ask whether a height is attested. **How far behind the settled view is.** Attestations land every ten source blocks and every tenth is checkpointed, so there are two frontiers and only the optimistic one is ever quoted. The console and `audit_attestors` now show the gap, and confirm the reported checkpoint really is one by handing its height back to `get_checkpoint_for_height`. ### Auditing the oracle three ways instead of one The console has always checked what Creditcoin's attestors signed against Ethereum itself. That catches attestors signing a header Ethereum does not have — and it cannot catch the attestation indexer inventing a row, because an invented row about a real block agrees with Ethereum perfectly. The `hash` the precompile returns is not the source header hash. It is the attestation's own digest: measured, at Ethereum height 25,925,380 the precompile returns `0x8786ef14…`, the indexer's `digest` field for that height returns the same `0x8786ef14…`, and Ethereum's header hash for that block is `0xd5ea7299…`. That makes `get_attestation_height_for_digest` the missing leg — the chain's own index from digest to height, which answers `exists: false` for a digest no attestation carries. Every row in the attestor pane now goes back to it, on the network that published the row. And because both Creditcoin networks attest Ethereum mainnet, the same call compares them. Read the newest attestation both have reached, and ask each for its digest there: **CC3 Testnet and Creditcoin Mainnet sign the same Ethereum block into a byte-identical digest, from attestor sets with nothing in common — five registered BLS keys against seven, zero shared.** The independence is checked rather than asserted, because two records agreeing means nothing if the same signers produced both. The comparison is taken at the lower of the two frontiers: they do not run in lockstep, and asking either about its own frontier reliably asks the other about a digest it has not indexed yet. Two SDK modules are deliberately unused, which is worth saying so it does not read as an oversight. `queryBuilder` builds ABIs for the oracle's query subsystem, and Utuh does not go through it — it calls the Block Prover precompile directly, which is a layer below. `utils.decoder` decodes EVM v1 transactions off-chain; Utuh decodes them _on_-chain through `EvmV1Decoder`, because an off-chain decode is a claim about bytes and an on-chain one is a check of them. The one thing an off-chain decode would have bought — knowing a call will succeed before paying for it — is bought more cheaply by the `eth_call` in `sendRegistryCall`. `EvmV1Decoder` is used for four of its sixteen methods, and the other twelve are not an oversight either. The five `decodeTypeSpecificFields*` and their `DecodedTransaction*` wrappers return gas prices, access lists and signatures — a claim about which events happened has no use for any of it, and decoding it would cost gas per member to reach a field nothing reads. `getLogsByEventSignature` looks closer, and is the wrong shape: it filters a receipt's logs into a new array, while a claim's identity is `(blockHeight, txIndex, logIndex)` and its proof names the index directly. Selecting by index is both cheaper and the thing that makes the key sound; matching the scope afterwards is `EventScope.matches`, which checks the emitter and all four topics rather than only the signature. Because every append and every refutation goes through `verifyAndEmit` rather than the `view` twin, Utuh's use of the oracle is visible from outside this repository: Creditcoin's own [USC Oracle dashboard](https://dashboard.cc3-testnet.creditcoin.network/transaction-verifications) lists each verification against its source-chain height and the Creditcoin block it landed in. The Sepolia log there is where the full-flow run's settlements and its repayment show up, minutes after they happen, recorded by the network rather than by us. The same indexer counts it. Its `transactionVerifieds` table is every `TransactionVerified` event `0x0FD2` has ever emitted, and on 2026-09-14 it held 142,712 rows for CC3 Testnet (on the 13th, 139,838 — 132,507 for chain key 3 and 7,331 for key 1 when read together; it grows with every attestation); 248 of them, 0.17%, were verified for Utuh's contracts across 376 transactions — none through the mainnet-sourced credit, which `npm run credit` never writes to. The 248 decompose exactly: 212 registry members, 34 refutation proofs, 2 control bindings. `npm run judge` reads the table (`offchain/lib/attestations.ts`) as an independent witness for the tally; the spot check is `appendBatch` `0x5ccfb529…25fb25`, three rows there and three members. ## Known limits - **Nobody outside this project has used it yet.** Every transaction into the four deployed contracts, 374 on 2026-09-14, was sent from one of four keys this project holds: the deployer, which is also the refuter of claims 5 and 20; the browser-test borrower; the claimant `npm run bait` uses; and the test borrower. Every claim and every refutation on both registries is ours. The mechanism is exercised end to end on the live chain, but no outside watcher, claimant or lender has used it. - Claim members are held as a storage array so refutation is a binary search the chain runs itself, with no witness a claimant could withhold. What caps a claim is not that array, though — measured, the cost follows the _bytes of the transactions being proven_ at about twice their calldata gas, and a ten-thousand-event claim is about thirty-three full blocks. The replacement — an incremental Merkle root per claim, with the refuter supplying an adjacency proof of the two members bracketing the gap — is built and tested on branch `merkle-claims` and is **not** on master or deployed, because it changes storage and a redeploy renumbers every claim this file links. CI runs the branch's 165 tests on every push to master, weekly and on request (`.github/workflows/merkle.yml`). [docs/ROADMAP.md](docs/ROADMAP.md) says why and when. - Writability is still in third-party audit and not on testnet, so Utuh is read-side only. A default is recorded on Creditcoin; enforcing consequences back on Ethereum waits for outbound messaging. - Completeness here is economic, not cryptographic. A bond makes lying expensive; it does not make it impossible. - **An endpoint conflict is reported, not resolved.** When two endpoints describe the same event position differently, the sweep says so and keeps the first answer. Nothing here can tell which of them is lying — only that one of them is. It does not affect what a claim records, because that comes from bytes the Block Prover verified, but it is a signal an operator has to act on themselves. - **The independent proof path is twenty to thirty times slower than the hosted one, and a paragraph here said otherwise for three days.** The SDK's builder fetched a block with all its transactions and then asked for each of them again by hash, one at a time with a sleep between; the block provider in `offchain/lib/proofs.ts` now answers the second ask from memory, and 127 round trips became zero. On 2026-09-10 this section then reported 0.8 s local against 0.9 s hosted. That measurement was wrong: `npm run provers` was reaching the hosted service under its second hostname and calling it local, because the SDK's `withDefaults` wires the alternate hosted URL in even when the primary is a dead port. Fixed on 2026-09-13, the real local builder produces byte-identical proofs in 20.0 s on Sepolia (hosted 0.9 s, 23×) and 29.9 s on mainnet (35×). What remains is the endpoint's own latency on one block-with-receipts call and the continuity blocks. Size a challenge window for the slow path, and measure it for your own endpoints with `npm run provers` first. `npm run provers -- --sample N` (294cb04) takes the newest N members already in the published claims, proves each with both builders, compares every field (the endpoint digest, every continuity root, height, transaction bytes, Merkle root, every sibling, the log index), and exits 1 on any byte of difference or on a member either builder could not prove within `PROVERS_DEADLINE_MS` (20 minutes by default). Its two measured members, one per source chain, show the slow path is minutes on a busy block, not seconds. Mainnet claim 71's member 25969575/195/5 came back identical across 26 continuity roots and 8 siblings, in 4.3 s hosted and 350.2 s local (`--sample 1`, exit 0, 2026-09-14). Sepolia claim 13's member 11582696/107/0 came back identical in 5.0 s hosted and 400.1 s local. The SDK fetches all 100 continuity blocks whole, with receipts and a hardcoded 500 ms pause, and Sepolia endpoints refuse some receipts as too large. The nightly `live precompile` job in `ci.yml` runs `--sample 2` (93fe9de) on the daily schedule and on manual dispatch. That proves the newest mainnet member and then a Sepolia one, so both source chains are compared byte for byte every night. The step timeout is 45 minutes, two 20-minute deadlines plus five, so a hang fails the job instead of stalling it. - A claimant watching the mempool can front-run an incoming refutation with their own, keeping half the bond and denying the watcher their reward. This is priced rather than prevented: the guarantee is `enforceableLoss`, not the bond. What it does not fix is the watcher's incentive — refuting pays only when the claimant fails to defend, so watching is worth less than the reward suggests. - **A page open in a tab is not a daemon, and an hourly sweep is not one either.** The console makes refuting available to anyone with a browser, and the scheduled dry sweep makes sure a short claim is at least _noticed_ within the hour — but noticing is not refuting, and a window at the 20-block floor closes in five minutes. A watcher that acts needs a key and a process that stays up; what the console and the workflow remove is the excuse that watching was hard to start. What still has to be true is that refuting pays enough for someone to bother, and the front-running note above is why that is weaker than the reward suggests. - **The union is safe for a watcher and was not for a claimant.** A watcher meeting a candidate it cannot prove shrugs and moves on; a claimant has to append everything it swept, so one unprovable candidate aborted the whole claim — and since the union deliberately trusts no endpoint, a single misbehaving one could inject a phantom event and stop every honest claimant from building anything. The Block Prover decides what exists: an event nobody can prove cannot be appended and cannot be refuted with either, so dropping it is not an omission. Dropping is only safe on a _definite_ answer, though, and the SDK returns "no such transaction" and "I could not reach the prover" in the same shape — `success: false` with a message. They are told apart by what each prover said, in its own dialect, and only when every prover consulted agrees; see [The prover was the last single point of failure](#the-prover-was-the-last-single-point-of-failure). Anything short of that aborts the claim instead of dropping, because an unbuilt claim costs nothing and an incomplete one costs the bond. Even a definite answer only counts once the block is attested, which is checked against `0x0FD3` rather than assumed. - **A watcher that was switched off does not get to skip what it missed.** It used to start `WATCH_LOOKBACK` blocks behind the head every time — 5,000 CC3 blocks, about fifteen hours, against a recommended challenge window of 5,760, a full day. Down for longer than that, it came back and never saw the claims sealed in the gap: not inconclusive, not queued, simply never discovered, which is a hole that the retirement rule below says nothing about. It now records how far it has read and what is still unresolved, per registry, and resumes from there. The mark advances only after a sweep that finished. - **A watcher only retires a claim on a verdict that cannot change.** Refuted, settled by someone else, proven complete by two or more endpoints, or past its window. Anything short of that — every endpoint down, an RPC hiccup mid-sweep, a refutation lost to a front-run — leaves the claim queued for the next pass. An earlier version marked claims checked _before_ inspecting them, so a transient outage during the minute a claim sealed made that claim invisible for good; and an unguarded `await` meant one failed refutation killed the process. A watcher that dies on its first lost race is not a watcher. Claims are also worked soonest-deadline-first, because one with three blocks left cannot wait behind one with five thousand. - **An endpoint's answer is not automatically an answer to the question.** A sweep now discards anything that does not match the filter it sent — wrong contract, wrong signature, wrong pinned topic, or a block outside the range asked for. That last one matters most: a log claiming a height above the attestation frontier becomes a candidate the prover cannot prove and the chain cannot yet rule absent, which by the drop rule aborts the whole claim. One hostile endpoint would otherwise stop anyone sealing anything. - **Reconciling log indices needs a capability not every endpoint has.** `eth_getLogs` numbers logs across the block; the decoder on Creditcoin numbers them within their transaction, and bridging the two needs every log that transaction emitted. There are two ways to ask and endpoints differ on which they answer — publicnode serves a filtered historical `eth_getLogs` while refusing both the receipt and an unfiltered block query over the same blocks; tenderly answers the block query. Both are tried, and an endpoint that can do neither fails loudly rather than guessing an index. - **A watcher's silence is only as good as its sources.** Deciding a claim is complete means trusting some node to have mentioned every log — the protocol's own problem, one layer down. Voting across endpoints would not fix it, since they can be wrong together or captured. What makes it tractable is that a refutation verifies itself: if _any_ endpoint surfaces an event the claim omits, the Block Prover settles whether it is real, and a fabricated one just fails to prove. So `watch.ts` sweeps every endpoint it has and takes the union rather than a vote, and no endpoint has to be trusted for the positive case. The negative stays soft, and is reported that way — "no gap found across 3 independent endpoints", or "inconclusive, 1 saw everything". The count is of endpoints that saw _everything the union holds_, not of endpoints that returned: one that answered with less than the others is behind or pruned, and its not mentioning a gap is not a second opinion. Before that distinction, publicnode's empty answer counted, and a claim could seal on one real source while the log said two. Three consecutive runs of the daemon's own sweep over the four sources, same 300 blocks, 60,000 deep: `publicnode=10 tenderly=10 0xrpc=10 ethpandaops=10`, then `publicnode=0 …` twice — answered 4, vouched 3. Public RPCs tested here **do not** always error rather than truncate. That was written here after testing simple queries and it was wrong. Measured on Sepolia, same query, same moment, WETH transfers over 200 blocks: | depth below head | publicnode | tenderly | | ---------------- | ---------- | -------- | | 100 | 27 | 27 | | 5,000 | 31 | 31 | | 20,000 | 24 | 24 | | 60,000 | **0** | 22 | An archive cutoff served as an empty result rather than an error, and intermittently — the same endpoint agreed at that depth twenty minutes later. Later still the intermittence resolved into a shape: the same 300-block query at the same depth, four times in a row, answered `0 8 8 0` — publicnode is a pool, and some of the nodes behind it are pruned. Which one you get is luck. So the Sepolia table now has four sources rather than two: every free Sepolia endpoint that could be found was asked the same question four times and then a 2,000-block window 300,000 blocks deep, and two more passed — `0xrpc.io` and the Ethereum Foundation's `ethpandaops` — while onfinality rate-limited after one call, thirdweb refused the deep window, 1rpc caps `eth_getLogs`, and the rest refused, timed out, or wanted a key. `0xrpc.io` had one more lesson in it: its front door answers any request without a `User-Agent` with a bare nginx 404, and ethers sends none, so the daemon saw it dead while a browser and curl saw it fine. Every request now says who is asking. Underwriting sweeps two hundred thousand blocks, so an endpoint like this reports an empty history for most of the range and a claimant trusting it alone seals an empty claim and loses the bond. This is why the union exists, why sealing needs two sources, and why `npm run doctor` reports a pass as "this time" rather than as a certificate. It now asks each endpoint the same question three times, because one answer cannot tell a node from a pool, and reports what it hears — `POOL publicnode answered 8, 0, 8 to the same question` — as a third verdict beside ok and FAIL: useful to the union, not one of the two a claim can be sealed on. The union absorbs any subset of endpoints being wrong-empty. Nothing here detects all of them being wrong together. - **The mechanism punishes scale.** One omission voids the whole claim and there is no amend path — by design, since amending after being caught would defeat it. So a five-thousand-member claim has five thousand chances to be fatally wrong. Most of that risk was self-inflicted and is now gone: claimants sweep the union of every endpoint (`sweepForClaim`) rather than betting a bond on one node having mentioned every log, and a single-source sweep warns before it seals. What remains is inherent — settling cheaply and building fragilely are one property seen from two sides — and it still argues for shorter claims than the guarantee deserves. - **A claim covers one event signature from one contract.** `EventScope` cannot match on non-indexed data either, which rules out protocols that keep the subject out of their topics. Composition is now handled a level up: a lender configures as many adverse-event classes as it cares about and `openLine` demands one finalized, empty claim for each, capping exposure at the _weakest_ of them. A spotless Aave record no longer says anything about Compound unless the lender asked about Compound. - **Honest claims pay watchers nothing, and there is no fix for that here.** A refutation earns only when someone lied; if the deterrent works, almost nobody does, and a watcher spends RPC quota and gas on claims that turn out fine. Paying watchers out of the burn pool was the obvious patch and it does not work: any bonus large enough to matter is also recoverable by a claimant refuting their own claim from a second address, which is the same front-running that made `enforceableLoss` necessary. Funding a public good is not a problem this layer can solve, and a token mechanism that pretends otherwise would be worse than the honest gap. - **The watcher of last resort is the party with money in it.** The limit above is about paying strangers and it stands. What it leaves out is the one participant who does not have to be paid. `UtuhCredit.openLine` reaches a claim only through `UtuhRegistry.isUsable`, which returns true for `Status.Finalized` and nothing else — the challenge window must have closed before anything is lent against it. So that window *is* the lender's diligence window, and the money a false clean claim takes is the lender's own. A lender who does not sweep is trusting strangers to have swept, for a bounty this section has just finished calling unreliable. Sweeping costs one union pass and, when it finds something, one proof; being wrong costs the line. Half the bond is then a rebate on diligence that had to happen anyway rather than a wage that has to clear — which is the whole reason the console needs no backend and `npx utuh-mcp` exists at all: the lender's watching should cost a browser tab or an agent, not a team. This makes watching rational for lenders. It does not make it funded, and the difference is worth stating rather than blurring: a claim nobody intends to lend against still finalizes with nobody looking. For credit that is the right shape, because the claims that matter are exactly the ones somebody is about to underwrite. For a general-purpose fact registry built on this layer it would not be, and such a registry would have to find its own reason for someone to watch. That is the boundary of what the bond buys. - **A finalized claim is not reserved by the lender that relies on it.** The registry's `isUsable` is stateless, and `claimSpent` and `underwrittenThrough` belong to one `UtuhCredit` deployment, so the same volume-and-clean pair opens a full line at every lender that accepts it. Each lender's cap holds for its own line — `BOND_MULTIPLE` times the enforceable loss — but the burned half of the bond is one amount, so aggregate exposure across N lenders on one bond is N times the cap while a liar loses the burn once. A registry-level reservation closes it and is an ABI change, so it is on the roadmap rather than in the deployed registries; a test pins the behaviour so nobody rediscovers it (`test_oneClaimPairUnderwritesALineAtEveryLender`). - **The lender's repayment window is not checked against the registry's challenge floor.** A repayment claim needs at least `MIN_CHALLENGE_WINDOW` blocks after sealing before it can be finalized, so a lender that deploys with `repayWindowBlocks` below that floor plus the time to build a claim has a line nobody can repay in time. It is the lender's own choice and is readable on-chain before any draw; the published policy is 5760 blocks against a floor of 25. - Binding an address costs the borrower one source-chain transaction. That is a real onboarding step, and there is no way around it that does not reintroduce the hole it closes. `npm run credit` therefore stops at `SubjectNotControlled` when pointed at a stranger's history — the refusal is the demonstration. ## License MIT. See [LICENSE](LICENSE); every Solidity file carries the matching SPDX header. # docs/AUDIT.md # Audit package What an external reviewer needs on day one, written down so the engagement starts at the code rather than at a call. This is the part of "an external audit" that the project can produce on its own; the report is the part it cannot. ## Scope | Contract | Lines | Role | Holds value | | --- | --- | --- | --- | | `src/UtuhRegistry.sol` | 532 | bonded completeness claims; the only contract that calls `0x0FD2` | yes — bonds, refuter rewards, `burned` | | `src/UtuhCredit.sol` | 890 | credit lines underwritten on registry claims | yes — lender funding, draws, repayments | | `src/lib/EventScope.sol` | 100 | which source-chain events a claim covers, and the ordering key | no | | `src/source/SettlementLedger.sol` (Sepolia) | 54 | the source-chain side of a repayment | transient — forwards in the same call | | `src/interfaces/IBlockProver.sol`, `IChainInfo.sol` | — | the precompiles at `0x0FD2` / `0x0FD3` | — | Out of scope: `offchain/`, `web/`, the MCP server. They build and submit; they cannot make the contracts accept something the contracts would refuse. A finding there is a finding about what a claimant or watcher *can be tricked into doing*, which is real but is a different engagement. Compiler `0.8.28`, Foundry pinned to `1.8.0` in every workflow. Every published address is a full match on Sourcify against this exact tree, so a reviewer reads what is deployed. ## Trust model - **`0x0FD2` and `0x0FD3` are trusted.** They are Substrate runtime natives with no bytecode; the contracts assume a verified proof means the transaction is in an attested source block. The attestor set (4 active, quorum 3 on testnet) is the protocol's trust root, not this project's. - **No administrator.** Neither contract has an owner, a role, an upgrade path or a pause. Grep `onlyOwner|Ownable|owner|onlyRole|AccessControl` across `src/` — empty. Every state transition is reachable by an unprivileged caller who satisfies the guard. - **Claimants are adversarial.** They choose what to submit and are assumed to omit whatever hurts them. The design accepts this and bonds it. - **Refuters are adversarial too.** A refutation has to prove an *in-scope* event the claim omits; the contract re-runs scope matching and `value()` on the proven log so a refuter cannot slash an honest claimant with an event they could not have appended (`refute`, the `c.scope.value(log)` line and its comment). - **Source-chain endpoints are untrusted and can lie by omission.** That is the problem the layer exists for, one level down, and is a known limit rather than a finding. ## Invariants Enforced by `test/RegistryInvariant.t.sol` over random sequences of four actors, and by the symbolic suites over every input: | Invariant | Where | | --- | --- | | every wei that entered is either a live bond, withdrawable, or `burned` | `invariant_everyWeiIsAccountedFor` | | a refuted claim holds nothing | `invariant_aRefutedClaimHoldsNothing` | | `enforceableLoss` equals the share that burns, never the bond | `invariant_enforceableLossIsTheBurnedShare` | | members of a claim are strictly ascending by key | `invariant_membersStayOrdered` | | `burned` never decreases | `invariant_burnedOnlyGrows` | | the credit contract's balance equals `available`; `funded − withdrawn == available + Σ drawn` | `test/CreditInvariant.t.sol` | | `drawn <= limit` on every line; a drawn line has a deadline and owes something, an undrawn one has neither | `test/CreditInvariant.t.sol` | | at most one `Active` line per subject, and `activeLineOf` names it; `defaultsOf` equals the count of `Defaulted` lines | `test/CreditInvariant.t.sol` | | `underwrittenThrough` and `settledThrough` only advance | `test/CreditInvariant.t.sol` | | ordering key is injective and chronological over all `(height, txIndex, logIndex)` | `EventScopeKey.symbolic.t.sol`, halmos | | backing is never short of the limit; every draw owes something | `CreditRounding.symbolic.t.sol`, halmos | | with both contracts driven at once: bonds are conserved, no line exceeds ten times the enforceable loss behind it, no refuted claim backs a line, the watermarks only advance | `test/UtuhProperties.t.sol` (forge invariants); the same four as medusa properties in `test/medusa/UtuhProperties.sol` | Properties that are *not* invariants and a reviewer should not expect: completeness of a finalized claim (economic, not cryptographic — see Known limits), and refuter income (front-runnable by the claimant, priced rather than prevented). ## Where to look first Ranked by what a bug there would cost. 1. **`UtuhRegistry.refute`** — the only path that moves a bond to a stranger. Check that `_verifyOne` cannot be satisfied by a proof for a different scope, a different chain key, a block outside `[fromBlock, toBlock]`, or a reverted transaction; that `_contains` cannot be made to return false for a present key; that `_pay` cannot reenter into a second reward. 2. **`UtuhRegistry.appendBatch`** — where members enter. Check that the ordering guard (`k <= lastKey` reverts) cannot be bypassed across batches, that `aggregate` cannot be inflated by a log the scope should reject, and that the batch cap counts queries not transactions. 3. **`UtuhCredit.openLine`** — where two claims become money. Ten guards, and since 2026-09-13 every refusal in `openLine`, `draw`, `settle`/`cure`, `closeLine`, `markDefault`, `appendBatch` and `refute` has a test that makes it fire (`test/Audit.t.sol`); the CI branch floor of 70% is a regression guard, not the coverage. Check `_requireFreshHistory` (one stretch of history, one line), `_requireScope` (both claims about the same subject and range), `_checkClean` (the cap is the *weakest* clean claim), and `_spend` (a claim funds one line). 4. **`UtuhCredit.markDefault` / `cure` / `_requireNotInDefault`** — default on silence, cured late on the original terms, peers' defaults honoured by read. Check that a cure cannot be satisfied by a repayment proof for someone else's line, and that a peer contract returning garbage cannot brick `openLine` for everyone. The honest answer to the last one: a peer whose `defaultsOf` reverts *does* block every `openLine` at the lender that named it (`test_aPeerThatRevertsBlocksEveryLine`). Peers are immutable and a `UtuhCredit` peer's getter cannot revert, so the mitigation is naming only `UtuhCredit` deployments — which the constructor's code-length check does not enforce. 5. **`proveControl`** — binding an address to an account from verified calldata. Check that the commitment cannot be replayed for a different account or chain. ## What is already known and is not a finding `README.md` § Known limits, in full. The ones a reviewer will otherwise report: - Completeness is economic, not cryptographic. - A claimant can front-run a refutation from a second address and keep half; `enforceableLoss` is the guarantee for exactly that reason. - Honest claims pay watchers nothing; the lender is the watcher of last resort. - Claim size is bounded by the storage array; the incremental-Merkle replacement is specified in `ROADMAP.md`. - Writability is not live; a default is recorded, not enforced on Ethereum. - **A finalized claim is not reserved by the lender that relies on it.** The registry's `isUsable` is stateless and `claimSpent` / `underwrittenThrough` are per `UtuhCredit` deployment (`src/UtuhCredit.sol:188`, `:211`), so one volume-and-clean pair opens a full line at every lender that accepts it. Each lender's cap holds for its own line; nothing bounds the sum, while a liar loses the burned half of one bond once. Pinned by `test_oneClaimPairUnderwritesALineAtEveryLender`. A registry-level reservation would fix it and is an ABI change — `ROADMAP.md`. - The constructor (`src/UtuhCredit.sol:343`) does not check `repayWindowBlocks` against the registry's `MIN_CHALLENGE_WINDOW`; a lender that sets it below the floor plus the time to build a claim has deployed a line nobody can repay in time. Lender-chosen, visible on-chain before any draw; the deployed policy is 5760 against a floor of 25. Pinned by `test_aRepayWindowShorterThanTheChallengeFloorCannotBeMet`. - `draw` (`src/UtuhCredit.sol:712`) checks the limit and the slot, not the deadline: an overdue `Active` line nobody has marked can still be drawn up to its limit. Exposure stays bounded by the limit. - **Two comments in `src/` are stale. Neither is a bug.** `src/UtuhCredit.sol:791-792` says a defaulted line still holds the subject's one slot when `cure` runs, but `markDefault` already gave the slot back (`:873`). The `@dev` on `defaultsOf` (`:234-235`) describes a borrower with two defaulted lines, which one lender cannot reach, because no line opens while a default stands. Both stay because `src/` is frozen: a comment changes the metadata hash, and every published address is a full Sourcify match against this tree. They are exactly why four of the seven equivalent mutants in `test/MUTATION.md` are equivalent. Anything that makes one of these *worse than described* is a finding. Verified sound and pinned by tests on the same day, so a reviewer need not re-derive them: reentrancy on every CTC path (`refute`, `withdraw`, `abandon`, `draw`) pays a reentrant caller once; the window boundaries are exact (`refute` allowed and `finalize` refused at `challengeUntil`, reversed one block later; the same for `settle` and `markDefault` at `dueBlock`); the ordering guard refuses the same key twice inside one batch; the binary search agrees with a linear scan under fuzzing, lower half included; the chain key is part of scope identity, so a Sepolia claim cannot underwrite a mainnet spec; an unsupported transaction type and a reverted source transaction are refused in both `appendBatch` and `proveControl`. There are no `unchecked` blocks in `src/`. ## What the tools already say `npm run check`: Slither at 0 findings across 10 contracts and 97 detectors, with five detectors off and four line-level suppressions each explained beside the code; `forge lint`; 211 Foundry tests (10 fuzzed, 16 invariants — 5 on the registry, 7 on the credit contract, 4 across both in `test/UtuhProperties.t.sol`); halmos over the ordering key and the roundings, 5 of 5 checks passing (the deep rounding proof takes about five minutes). Line and branch coverage over `src/` are 100% (432/432 and 104/104) on 2026-09-14. Mutation testing (Certora gambit 0.2.1) kills 730 of 737 mutants, 99.1%; the other 7 are equivalent, each argued in `test/MUTATION.md`, and a reviewer who can kill one has a finding. A 30-minute medusa campaign passed its 22 checks over 639,295 calls and caught both bugs planted in scratch copies of `src/`. `README.md` § What the tools say lists every suppression and why. A reviewer disagreeing with a suppression is a finding. ## Reproduction ``` git clone https://github.com/PugarHuda/utuh && cd utuh npm ci && forge build forge test # 211 (`forge test --list` counts 224 functions; the summary counts each invariant contract once), no network, no key npm run puretest # 93 assertions on the classifiers, payload reader and watcher rules, no key npm run judge # every deployed claim measured live, no key SOLC= WORKERS=4 bash test/mutation/run.sh # 738 gambit mutants, hours, not in CI npm run livetest # the full live suite against CC3 — needs a funded testnet key ``` Deployed addresses, verified: `README.md` § Deployed on CC3 Testnet. ## Reporting `SECURITY.md`. Private first; credit in the fix. # docs/COMPLETENESS.md # The completeness hole, in code that already exists A diagnostic, not an accusation. Every contract discussed here is correct about what it claims; the point is what none of them can claim, because the protocol underneath cannot express it. If you are building a fact registry, a credit score, a reputation layer or a lending market on the Attestcoin Protocol, this document is three questions to ask your own contract, and one worked example from code neither of us wrote. --- ## The three questions **1. Who chooses which proofs arrive?** The Block Prover verifies a transaction that happened. It has nothing to say about a transaction that was never submitted. If the party who benefits from a fact is also the party who submits it, then your contract sees a filtered view and cannot tell a filtered view from a complete one — every proof in it verifies. **2. Does any sentence in your product have the shape "this address has never …"?** _Never liquidated. No defaults. Clean record. No adverse events._ These are statements about events that do not exist. An inclusion proof can only speak about events that do. If a number in your product is computed as though such a sentence were established, the number inherits the gap whether or not the code mentions it. **3. When a negative fact has to be settled, what settles it?** This is the question that finds the hole fastest, because the answer is usually visible in one function signature. If it is an owner key, a multisig, an oracle address, or an off-chain job with a private key, then the protocol proved everything else in your system and a person decided that one. That is not automatically wrong. It is worth knowing that it is what happened. --- ## A worked example: Creditcoin's own loan flow `gluwa/attestcoin-protocol-examples`, commit `6668487ad07f` (2026-09-02), `loan/contracts/sol/ASCLoanManager.sol` — the reference cross-chain loan the docs point newcomers at. It is a good example, which is exactly why it is the useful one. Every positive fact in it is cryptographic. `_noteLoanRepayment` (line 180) accepts a repayment only through a Block Prover proof, checks the emitting contract against a registered address, checks `topics[0]` against the event signature, and decodes the amount out of verified bytes. A borrower cannot invent a repayment. The comment above `_processRepayLogs` says why the emitter check is there, in the authors' own words: without it "anyone could deploy a contract that emits a LoanRepaid event with an arbitrary loanId/amount and prove it to fraudulently repay loans." That is careful work. Now the one adverse outcome the flow has. A borrower who does not repay: ```solidity function markLoanAsExpired(uint256 loanId) external onlyOwner { ``` Line 208. The only bad thing that can happen to a lender in the canonical example is settled by an owner key. **This is not a flaw in the example.** It is the protocol's shape showing through. Repayment is a transaction that exists and can be proven. Default is the absence of one, and there was nothing in the protocol to resolve it with, so the authors resolved it the only way that was available. Any contract that needed the same sentence in 2026 reached the same place. A second, softer observation from the same file. `_processRepayLogs` (line 274) takes `repayLogs[0]` and moves on: > // For this demonstration we only process the first repay log found within a transaction. > // We only expect a single repay log to exist per transaction anyways That is a statement about a **set** — that this transaction holds one such log and not two — asserted rather than proven, and the authors flag it themselves as a demonstration simplification. It is a smaller cousin of the same problem one level down: not "which transactions were submitted" but "which logs within one were looked at". Per-proof binding of that kind is a real and separate concern, and it is not what this document is about. --- ## What Utuh puts in that place `UtuhCredit` has the same adverse outcome and no owner: ```solidity /// @dev No proof is required and none exists to give. The contract is not asserting that a /// payment was missed — it is recording that the borrower, who alone could have proven /// otherwise, did not. function markDefault(uint256 lineId) external { ``` `src/UtuhCredit.sol:862`. Anyone may call it; nobody has to. An overdue line that nobody marked is still `Active`, and `Active` already blocks the next line, so the guard holds even when no one volunteers the gas. Grep either contract in this repository for `onlyOwner`, `Ownable`, `owner` or `onlyRole` and the result is empty — there is no administrator in the system at all. That handles a negative fact the borrower alone could have refuted. The harder case is a negative fact about a stranger's whole history — _this address has never been liquidated on Aave_ — and that is what `UtuhRegistry` is for. A claimant bonds the assertion that a set of in-scope events is complete; every member is verified by the Block Prover on the way in, so the set cannot be padded; and anyone who proves one in-scope event the claim omits takes half the bond and voids the claim. Presence stays cryptographic. Absence becomes economic. It is worth being exact about what that buys, because the distinction is load-bearing: **completeness here is economic, not cryptographic.** A bond makes lying expensive. It does not make it impossible. --- ## Closing it in your own contract Your contract holds no proofs and never calls `0x0FD2`. It reads one boolean: ```solidity if (!REGISTRY.isUsable(claimId, exposure)) revert NotUnderwritten(); ``` `isUsable` is true only for a claim that reached `Status.Finalized` — its challenge window closed with nobody breaking it — and whose `enforceableLoss` is at least the exposure you are about to take. Sizing against `enforceableLoss` rather than the posted bond is the part most integrations get wrong on the first read; [INTEGRATING.md](INTEGRATING.md) is the whole thirty-line version, with a working consumer compiled and tested in `test/Consumer.t.sol`. Two things that document will tell you and this one should not bury: - **You choose the scope, and the scope is the claim.** A claim covers one event signature from one contract. A spotless Aave record says nothing about Compound unless you asked about Compound. - **You are the watcher of last resort.** A line opens only on a finalized claim, so the challenge window is your diligence window and the loss from a false clean claim is yours. Half the bond is a rebate on work you had to do, not a wage that has to clear. The console at sweeps and refutes from a browser with no backend, and `npx utuh-mcp` puts the same role behind the Model Context Protocol, so holding it costs a tab or an agent rather than a team. --- ## If you disagree The claim in this document is falsifiable and the addresses are public. If your registry closes the gap some other way, that is a better answer than this one and worth writing down. If it does not and you would rather it did, the integration is a boolean and an interface. Two live examples to read rather than take on trust — the landing page at reads both from Creditcoin as it draws, and each link opens the console on the claim, verdict first: - A claim sealed one event short, found and broken from a browser: - A false "never liquidated" claim over 216,000 blocks of Ethereum mainnet, refuted by one liquidation proof: ![Claim 5 in the console after a sweep from the browser: three members verified by the Block Prover, four Sepolia endpoints that each answered 4 in-scope events, and the verdict INCOMPLETE: 1 event(s) the claim does not contain](img/console-claim-5-sweep.png) # docs/INTEGRATING.md # Using UtuhRegistry from your own contract Utuh is two things, and this document is about the first one. `UtuhCredit` is an application. `UtuhRegistry` is infrastructure: it answers one question, for any contract on Creditcoin, about any class of source-chain event. > Was a bonded assertion that this set of events is **complete** left standing — and how much was > at stake while anyone could have broken it? The Attestcoin Protocol already proves that a transaction happened. What it cannot prove is that a set is complete, because whoever submits proofs chooses which proofs to submit. If your contract needs a sentence of the form _"this address has never …"_, no number of inclusion proofs will get you there, and this registry is one way to get the rest of the way. You do not need to understand Merkle proofs, continuity proofs, or the precompiles to consume it. Your contract holds no proofs and never calls `0x0FD2`. --- ## The whole integration There are two consumers to copy. One is deployed: [`examples/completeness-gate`](../examples/completeness-gate/README.md) is `NeverLiquidatedGate`, [`0xcA6228C30607F26253Fffc2A4013a801DEEB5D09`](https://creditcoin-testnet.blockscout.com/address/0xcA6228C30607F26253Fffc2A4013a801DEEB5D09?tab=contract) on CC3 Testnet, verified on Blockscout. It was installed exactly as [Installing it](#installing-it) below says, and it grants an allowance only on a Finalized, unrefuted claim that an address was never liquidated on Aave V3 over at least 216,000 mainnet blocks, and only to the account that address proved it controls. On 2026-09-14 it [granted 0.5 CTC on claim 72](https://creditcoin-testnet.blockscout.com/tx/0x82fe073a1de45d11e644ec1147630303851d87253a3c276ced6ec9d151e01a53) and [reverted `NotUsable` on refuted claim 20](https://creditcoin-testnet.blockscout.com/tx/0x89561d2e9b9bd96cc4df97010d78b0f8723fb6d1cea782463a0af3ac09ba46b9); an `eth_call` on claim 18 answers `NotController`, and one on claim 69 answers `WrongScope`. It has 20 tests against a mocked registry and 6 forked from CC3. This project wrote and deployed it, and the key that was granted is this project's test borrower, so it shows the integration working. It is not an outside user. [`examples/ts-read`](../examples/ts-read/README.md) is the same read from a frontend, in about forty lines of ethers. The other, `test/Consumer.t.sol`, contains a working consumer called `Gate`, compiled and tested in this repository so that nothing here is a claim about code that does not exist. It is about thirty lines. Three of them matter: ```solidity UtuhRegistry.Claim memory c = REGISTRY.claim(claimId); // 1. Is this claim about the subject you are asking about? if (c.scope.topics[0] != bytes32(uint256(uint160(subject)))) revert WrongSubject(subject); // 2. Is the set empty — that is, did nothing adverse happen? if (REGISTRY.memberCount(claimId) != 0) revert NotClean(...); // 3. Was it backed by at least what you are about to risk? if (!REGISTRY.isUsable(claimId, exposure)) revert NotBackedEnough(claimId, exposure); ``` ### 1. Pin the scope A claim carries the `EventScope.Scope` it was opened with: chain key, emitting contract, event signature, up to three indexed topics, and how each matching event is counted. Every field is stored, so you can check it. Checking the subject topic is the minimum. In most integrations you should rebuild the entire scope you expect and compare identities, the way `UtuhCredit.expectedScope` does: ```solidity bytes32 want = EventScope.id(myExpectedScope(subject)); if (EventScope.id(c.scope) != want) revert ScopeMismatch(want, EventScope.id(c.scope)); ``` Otherwise a claimant can hand you a perfectly valid, perfectly finalized claim about a _different_ contract's events, or a different address, and every check below it will pass. ### 2. Decide what the set means - **Empty set** — "nothing of this kind happened in this range". `memberCount(claimId) == 0`. This is the assertion an inclusion proof can never make, and the reason the registry exists. - **Non-empty set** — `claim.aggregate` is the sum of the scope's metric over its members. Every member was verified by the Block Prover on the way in, so the aggregate is a **floor** on what really happened, never a ceiling on what was asserted. Count members rather than reading the aggregate when you mean "nothing happened". A `DATA_WORD` scope over an adverse event that happened to carry a zero amount would sum to nothing while the event sat in the set. ![The console on claim 20 of the mainnet-sourced registry: an empty set asserted over 216,000 blocks of Ethereum mainnet, refuted by one proof of an in-scope event at source block 25640810 — what a consumer reads as memberCount 0 and a status that is not Finalized](img/console-mainnet-20.png) ### 3. Size it against `enforceableLoss`, not the bond This is the step consumers get wrong. `enforceableLoss(claimId)` is what a false claim costs its author _no matter what they do about it_. It is not the bond. A claimant who sees a refutation coming can send their own from a second address and take the refuter's share back; the part they cannot recover is the part that is burned. `isUsable(claimId, exposure)` is `Finalized && enforceableLoss >= exposure`. Only you know what you are about to risk, so only you can size it — the registry cannot do it for you. If your exposure is larger than any single claim's enforceable loss, that is the registry telling you the truth: nobody has staked enough for you to rely on this. Ask for a larger bond, or lend less. --- ## What you also have to decide **How long a window you will accept.** Each claimant picks their own challenge window above the registry's floor. A claim exposed for 20 blocks is worth much less than one exposed for a day, at the same bond. Read `claim.challengeWindow` and set a minimum — `UtuhCredit.MIN_UNDERWRITING_WINDOW` does exactly this. **How stale the range may be.** A spotless year that ended the day before the liquidation that ruined them is still a spotless year. Compare `claim.toBlock` against `CHAIN_INFO.get_latest_attestation_height_and_hash(chainKey).height`. **How much history is enough.** A clean claim over a short window is cheap to keep clean and says almost nothing. `claim.toBlock - claim.fromBlock` is the span. **Whether one claim can be spent twice.** Claims are the registry's; nothing stops the same finalized claim being presented to you and to somebody else. If that matters, record what you have consumed — `UtuhCredit` keeps `claimSpent`, and a watermark per subject so that a _range_ of history, not just a claim id, is consumed by the thing that rested on it. **Who is allowed to present it.** Reading a public history is not the same as owning it. If the claim is about somebody's address and the benefit goes to a caller, you need a binding between the two — `UtuhCredit.proveControl` is one way: an ordinary source-chain transaction from the subject whose calldata is a tag and the Creditcoin account, with the sender read out of proven bytes. --- ## If you are the second lender `UtuhCredit` keeps `defaultsOf(address) → uint64`: how many of its own lines that subject walked away from and has not made good. It is the one fact a lender is willing to answer for out loud, and it is read rather than reported — the contract that extended the credit is the only thing that can speak for its own books. That makes cross-lender checking a constructor argument rather than an institution. Name the peers whose word you take, and `openLine` refuses a subject who is in default at any of them: ```solidity interface IDefaultsElsewhere { function defaultsOf(address subject) external view returns (uint64); } ``` There is deliberately no shared bureau to write to. A registry anyone may report into is a blacklist with extra steps — deploy a contract, report a rival's borrower, done — and every fix for that is a permission. Naming your own peers keeps the trust explicit and one-directional: a hostile peer can only refuse you credit you were not going to extend, and a lender that names nobody is affected by nobody. If you expose `defaultsOf` from your own contract with the same meaning, other lenders can name you back. That is the whole protocol. --- ## What it costs Nothing, for the consumer. Every call above is a `view` against a contract on Creditcoin. The cost sits with the claimant, who pays gas to append each proven event and posts a bond — and with whoever refutes, who spends gas on one proof and takes half the bond. Measured on CC3 Testnet, an append is about `290,899 + 1.5 × calldata gas + 81,427 per member` over the 56 appends measured so far; the dominant term is the size of the transactions being proven, and the per-member term is the one the data pins down least. `npm run gas` refits it from the registry's own receipts. --- ## Who has this gap today Eight contracts on this hackathon's board, read at the commit named on 2026-09-13, that compute a score, a tier or a cap from adverse-event counters which only move when somebody submits the proof. Each is a prospective consumer of `isUsable`, not a competitor, and none is an accusation: every one of them is correct about what it proves. | Repository, commit | The line that assumes the set is complete | | --- | --- | | `OoJae/crosscredit` @ `8366b1a` | `contracts/src/creditcoin/ScoreLib.sol:175` — `spotless = profile.late == 0 && profile.liquidations == 0`, Platinum tier gated on it; the counters move at `CreditRegistry.sol:591` and `:691`, each only on a submitted proof | | `seekdaseek/nomen` @ `6950c36` | `contracts/Nomen.sol:153` — `if (r.borrows > 0 && r.liquidations == 0) v += 50`; `++r.liquidations` at `:283`, reached only through permissionless `record()` | | `henessay/truthgate` @ `7f9fd3a` | `contracts/src/cc3/CreditCore.sol:611` — liquidation penalty accumulated in `_scoreLiquidations`; `markLoanAsExpired` is `onlyOwner` at `:856` | | `HoangDucBach/miro` @ `6be210b` | `contracts/creditcoin/src/CreditPassport.sol:251` — `p.negativeEvents++`; score is `positive − negativeEvents × NEGATIVE_PENALTY` at `:279-280` | | `Ted1166/attestcoin-credit-passport` @ `e275766` | `contracts/contracts/CreditScoreRegistry.sol:152` — `recordVerifiedRepayment` is `onlyRole(OPERATOR_ROLE)`; score at `:250` has no adverse term at all | | `Kohap/credit-passport` @ `2bbe48e` | `packages/contracts-creditcoin/src/CreditScore.sol:40-41` — every admitted loan adds 30 or 50; `CreditPassportASC.sol:123` admits closed loans only | | `SamarthSrivastavaa/clearbook` @ `a306d77` | `contracts/src/Clearbook.sol:307-312` — `markDelinquent` means "no repayment claimed by maturity", which its README concedes it "cannot prevent, so it measures" | | `gluwa/attestcoin-protocol-examples` @ `6668487` | `loan/contracts/sol/ASCLoanManager.sol` — `_noteLoanRepayment` proves presence; `markLoanAsExpired` is `onlyOwner` ([COMPLETENESS.md](COMPLETENESS.md)) | Three more carry the softer, positive-only version of the same shape (`DruxAMB/creditpass`, `Spagero763/standing`, `Nasiru0001/attestlend-passport`). These repositories move; the commit is the claim. ## Who this is for Anything that wants a sentence about events that did not happen: - an airdrop that excludes addresses ever slashed on another chain - a DAO seat that requires never having been liquidated - a market maker admitting counterparties with no failed settlements - insurance underwriting on a claims history - and lending, which is what `UtuhCredit` does with it The registry does not know or care which. A scope is a scope. --- ## Getting a claim built Your users need finalized claims to hand you, and building one means sweeping the source chain, fetching proofs and appending them. That is what `offchain/lib/claims.ts` does, and `offchain/lib/scope.ts` is the sweep — both are importable, and the browser console in `web/` uses the same sweep to check claims rather than to build them. The thing to tell your users: **build the claim from more than one source-chain endpoint**. A claimant who sweeps with a single RPC is betting their bond on that node having mentioned every log, and a missed event is not a smaller claim — it is an incomplete one, and being slashed for it looks exactly like lying. ## Installing it Checked end to end on 2026-09-13 with forge 1.8.0, from an empty directory: ```sh forge init gate && cd gate forge install PugarHuda/utuh --no-git npm i @gluwa/usc-contracts@0.1.2 ``` `remappings.txt`: ``` utuh/=lib/utuh/src/ @gluwa/usc-contracts/=node_modules/@gluwa/usc-contracts/ forge-std/=lib/forge-std/src/ ``` `foundry.toml` needs `solc = "0.8.28"`, `optimizer = true`, `optimizer_runs = 200`, `via_ir = false` — the settings the deployed contracts were built with. A twelve-line consumer that imports `utuh/UtuhRegistry.sol` and calls `claim`, `memberCount` and `isUsable` then compiles to 1,488 bytes of bytecode with no link references: your contract never touches `EvmV1Decoder`, so it does not need the deployed library. solc prints a stack-depth *note* on `_extractLog` while compiling the registry; it is a lint, not an error. The pin is `0.1.2` on purpose. Gluwa's newer `@gluwa/asc-contracts` 0.2.1 made the decoder's functions `internal`, and the published registries link the `public` 0.1.2 decoder as a library; the interfaces your contract reads are the same in both. ## Addresses CC3 Testnet, chain id 102031. The deployed registries are listed in the [README](../README.md#deployed-on-cc3-testnet-chain-id-102031), all verified on Blockscout. A registry is not upgradeable and holds no admin key; deploying your own with a different `MIN_CHALLENGE_WINDOW` costs one transaction if you want a floor of your own. # docs/MAINNET.md # Deploying on Creditcoin Mainnet A runbook, written before the deployment rather than after, because the deployment needs a funded key that only one person holds and the person running it should not be reading code to find out what differs. Everything below was measured against `https://rpc.cc3-mainnet.creditcoin.network` on 2026-09-10. ## What is the same - Chain id `102030`. The RPC answers `eth_chainId`, is CORS-open, and serves the ChainInfo precompile: `get_supported_chains` returns one chain, and `get_latest_attestation_height_and_hash` for it returned `25,947,520` — Ethereum mainnet's height that minute, within a few hundred blocks of what the testnet's frontier said. Both networks sign the same Ethereum block into a byte-identical attestation digest from attestor sets with no shared BLS keys (`npm run livetest` asserts it), so an Ethereum event provable on testnet is provable on mainnet. - `0x0FD2` and `0x0FD3` have no bytecode on either network. Same reason, same consequence: nothing about verification can be fork-tested. - The contracts. Nothing in `src/` is testnet-shaped; the chain key and the precompile addresses are constructor arguments and constants respectively. - Blockscout exists: `https://creditcoin.blockscout.com` answers `/api/v2/stats`. - Nobody has built there yet. The mainnet attestation indexer (`attestations-graphql.cc3-mainnet-usc.creditcoin.network/graphql`) records 65 `TransactionVerified` events ever, from 65 distinct transactions, the first on 2026-06-18 and the last on 2026-08-07 — and every one of them, resolved on `creditcoin.blockscout.com` 2026-09-13, is an EOA calling `0x0FD2` `verifyAndEmit` directly (29, 23, 7 and 6 from four addresses). No application contract has verified a transaction on Creditcoin Mainnet. The attestor set there is seven keys; the testnet frontier for Ethereum ran 40 blocks ahead of mainnet's that minute (25,969,540 against 25,969,500). ## What is different, and it is three things ### 1. Ethereum is chain key **1** on mainnet, not 3 On CC3 Testnet, key 1 is Sepolia and key 3 is Ethereum mainnet. On Creditcoin Mainnet there is one attested chain and its key is 1, and `get_chain_by_key(3)` reverts `chain not supported`. The README already says a chain key is not a global constant, and `verifyChainKeys` in `offchain/lib/chain.ts` asks the network what each configured key is *named* before anything trusts the table. That check is the tripwire: pointed at mainnet with the testnet table, it fails on the first line, which is the correct outcome. It also means the `CHAIN_KEY` / `SOURCE_CHAIN_ID` / `SOURCE_RPCS_DEFAULT` tables in `offchain/lib/networks.ts` — keyed by number, and used by every script and the console — need a mainnet profile in which key 1 maps to Ethereum, not Sepolia. That is the one real code change, and it is not made yet. It is deliberately not made yet: a profile nobody can exercise until a key is funded is scaffolding, and the tripwire means the wrong table cannot be used by accident. ### 2. There is no hosted Proof Builder `prover.cc3-mainnet.creditcoin.network`, `proof-gen-api.cc3-mainnet.creditcoin.network` and `prover.creditcoin.network` do not resolve. Every proof on mainnet comes from `RawProofBuilder` over public Ethereum endpoints plus the ChainInfo precompile — the path `npm run provers` proves is byte-identical to the hosted one, and which takes 20–30 s per proof (29.9 s on mainnet, measured 2026-09-13; an earlier note here saying "under a second" was measuring the hosted service under its second hostname). `Prover.withDefaults(chainKey, budget, 'http://127.0.0.1:1')` is how the scripts already run it with the hosted URL sent nowhere. The consequence for refuters: the independence argument stops being a fallback and becomes the only path. It was built for that. ### 3. The CTC is real Testnet CTC comes from a faucet. Mainnet CTC is bought. `npm run gas` fits the registry's real cost from its own receipts rather than from a table; Creditcoin's published price for a verification is `2.3e-5 + 2.9e-7 × continuity-hash-count` CTC, and a member's continuity proof is at most 100 hashes because checkpoints land every 100 blocks. Run `npm run gas` against the testnet record before funding, and size the key for a demonstration, not for a market — the market is gated on a counterparty, not on this. ## The runbook ```sh # 1. The tripwire, before anything else. This must FAIL with the testnet table — # `doctor` is the script that runs verifyChainKeys; `probe` does not. CC3_RPC=https://rpc.cc3-mainnet.creditcoin.network npm run doctor # 2. Add the mainnet profile to offchain/lib/networks.ts (chain id 102030, key 1 → Ethereum, # no PROVER_URL, Blockscout at creditcoin.blockscout.com) and make step 1 pass. # 3. Deploy with a funded key. DEPLOYMENTS names a separate record so nothing testnet is touched. CC3_RPC=https://rpc.cc3-mainnet.creditcoin.network DEPLOYMENTS=deployments.mainnet.json \ REDEPLOY=1 PRIVATE_KEY=... npm run deploy # 4. Verify on both, the way the testnet contracts are. npm run verify # Blockscout, reads the record DEPLOYMENTS names forge verify-contract --chain 102030 --verifier sourcify
# 5. One real claim, sealed and finalized, so the deployment is a deployment and not an address. CC3_RPC=... DEPLOYMENTS=deployments.mainnet.json PRIVATE_KEY=... npm run e2e # 6. Tell the judge about it. # Add the record to LISTED in offchain/judge.ts so the daily run measures it too. ``` ## How you would know it happened Addresses on chain 102030, verified on Blockscout and matched on Sourcify, with at least one claim sealed and one refuted — the same two observables the testnet deployment carries, on a chain where the CTC was paid for. `ROADMAP.md` moves the item from *specified* to *shipped* on that day and not before. # docs/README.ko.md # Utuh — 요약 이 문서는 요약본입니다. 정확한 내용은 영어 [README](../README.md)가 기준입니다. ## 문제 Attestcoin 프로토콜은 소스 체인에서 **어떤 트랜잭션이 일어났다**는 사실을 증명합니다. 하지만 **어떤 집합이 완전하다**는 사실은 증명하지 못합니다. 어떤 증명을 제출할지 고르는 쪽이 증명을 제출하는 당사자이고, 제출된 증명은 하나하나가 모두 유효하기 때문입니다. 이 빈틈은 신용에서 치명적입니다. 신용이 필요로 하는 문장은 이런 형태입니다. > 이 차입자는 **한 번도** 청산된 적이 없다. 이것은 존재하지 않는 사건에 대한 진술이고, 포함 증명(inclusion proof)은 존재하는 사건에 대해서만 말할 수 있습니다. 가정이 아닙니다. Creditcoin이 공개한 참조 대출 예제(`gluwa/attestcoin-protocol-examples`, `loan/contracts/sol/ASCLoanManager.sol`)에서 상환은 모두 블록 프루버로 증명되지만, 유일한 부정적 결과인 미상환은 `markLoanAsExpired` — `onlyOwner` 함수 — 로 처리됩니다. 예제의 결함이 아니라, 프로토콜의 형태가 그대로 드러난 것입니다. ## Utuh가 하는 일 - 클레임에 들어가는 모든 항목은 저장되기 전에 블록 프루버로 검증됩니다. 따라서 클레임을 **부풀릴 수는 없습니다**. - 대신 **누락시킬 수는** 있습니다. 그래서 클레임 제출자는 "빠뜨린 것이 없다"는 주장에 본드를 겁니다. - 범위 안의 사건 **단 하나**라도 누락되었음을 증명하면 클레임은 무효가 되고, 증명한 사람이 본드의 절반을 가져갑니다. 존재는 암호학적으로, 부재는 경제적으로. 본드는 거짓말을 비싸게 만들 뿐, 불가능하게 만들지는 않습니다. 이 구분은 중요하며 과장해서는 안 됩니다. 그 위에 `UtuhCredit`이 이더리움 메인넷의 실제 상환 이력을 근거로 초과담보 없이 CTC 신용 한도를 개설합니다. 다리(bridge)는 없습니다. 이력은 이더리움에, 신용은 Creditcoin에 남고, 증명만 건너갑니다. ## 직접 확인하기 지갑 없이 열립니다. 두 링크 모두 누군가 실제로 깨뜨린 클레임 위에서 바로 시작합니다. - 사건 하나가 빠진 채 봉인된 클레임: - 이더리움 메인넷 216,000블록에 대한 거짓 "청산된 적 없음" 클레임, 청산 증명 하나로 파기됨: - 데모 영상(3분): - 모든 수치를 직접 재측정: `npm run judge` — 키 없이 라이브 체인에 대해 다시 측정하고, 하나라도 더 이상 성립하지 않으면 0이 아닌 코드로 종료합니다. 2026-09-14 프로덕션 배포 후 25개 중 25개 성립(예제 게이트를 읽는 검사 2개 포함). 각 검증 기록은 Creditcoin이 직접 운영하는 오라클 대시보드에서 소스 높이별로 확인할 수 있습니다. 이쪽에서는 쓸 수 없는 기록입니다: ## 참여 방법 - 랜딩 페이지 는 두 레지스트리를 Creditcoin에서 직접 읽어 그립니다. 콘솔 은 백엔드도 키도 없이 브라우저에서 이더리움을 훑고 클레임을 반박합니다. - 감시자 역할을 에이전트에게 — `npx -y utuh-mcp` (npm 및 공식 MCP 레지스트리에 `io.github.PugarHuda/utuh-mcp`로 등재). - 직접 만든 컨트랙트에서 레지스트리 사용 — [INTEGRATING.md](INTEGRATING.md). 컨트랙트는 증명을 보관하지 않고 `0x0FD2`를 호출하지도 않습니다. 불리언 하나만 읽습니다. - 내가 만든 것에도 이 빈틈이 있는지 확인 — [COMPLETENESS.md](COMPLETENESS.md). ## 알려진 한계 - 완전성은 암호학적이 아니라 경제적입니다. - Attestcoin의 쓰기 기능(writability)이 아직 테스트넷에 없으므로, 채무불이행은 Creditcoin에 기록될 뿐 이더리움 쪽으로 집행되지 않습니다. - 클레임 항목은 스토리지 배열이라 반박은 체인이 직접 수행하는 이진 탐색입니다. 대신 약 1만 건을 넘어서면 비용이 감당하기 어려워집니다. 대체 구현(증분 머클 루트와 인접성 증명)은 `merkle-claims` 브랜치에 만들어져 테스트를 통과했지만, 스토리지가 바뀌어 클레임 번호가 모두 바뀌기 때문에 master에 병합되지도 배포되지도 않았습니다 ([ROADMAP.md](ROADMAP.md)). - 만든 사람은 한 명입니다. # docs/ROADMAP.md # Roadmap Written 2026-09-10. Everything below is either **done**, **gated on somebody else**, or **engineering that has not been done yet** — and each item says which, because a roadmap that does not distinguish those three is a wish list. Every item also carries a line saying **how you would know it happened**, from outside this repository. That is the only part that matters: the rest is intent, and intent is cheap. --- ## How to read this | Marker | Means | | ------ | ----- | | **Shipped** | It is on a public chain, in the published package, or in CI. Check it now. | | **Specified** | The design is written down and the work is known. Nothing external blocks it. | | **Gated** | Blocked on something outside this project. The gate is named. | | **Refused** | Deliberately not on the roadmap, with the reason. | There are no dates on the unshipped items. This is one person, the deadline that matters was a hackathon, and inventing quarters for work whose start depends on funding and on somebody else's audit would be the least honest page in the repository. --- ## Shipped - **The registry.** Bonded completeness claims, members verified by the Block Prover on the way in, refutation by one proof of one omitted in-scope event, half the bond to whoever finds it. _Check it:_ `UtuhRegistry` at `0x8FA0BD5301D998Be873E31453E53d114929a5Fac` (mainnet-sourced) and `0x26880c8980Cd54827543bD34c6c613253c69347b` (Sepolia-sourced) on CC3 Testnet, verified on Blockscout and matched on Sourcify. - **The credit line.** `UtuhCredit` underwrites undercollateralized CTC against proven Ethereum repayment volume plus a bonded clean claim, caps the limit at `enforceableLoss × BOND_MULTIPLE` rather than at volume, and defaults on silence with no administrator anywhere in either contract. _Check it:_ grep both contracts for `onlyOwner`, `Ownable`, `owner`, `onlyRole`. Empty. - **Refutation, actually run.** Not described — executed on-chain, repeatedly. _Check it:_ and , and the tally on the landing page. - **A watcher anybody can be.** The console sweeps Ethereum from a browser across independent endpoints, takes the union rather than a vote, and sends the refutation itself — no backend, no key to look. An hourly keyless sweep in CI goes red if a sealed claim is short. _Check it:_ the `watch` workflow's run history, and the page's own network log. - **The watcher role as an agent.** `npx utuh-mcp` — five tools, claims as addressable resources, the job written down as a prompt, per-tool annotations saying which one spends. _Check it:_ npm `utuh-mcp@0.4.0`, the MCP Registry entry `io.github.PugarHuda/utuh-mcp`, and the `utuh-mcp.mcpb` on GitHub release `v0.4.0`. - **All sixteen protocol entry points**, five on the Block Prover and eleven on ChainInfo, each because something needed it. _Check it:_ the table in the README, then the contracts. --- ## Specified — engineering, not research ### 1. Claim size stops being the ceiling — **built, on branch `merkle-claims`, not deployed** Members are a storage array on the published registries, so refutation is a binary search the chain runs itself with no witness a claimant could withhold. That property was worth keeping, and it cost a slot per member. The replacement exists as of 2026-09-10, on a branch: `IncrementalMerkle.sol`, a depth-32 append-only tree in the deposit contract's shape — 32 words and a count per claim whatever its size — with the refuter supplying an **adjacency proof** of the two members bracketing the gap, built off chain from the `EventAppended` log that has always carried every key. Fuzzed against a reference that keeps every leaf; the invariant suite rebuilds each claim's root from the keys it saw accepted; a vector the TypeScript computed is pinned into Foundry so the three implementations have to agree. 165 tests. A real refutation costs about 83k gas more, most of it the two proofs in calldata; an append is within a percent. It is not on master and not deployed, deliberately: it changes storage, a redeploy renumbers every claim, and the submission, the film and the deck all point at claims by number. It merges the day after judging closes. What remains after the merge is a deployment and the live suites run against it — the console and the MCP server on that branch read `claimRoot` and `openedAt`, which the published registries do not have. _How you would know:_ a claim with six figures of members, sealed and refuted, on a public chain. Until then: `git checkout merkle-claims && forge test`, or let CI do it — workflow `merkle-claims`, job `contracts on merkle-claims` (`.github/workflows/merkle.yml`), checks the branch out and runs its 165 tests on forge 1.8.0 on every push to master, weekly, and on dispatch (a workflow file only runs from the ref that carries it, so not on pushes to the branch itself). Run history: . ### 2. Mainnet The contracts read Creditcoin Mainnet's ChainInfo precompile today — both networks sign the same Ethereum block into a byte-identical attestation digest from attestor sets with zero shared BLS keys, which is checked from the browser in two calls. Nothing in the design is testnet-shaped. What is missing is a deployment and the CTC to run it. _How you would know:_ addresses on chain 102030, verified, with the same claims flow running against them. The runbook is [MAINNET.md](MAINNET.md): measured 2026-09-10, three things differ — Ethereum is chain key 1 there, there is no hosted Proof Builder, and the CTC is real. ### 3. An external audit Money at rest in a bond contract is a different risk class from a demo, and no amount of self-testing substitutes for someone whose job is to break it. Slither at zero findings across 97 detectors, 211 Foundry tests, symbolic proofs over every input rather than 256 samples, and an invariant suite over random sequences are the floor, not the ceiling. The CertiK credits attached to this hackathon's prizes are the start of it. _How you would know:_ a published report with findings and responses, including the findings that were not fixed and why. The half a project can produce alone is done: [AUDIT.md](AUDIT.md) — scope, trust model, invariants, where to look first, and what is already known. ### 4. The claim-building path stops being the slow half — **partly shipped; the 09-10 number was wrong** Building a proof locally cost tens of seconds against roughly one for the hosted service, because `RawProofBuilder` re-fetched every sibling transaction in the block one at a time after already having fetched the block that contained them. The block provider now keeps what the block carried and answers the second ask from memory; 127 round trips became zero, and the proofs are byte-identical. This page said on 2026-09-10 that the local path then ran in 0.8 s. It did not: `npm run provers` was reaching the hosted service under its second hostname and reporting it as local. Measured correctly on 2026-09-13, the local builder takes 20.0 s on Sepolia and 29.9 s on mainnet against 0.9 s hosted. What remains is the endpoint's own latency on the block-with-receipts call and the continuity blocks, which no client-side change removes. _How you would know:_ `npm run provers` printing a local time within a small multiple of the hosted one. Today it prints 23× and 35×. ### 5. A claim reserved by the lender that relies on it Found in the 2026-09-13 audit pass, pinned by `test_oneClaimPairUnderwritesALineAtEveryLender`: `isUsable` is stateless and `claimSpent` is per `UtuhCredit`, so one finalized volume-and-clean pair opens a full line at every lender that accepts it, and the burned half of one bond stands behind all of them. The fix is a registry function that lets the consuming contract reserve a claim — one call, one mapping, an ABI change, so it rides the same redeploy as item 1 rather than its own. _How you would know:_ a `reserve(claimId)` (or equivalent) on the published registry's ABI, and `UtuhCredit.openLine` calling it before it lends. --- ## Gated on somebody else ### Enforcement back on the source chain **Gate: Attestcoin writability, which is in third-party audit and not on testnet.** Today a default is *recorded* on Creditcoin. The borrower is refused the next line while it stands, and may cure it late on exactly the terms it was owed. What cannot happen yet is consequence on Ethereum, because outbound messaging does not exist to build against. The seam is reserved in the design rather than retrofitted later: the default is already a first-class on-chain record with the subject, the amount and the height, which is the message such a relay would carry. _How you would know:_ the week it lands on testnet, an integration exists. Not before — designing against an unshipped interface is how you build the wrong thing twice. ### A counterparty **Gate: one lender with real capital deciding a real limit on a real borrower's history.** This is the honest gap between a working mechanism and a market, and it is the one thing on this page that cannot be closed by writing more code. Every number in this repository is measured and none of them is a loan somebody could lose money on. The economics — that a bond deters, that a lender watches because the exposure is theirs, that `enforceableLoss` is the right ceiling — are argued and tested, not observed in a market. _How you would know:_ a line opened by a lender who is not the author, against a borrower who is not the author, for money that is not testnet. --- ## Refused - **A token.** The mechanism pays refuters out of bonds that lying claimants posted. Any additional bounty large enough to matter is recoverable by a claimant refuting their own claim from a second address — the same front-running that made `enforceableLoss` necessary rather than the bond. A token layered on that would be worse than the honest gap, and Known limits says so where a buyer can read it. - **More source chains before there is a lender.** Only chain keys 1 and 3 are attested, and adding breadth to a layer nobody is lending on optimises the wrong number. - **More adverse-event classes shipped as adapters.** A lender already configures as many classes as it cares about, and `openLine` caps exposure at the weakest of them. Shipping a library of protocol adapters would be building somebody else's integration before they asked. - **Funding watching as a public good.** Honest claims pay watchers nothing and there is no fix for it at this layer. What there is instead is a reason the party with money at risk watches anyway, and the console and MCP server exist so that costs them a tab rather than a team. Pretending the general problem is solved would be a worse answer than naming it. --- ## What would change this page Two things, and they are the two gates above. If writability ships, enforcement moves from gated to specified and the design's last reserved seam gets used. If a lender appears, every economic claim here becomes an observation instead of an argument, and the roadmap after that is written by what that lender needs rather than by what seemed likely from here. Everything else on this page is the same work whether or not either happens.