A completeness layer for the Attestcoin Protocol, and undercollateralized credit built on it.
utuh — Indonesian: whole, intact, with nothing missing.
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.
Built for BUIDL CTC 2026 Fall on Creditcoin.
Technical brief: https://claude.ai/code/artifact/2caca05b-c659-463f-b5ca-28e207f95147
Deployed and verified on Creditcoin CC3 Testnet. The console is live at
utuh.vercel.app — it 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 runs the same page locally.
Building something else on Creditcoin that needs a sentence about events that did not happen? The registry is usable on its own — see docs/INTEGRATING.md.
The Attestcoin Protocol proves that a source-chain transaction happened. A Merkle proof places
the transaction in a block; a continuity proof anchors that block to an attestation stored on
Creditcoin. The Block Prover precompile at 0x0FD2 checks both natively, synchronously, inside a
single Creditcoin block.
What it cannot prove is that a set of events is complete.
Whoever submits proofs chooses which proofs to submit. Every one of them verifies. Nothing in the protocol notices the ones that were left out.
For credit, that gap is fatal. The sentence every on-chain credit system needs is:
This borrower has never been liquidated.
That is a statement about events which do not exist, and an inclusion proof can only ever speak about events which do. A borrower assembles their own history, submits the flattering half, and each proof checks out.
This is not hypothetical. Season 1 of this hackathon drew 76 submissions, and more than twenty of them were some form of on-chain credit score or reputation-based lending; one of those, CreditX, took a prize. Every one of them inherits this hole, the winner included — placing well is not the same as closing it, and an inclusion proof gives none of them a way to.
npm run doctor ends by making a live request to every Creditcoin-owned surface this depends on,
so the list below is checkable rather than asserted. Today's run:
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.
Two of those lines are worth reading twice. Every append and every refutation goes through
verifyAndEmit rather than its view twin, so Creditcoin's own oracle dashboard is the record
— the network logged this project's work, not the other way round. And the Creditcoin Mainnet
indexer is read even though nothing here is deployed there: the console audits mainnet's attestors
against Ethereum itself, because auditing only the network you deployed to leaves the production
oracle unchecked by the one page that can check it.
PenguinBridge, PenguinSwap and PenguinBase are Creditcoin's own consumer applications and none of them appears here. That is a design decision, not an oversight:
- 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.
Creditcoin ships a loan-flow tutorial
— USCLoanManager on Creditcoin, an auxiliary contract on Sepolia, an offchain worker between
them. 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; USCBase.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.
markLoanAsExpiredisonlyOwner. 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.
Two halves, each sound on its own.
Nothing invented. Every event enters a claim through appendBatch, which runs the Attestcoin
Block Prover on it before it is recorded. Members must arrive in strictly ascending
(blockHeight, txIndex, logIndex) order, which the contract enforces rather than trusts. A claim
can only ever contain events that provably happened, so its aggregate cannot be inflated.
Nothing omitted. The claimant bonds the assertion that the set is complete. Anyone may break the claim by proving a single in-scope event the set does not contain. Absence is never proven — a claim of absence is refuted by presence, which Attestcoin does prove.
presence → cryptographic (Merkle + continuity, verified by 0x0FD2)
absence → economic (bonded assertion, refutable by one proof)
Settling a claim is O(1): the registry never verifies a whole set, so a claim spanning ten thousand events is broken by a single proof or by none at all.
Building one is not. npm run gas measures it rather than reasoning about it — it finds every
transaction a registry has ever seen from the registry's own logs, reads the receipts, and fits a
cost model. No explorer involved. Across the four registries deployed so far, 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 all 56 appends the published registries have seen, 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 thirty blocks' worth of gas, and that is the number that caps this rather than any argument about storage.
A challenge window is only meaningful if a watcher could actually have acted inside it. So a claim
may not open until its entire block range is already attested on Creditcoin — checked against the
ChainInfo precompile at 0x0FD3:
if (!CHAIN_INFO.is_height_attested(scope.chainKey, toBlock)) revert RangeNotAttested(...);Without that gate, a claimant could cover a range whose tail is not yet attested, and the window
would expire on a claim nobody was able to refute. Attestation heights only advance, so once
toBlock is attested the whole range stays provable for the life of the claim.
A second detail: a refuter receives half the slashed bond, not all of it. If they took the whole bond, a claimant caught lying could refute their own claim and walk away whole, which would make a false claim free to attempt. The burned remainder is what puts a price on being wrong.
That price is smaller than the bond, and the difference matters. A claimant knows which event they omitted from the moment they seal, so they can watch for an incoming refutation and send their own from a second address, taking the refuter's share back. No ordering scheme closes this — an earlier draft of these notes claimed commit-reveal would, which was wrong: the claimant holds the private knowledge, so they simply commit first. What survives is the burn, which nobody can recover.
So the registry reports enforceableLoss rather than the bond, and isUsable measures exposure
against that. Sizing a line against the whole bond, as UtuhCredit did at first, carried twice
the exposure the deterrent actually covered.
CC3 Testnet attests Ethereum mainnet (chainKey 3) alongside Sepolia (chainKey 1), from
genesis height 0. Verified live:
$ npm run balance
attested sepolia chainKey 1 height 11530210
attested mainnet chainKey 3 height 25797540
The mainnet frontier tracks within roughly a hundred blocks of the real chain head. So contracts on a free testnet can be underwritten on real Aave positions, real USDC flows, and real borrowers, with no capital at risk and nothing simulated.
Budget for the attestation lag when running anything live: a freshly mined block takes on the order of ten minutes to become provable on either chain. Historical blocks are immediate, and a proof for one 210,000 blocks back still resolves in about seven seconds — it is only the tip that you wait on.
Every number in the demos comes from Ethereum mainnet.
An undercollateralized credit line on Creditcoin, underwritten on Ethereum. Nothing bridges: the history stays on Ethereum, the credit is issued in CTC on Creditcoin, repayment happens back on Ethereum. The only thing that crosses is proof.
Underwriting rests on two claims that are adversarial in opposite directions, which is what makes the pair sound:
| Claim | Assertion | Who benefits from a lie | Defence |
|---|---|---|---|
| Volume | proven Aave USDC repayments | inflating it | every member verified by 0x0FD2 on append |
| Clean | complete set of liquidations, normally empty | omitting one | bond, refutable by one liquidation proof |
limit = min( 20% of proven volume × the lender's rate , 10 × the bond behind the clean claim )
The second term is the consumer half of the mechanism. The registry cannot size a bond, because it does not know what the claim will be used for — only the party about to lend knows its own exposure. A line never risks more than a liar stood to lose.
Aave's Repay carries amount in the reserve asset's own decimals. Scoping a volume claim to the
event alone would sum WETH's 18 decimals into USDC's 6 and call the total a credit history. So the
volume scope pins the reserve — Repay puts it in topic 1 — and a claim is denominated in
exactly one asset.
That leaves a second gap: the claim aggregates USDC at 1e6, a line is CTC at 1e18, and crossing
between them is a price. This contract has no oracle and does not pretend to: the lender fixes
VOLUME_UNIT_IN_CTC at deployment, in the open, where anyone can judge it. A lender wanting a live
price puts a feed in front of this contract rather than having the protocol invent one.
Underwriting reads a public chain. Nothing about reading it proves the reader holds the key that wrote it, so before a line opens the borrower must bind their Ethereum address to their Creditcoin account:
calldata = bytes12("utuh:control") || <creditcoin account>
One ordinary transaction from the subject address carrying exactly that. proveControl verifies
it through the Block Prover and reads the sender out of the decoded transaction — no signature
scheme of our own, no trusted relayer. The tag stops the commitment colliding with real calldata,
and naming the account inside it stops anyone binding a stranger's address to their own account.
Each commitment may be applied once, and that is not bookkeeping. A subject can move their
binding by sending a second commitment naming a different account — which is how anyone rotates
away from a Creditcoin account they no longer control. The proof of the first commitment stays
valid forever, and anyone may submit it. Without a used-marker the binding is therefore whichever
proof was replayed most recently, not whichever the subject meant: an attacker holding the
rotated-away account puts it back at will, including in front of the subject's own openLine.
Creditcoin's own USCBase records processed queries for exactly this reason, and this did not
until it was found. controlIdOf keys on the chain and the encoded transaction — which carries
its own signature — and deliberately not on the block height, so a reorg that moved the same
transaction cannot make the same commitment usable twice. npm run control and npm run full
both replay the commitment they just used and require ControlProofAlreadyUsed.
Any supported source chain will do: an EOA address derives from its public key and is identical on all of them, so Sepolia gas proves exactly as much as mainnet gas.
The terms of a draw are the lender's, never the borrower's. draw takes an amount and nothing
else; what must come back and by when are computed from policy, converting CTC back through the
same rate that produced the limit and rounding up so no draw is small enough to owe nothing.
Two things about the money are worth stating because both were wrong once. Every rounding in the
contract lands against the party carrying the risk: _repaymentFor rounds up so no draw is small
enough to owe nothing, and backingFor rounds up so no limit is backed by less than a
BOND_MULTIPLEth of itself. That second one is asked at both ends of a line — when it opens and
when it settles — and it was a bare division at both until it was not.
And a lender can name where its own capital goes. LENDER is msg.sender at construction and
immutable, so a lender that is a contract without a payable fallback could fund this and never
get the money back out: withdraw would revert with TransferFailed forever. withdrawTo is the
way out. The authority check is unchanged — only the lender may call it.
Three smaller rules close the same class of hole. A finalized claim is spent when it opens a
line, so one underwriting funds one line and the cap bounds aggregate exposure rather than each
line separately. A line's deadline is fixed by its first draw and never moves — otherwise a
borrower who owes money could buy an unlimited extension by drawing one more wei. And each
settlement consumes the source-chain range it rests on, tracked per subject in settledThrough,
because marking a claim spent does not stop a payment being spent twice: two lines, two claims
over overlapping ranges, one transfer inside both.
A finalized claim can only open one line: openLine marks it spent. That is not enough on its own,
and for a while nothing else was.
The registry will hold any number of claims over the same range with the same scope, and finalizing
one gives the bond back. So a borrower could build a second claim over the same three repayments,
finalize it, open a second line, and draw the limit again — every guard in openLine passing each
time, because each of them looks at one line in isolation. The bond cap bounded each line and
nothing bounded the total.
underwrittenThrough[subject] is the fix, and it is the same shape as the settledThrough
watermark that already stopped one payment discharging two debts. Opening a line consumes the range
it rests on. Borrowing again means new history: a range starting after the last one, still
MIN_HISTORY_BLOCKS long, still inside MAX_STALENESS_BLOCKS of the frontier. A credit line that
renews on performance, rather than a number that can be spent twice.
markDefault is permissionless and nobody is paid to call it. That was fine while it only wrote a
status, and stopped being fine the moment a standing default started blocking new lines: a borrower
whose deadline passed could wait, accumulate a fresh month of history, satisfy
underwrittenThrough, and open the next line with the first one still sitting there overdue and
unmarked. The guard was resting on a transaction nobody was obliged to send.
activeLineOf[subject] removes the dependency. A subject has one line at a time; an overdue line is
still Active, so it blocks by itself, and markDefault goes back to being bookkeeping. Each guard
then has exactly one job — the slot says you have a line open, the count says you failed one.
The rule needs an exit, or it is a trap. An undrawn line cannot be settled (nothing was borrowed)
and cannot be defaulted (markDefault refuses a drawn of zero, correctly — no money went out, so
nothing was missed), so closeLine gives the slot back. It does not give the history back:
underwrittenThrough has already moved, and it should have.
defaultsOf belongs to one deployment. A borrower who walks away from a line here opens one at the
lender next door with nothing in the way, and that is the gap a credit bureau fills.
The tempting shape is a shared contract everyone reports to. It did not survive being designed. Reports have to be trusted, and a registry anyone may write to is a blacklist with extra steps — deploy a contract, report a rival's borrower as a defaulter, done. Every fix for that is a permission, and a permissioned bureau is the centralised thing this whole repository exists to avoid.
So there is no bureau. A lender names the peers whose word it takes, in its constructor, and the answer is pulled from the peer's own storage — where the fact was recorded by the contract that actually extended the credit. No reports, no writes, nothing to forge: a peer can only ever say what happened on its own books, and the worst a hostile one can do is refuse credit it was never going to extend. A lender that names nobody is unaffected by everyone, which is the safe default and has to be a choice rather than an accident.
A drawn line is settled by the borrower proving repayment landed at the lender's Ethereum address. If no finalized claim arrives before the deadline, the line defaults. The contract never establishes that a payment was missed — the burden sits with the only party who could discharge it. Silence is the default condition, not an inference.
A default that costs nothing but the line it happened on is not a credit event, though, and that is
what it used to be: markDefault set a status and the borrower opened the next line the same block
on a later slice of history. defaultsOf[subject] counts defaults that still stand, and openLine
refuses while any do.
cure is the way back. The borrower proves the repayment late, on exactly the terms it was owed —
same scope, same watermark, same backing, same amount, every check settle makes, sharing one
function with it so the cheaper path cannot drift into existence. The line becomes Settled, the
count comes down, and the subject can borrow again on history it has not already spent. Nothing is
forgiven for being late; the deadline has already done its work, which was to record the default
while it stood.
That distinction is what separates a credit protocol from a blacklist, and it costs one counter.
Every contract below is verified on Blockscout — source, ABI and decoded constructor arguments
are readable at its address. An unverified address is a wall of bytecode, and "the source is on
GitHub" is a different claim from "this address runs that source". npm run verify republishes
them after a redeploy, reading whichever record DEPLOYMENTS names — so
DEPLOYMENTS=deployments.full.json npm run verify covers the Sepolia-sourced set including its
ledger, with nothing reconstructed by hand.
Deploying refuses to overwrite an existing record without REDEPLOY=1. The addresses below are
the ones in it, all verified; npm run demo used to begin by replacing them, so following this
file to record a demonstration quietly made everything published about them false.
Blockscout reports a partial match: the runtime bytecode agrees and the trailing metadata hash does not, which is what happens when the compilation environment is not reproduced byte for byte. The code is readable and the functions are callable.
Sourcify disagrees, in the right direction. It compares the metadata hash
too, and reports every contract below as a full match — exact_match for the decoder and the
Sepolia ledger, match for the registry and both credits — from a tree it read itself. npm run verify now submits to both, because two verifiers that do not share a backend agreeing on the same
source is a stronger sentence than one, and because Blockscout forwarding to Sourcify is a thing it
usually does rather than a thing to rely on: the Sepolia ledger had not arrived until it was sent.
repo.sourcify.dev/102031/<address> has the sources.
| Contract | Address |
|---|---|
UtuhRegistry |
0x8FA0BD5301D998Be873E31453E53d114929a5Fac |
UtuhCredit |
0x89FB81b1e453b7Bd18ac1A6AF03C84A40Ce10C57 |
EvmV1Decoder |
0x5cab00c032D7d4436f312Dd51ef59Dc5b860df3F |
npm run credit runs against these, on Ethereum mainnet data.
| Contract | Address |
|---|---|
UtuhRegistry |
0x26880c8980Cd54827543bD34c6c613253c69347b |
UtuhCredit |
0x0177aDb82152c8673a85271F7F06336B820324b6 |
EvmV1Decoder |
0x084c45552A6c45C7269F4a7041E757ABf4Bcc008 |
SettlementLedger (Sepolia) |
0xC8C9053C4E2c0590df684c12e5f2610EFeC9575B |
Everything below is readable at those addresses rather than taken on trust — claim(id),
memberCount(id), keyAt(id, i), enforceableLoss(id), line(1), underwrittenThrough(subject)
and settledThrough(subject) all answer to anyone, and the console at npm run web shows them
without a terminal.
| Read | Answer |
|---|---|
claim(1) |
Finalized, 3 members, aggregate 0.003 ETH of proven volume |
keyAt(1, 0..2) |
Sepolia blocks 11575883, 11575885, 11575886 |
claim(2) |
Finalized, 0 members — the clean claim, and there is nothing to show |
claim(3) |
Refuted, enforceableLoss collapsed to 0 |
claim(4) |
Finalized, repayment of 0.000525 ETH |
claim(5) |
Refuted — planted short by one, and broken from a browser |
burned() |
2 CTC, two refuted claimants' halves that nobody collected |
line(1) |
Settled, limit 10 CTC, drawn 10 CTC, repayRequired 525000000000000 |
underwrittenThrough(borrower) |
11575891 — one past the range that opened the line |
settledThrough(borrower) |
11575986 — one past the range that discharged it |
defaultsOf(borrower) |
0 |
activeLineOf(borrower) |
0 — the line settled, so the slot is back |
claim(6), claim(7) |
Finalized — 3 members and 0, built from the browser by a second borrower |
line(2) |
Settled — opened, drawn, repaid and settled entirely from the console |
claim(8) |
Finalized, 1 member — the repayment; opened by one run, resumed by the next |
claim(9), claim(10) |
Finalized, unspent, useless — a 4-block range against a 5-block floor; the |
| page now refuses that before a bond is posted | |
claim(11), claim(12), line(3) |
The second underwriting of the same key, on later history — line 3 Settled — the whole loop, twice, from the page (claim 13 is its repayment) |
peerCount() |
0 — this lender takes nobody else's books, which is the safe default |
The borrower's sweep read publicnode=3 tenderly=3: two independent endpoints agreeing, and the
claim built on the union rather than on whichever answered first.
Lines 2 and 3 belong to 0x0C2ffE823f1b64c975D768c9822F31eFED6f6a83, a key that has never run a
script here. It paid the lender three times on Sepolia and then did everything else through the
page — sent its control commitment (the wallet switched to Sepolia and back), proved it, built
claims 6 and 7, waited out their windows, finalized, opened line 2, drew 1 CTC, paid the lender back
through the ledger with the same wallet, built claim 8 over that payment, waited out that window,
and settled — as web/tests/borrow.live.spec.ts, against these contracts. Then it did it again on
later history, for line 3. The limit is 5 CTC and not the 12 the volume would justify, because it
posted the 1 CTC minimum bond and a 1 CTC bond guarantees a 0.5 CTC loss.
Claim 5 is the interesting one. npm run bait sealed it deliberately short by one event and told
nobody. It was found and broken from the console, in a browser: the page swept Sepolia across
two endpoints (publicnode=4 tenderly=4), checked all four against the claim on-chain, found the
one it omitted, fetched a proof from the hosted builder and sent the refutation itself. That path
is a Playwright test — UTUH_LIVE_UI=1 npm run web:test -- refute.live — so it is a thing that is
checked rather than a thing that was done once.
Three figures there are the mechanism, not decoration. The limit is 10 CTC —
enforceableLoss of 1 CTC times a BOND_MULTIPLE of 10 — and not the 12 CTC the 0.003 ETH of
volume alone would justify, because a 2 CTC bond only guarantees a 1 CTC loss and it is the
guarantee that lends. 0.000525 ETH is what drawing 10 CTC obliges at the lender's rate and 105%
terms; the borrower had no say in the figure. And the two watermarks now read one past the ranges
they consumed, which is what stops the same history opening a second line and the same payment
discharging a second debt.
test/Lifecycle.t.sol reproduces all three of those numbers locally, from the fixture's own
amount and the deployed policy, so they are a property of the code rather than of that afternoon.
A default, and the way back. DEPLOYMENTS=deployments.full.json npm run cure deploys a second
UtuhCredit over the same registry with a five-minute repayment window — the recorded run left one
at 0x509fab6a2Fd8C1a50dAB8C05cD7C7e53cB29868f — and underwrites the same
borrower on the same finalized claims, draws, lets the deadline pass, is marked in default — and
then makes it good with the repayment claim the loop already finalized. Claims belong to the
registry and claimSpent belongs to the credit contract, which is why that costs one deployment
and one Sepolia transaction rather than a second loop.
An earlier run, at earlier addresses, did not finish in one go — twice — and both interruptions
are worth recording because the chain absorbed them. The first: the process died during the long
wait for Sepolia's attestation frontier to reach the repayment block. Nothing was lost, because
nothing was being held in the script; npm run finish -- <registry> <credit> 1 read the line's
state off the chain and closed it. The second was mine. I stopped a resume that looked stuck and
started another, and the first was still running: two processes built the same repayment claim, both
holding the identical event. Nothing broke, because a settlement consumes both the claim and the
source-chain range it rests on — the duplicate could not settle the same line, and the watermark
meant that payment could not settle any other. It cost its author a bond locked until its own window
closed, and nothing else. Two guards written for a lying claimant turned out to cover a clumsy
honest one too.
npm run credit reads Ethereum mainnet: real Aave positions, real liquidations, real
borrowers. It underwrites them, refutes a genuinely liquidated address that claims it was never
liquidated — and then stops, because nobody can prove control of a stranger's address. That
refusal is the honest end of that flow.
npm run full closes the loop instead. A borrower we control acts on Sepolia: they pay a
lender through SettlementLedger, bind their address with a control commitment, get underwritten
on what they actually did, draw CTC on Creditcoin, repay on Sepolia, and settle. Two parties, both
transacting for themselves.
The source-chain contract is not a stand-in for anything under test. The payments are real transfers, the events are real logs in real blocks, and Creditcoin attests them exactly as it attests Aave's. A scope is a scope — the registry cannot tell the difference, and does not need to.
npm run web # http://127.0.0.1:5173Everything on that page is read from CC3 Testnet as the page draws it. 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.
It 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.
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.
The sweep is the daemon's own function, imported rather than reimplemented — scanScopeUnion in
offchain/lib/scope.ts, bundled into the page. A browser cannot conclude that a claim is complete
on different reasoning than the daemon would.
The console reads either published deployment — ?deployment=mainnet for the one that underwrites
real Aave history, where the claims about real borrowers live, dozens of them, built by the daily
live suite and the mainnet demo. It pages through them twenty-five at a time, and a sweep of a
mainnet claim runs from the browser against Ethereum mainnet in ten-thousand-block pieces, the
size both default endpoints serve; Sepolia's are swept in five-hundred-block pieces because
publicnode stops answering past that. The daemon uses the same table, so the page and the daemon
cannot reach different verdicts by asking in different pieces.
A claim has an address. ?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. And a visitor with two wallets
installed is asked which: the page listens for EIP-6963 announcements and names every wallet that
answers, instead of taking whichever one grabbed window.ethereum last. A wallet that only does
the old thing still connects.
Selecting a claim opens it: every member decoded from the ordering key the registry stores — source block, transaction index, log index — with the block linked into the source chain's explorer and a pointer to Creditcoin's own oracle dashboard, where the verification the precompile emitted for it sits by source height. The registry keeps only the key, so the block is what can be linked; the transaction at that index inside it is the one. "Read it back yourself" is a click now.
Seventy-odd links sit between the top of that page and the one button that matters to a keyboard user, so the first Tab lands on a skip link and Enter puts focus on the sweep itself.
"A page open in a tab is not a daemon" is under Known limits, and it stayed true after the console
shipped: the page makes refuting available to anyone and makes nobody do it. The smallest thing
that does it without being paid is .github/workflows/watch.yml — every hour, a dry sweep of both
published registries, the same union across independent endpoints the daemon and the console run,
and a red run if a sealed claim is short of an event. The same hour, the same workflow opens the
published console in a real browser and asks what a visitor would: it loads, it says which chain it
is on, the block it shows is this hour's, both deployments read, no pane is broken, and the page
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.
It holds no key. npm run watch -- --dry reads and never signs, so it no longer asks for one, and a
public repository can run it with nothing in its secrets. The red run is the alert: somebody sealed
a lie and nobody has taken the bond yet, and whoever reads that with a key and a few minutes is
paid half the bond to act on it. Or give it the key: a fork that sets a WATCH_PRIVATE_KEY secret
(any account with a little CTC for gas) runs the same hourly sweep without --dry, refutes what it
finds, and is paid the refuter's share — a watcher that costs a repository and nothing else.
The scripts could always do this, and that was the problem: being underwritten meant cloning a
repository, filling in a .env and running TypeScript. The Borrow pane is the same flow with the
visitor's own wallet — bind your address (it will send the control commitment on the source chain
for you, switching the wallet there and back), build the volume and clean claims, wait out their
challenge window, open the line, draw.
Nothing there is a shortcut around the protocol. The claims it builds are ordinary claims: swept
across independent endpoints, proven event by event through 0x0FD2, bonded, sealed, refutable by
the watcher in the pane above, and finalized only once 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.
npm run web:static bakes the ABIs and the deployment record into the page and writes four files.
There is no server in the published build at all, and the browser tests assert exactly that: the
page boots, reads the live chain, and asks its host for nothing but 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.
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.
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.
.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
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
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, captured from a recorded run
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 console — everything on it is read from CC3 as the page draws it
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 — what a link preview shows
build-static.ts the published build: four files and a picture, no server, ABIs baked in
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
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 # 136 forge tests, five of them invariants
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 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 -- <registry> <credit> <lineId> # resume an interrupted run
npm run watch # the watcher; --once to sweep and exit, --dry to look without acting
# (--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 # 107 guards asserted against the live chain, refunds included
npm run web # the console on http://127.0.0.1:5173 — 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: four files, 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 recordedRun 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:<your address>
| 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.
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:
eth_callfirst. 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.- Then
eth_estimateGas, with the usual 35% buffer, when the node will answer. - 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
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 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.
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.
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.
136 tests, 9 of them fuzzed and 5 of them invariants over random sequences. Everything below runs with forge test, no key and no network.
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.
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.
CI also refuses a push that drops line coverage under 90%; it reads 97.69% today, and the table below is that number.
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 52 errors these contracts can revert with, 21 are named by a unit test, 8 by the live suite,
and 2 by another script in the loop. The remaining 21 are named nowhere. Most are behind openLine,
which is behind proveControl, which is behind 0x0FD2: reaching them means a real line on a real
chain, so the live suite reaches what it can — a settled line refuses a draw, a second settlement
and a default, and an unopened line refuses a draw — and the rest are reached only by the full
loop's happy path.
One of the 52 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 now reads:
| File | Lines | Functions |
|---|---|---|
src/UtuhCredit.sol |
97.48% (232/238) | 100.00% (35/35) |
src/UtuhRegistry.sol |
97.45% (153/157) | 100.00% (21/21) |
src/lib/EventScope.sol |
100.00% (25/25) | 100.00% (6/6) |
src/source/SettlementLedger.sol |
100.00% (8/8) | 100.00% (2/2) |
| Total | 97.69% | 100.00% |
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. Branch coverage is 58%, and that is the honest number to look at next: the uncovered branches are mostly revert arms of guards whose other side is exercised.
npm run livetest is the one that reaches furthest: 107 guards, most of them staticCalls 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.
npm run check is what CI runs: forge fmt --check, the 136 tests, tsc --noEmit, and Slither.
Slither reports 0 findings, 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:
_payis flagged for sending to an arbitrary destination. Every call site passesmsg.sender, and each sets its state before calling, so a payee that reenters finds the work already done.appendBatch,refuteandproveControlwrite state after calling the Block Prover.0x0FD2is a Substrate runtime native with no bytecode —eth_getCodereturns0x— 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.
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.
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:
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.
| 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 |
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 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 |
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.
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
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.
-
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 forty blocks' worth. Beyond the point where that is affordable, the array becomes an incremental Merkle root and the refuter supplies an adjacency proof of the two members bracketing the gap.
-
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 slow enough to matter. Building a proof locally costs tens of seconds against roughly one for the hosted service, because it re-fetches every sibling transaction in the block and every block in the continuity range. It is correct, and it is what makes refutation independent of a hosted service at all, but a challenge window near the 20-block floor leaves a refuter on that path with almost no margin. Measure it for your own endpoints with
npm run proversbefore choosing a window. -
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: falsewith 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. 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 against0x0FD3rather than assumed. -
A watcher that was switched off does not get to skip what it missed. It used to start
WATCH_LOOKBACKblocks 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
awaitmeant 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_getLogsnumbers 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 historicaleth_getLogswhile 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.tssweeps 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, thenpublicnode=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.ioand the Ethereum Foundation'sethpandaops— while onfinality rate-limited after one call, thirdweb refused the deep window, 1rpc capseth_getLogs, and the rest refused, timed out, or wanted a key.0xrpc.iohad one more lesson in it: its front door answers any request without aUser-Agentwith 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 whynpm run doctorreports 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.
EventScopecannot 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 andopenLinedemands 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
enforceableLossnecessary. 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. -
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 credittherefore stops atSubjectNotControlledwhen pointed at a stranger's history — the refusal is the demonstration.
MIT. See LICENSE; every Solidity file carries the matching SPDX header.