
citable
Prove you quoted correctly — per-paragraph position proofs on chain, ENS-named, any language.
Demo
About
Citable is a provenance register for language. It does not try to answer "is this quote real?" — that question has no denominator, because nobody knows what share of everything a person ever said is available to check against. Citable inverts the burden of proof and answers a question that is actually decidable: "here is the proof that I quoted correctly." An author registers a statement. The text is split into paragraphs at blank lines, each paragraph is bound to its position and hashed into a merkle tree, and only the 32-byte root goes on chain — together with the author address, an ENS node, a timestamp, and the IPFS CID of a bundle holding the full text. The index sits inside the leaf: leaf = keccak256(keccak256(abi.encode(uint256 index, string segment))). That detail is the entire point. Sibling pairs get sorted when the tree is built, which discards their order, so without the index in the leaf a proof would show membership but not position — and position is what quoting out of context destroys. Verification runs as a cascade of three stages, and the interface never lets them blur into each other: 1. Wording — is this paragraph in the statement, and where? A merkle proof, verified by the contract. Paragraph 5 of 7, with the neighbouring paragraphs shown next to it. Change a single character and the proof fails. 2. Excerpt — is the fragment cut out of a registered paragraph? A fact, checked locally, with the full paragraph displayed beside the shortened quote. This is the common case of honest-looking distortion: the words are real, the meaning is not. 3. Coverage — is the claim covered by the text, in another language or other words? A measurement, explicitly labelled as one, never as a proof. Stage 3 exists because a translation shares no bytes with its original. It runs entirely in the browser: embeddings (multilingual-e5-small) pick candidate paragraphs, a deterministic value check throws out claims that assert numbers or persons the paragraph never mentions, and an NLI model (mDeBERTa-v3-base-xnli) judges whether the claim is actually entailed by the text. Typing "Revenue rose by four percent last quarter." against a German paragraph scores 0.8253 entailment and reports paragraph 3 of 6. Changing one word to "forty percent" is not scored at all — the value check sees a 40 in a paragraph that says four and never lets the model near it. That design came out of a measurement, not a preference. Plain semantic similarity rated an invented confession ("Roth admitted the figures were manipulated") at 0.880 — higher than a faithful English translation of the same paragraph at 0.877. Similarity models measure what is being talked about, not what is being claimed. Built on similarity, the percentage would have endorsed the disinformation in exactly the case the project exists for. Entailment plus the value check separates the strongest false quote from the threshold by 0.235 instead of 0.072. A percentage from stage 3 means "no distorted quote in our 16-case test set reached this score" — not "83% true". The paragraph is always shown in full next to the number, so the reader judges, not the model. Identity comes from ENS. A signature alone is anonymous, and "key 0x7f3a… said this" convinces no reader. registerRoot is gated by an INameGuard, implemented against the real ENSv2 registry on Sepolia: the name holder may publish, and so may anyone the holder granted the publish role to — which is what a newsroom actually looks like, a volunteer publishing under the paper's name without owning it. The limits are on the landing page, not behind it. Stage 3 can be wrong. Spanish scores 0.562 against 0.821 for English and would be rejected at threshold 0.80. Irony and quote-within-quote are not handled. "Not registered" does not mean "fabricated" — and when nothing matches, the app says so with its denominator visible, naming how many statements and paragraphs it compared against.
How it's made
Contracts in Solidity with Foundry. CitableRegistry stores one record per statement — root, author, ensNode, timestamp, CID, segment count — and verifies position proofs on chain. Registration is gated by an INameGuard interface; ENSv2NameGuard implements it against the live ENSv2 registry on Sepolia. Both are deployed and verified on Etherscan, and all three registered statements went out with the guard armed. The single biggest technical risk is that the JavaScript and the Solidity disagree about a hash. If they ever drift, every statement already registered becomes unverifiable. So there is no hardcoded fixture anywhere: test/Leaf.t.sol shells out via Foundry FFI to script/js/leaf.mjs and compares the result against Leaf.leafOf, and test/ProofBridge.t.sol lets the JS segment a text, build the tree and produce a proof, then hands that proof to the contract. Segmentation, tree shape, pair sorting and the leaf formula are proven identical by forge test, not assumed. 55 Solidity tests, 60 JS tests for the client library. The client library (lib/citable/) is plain ESM on viem and merkletreejs, and the frontend imports it through an alias rather than copying it — a second copy of the leaf formula is exactly the drift the bridge test exists to prevent. Frontend is Next.js 16 with Tailwind 4, exported as fully static routes. There is no database, no server, no indexer: search by ENS name reads logs by the indexed ensNode topic straight from an RPC endpoint, the bundle is fetched from IPFS, and the tree is rebuilt in the browser. Verifying requires trusting no service at all. Three things were harder than expected and are worth naming: ENSv2 token ids are not labelhashes. The registry keeps a tokenVersionId in the lower 32 bits of the id, so ownerOf(labelhash(name)) returns 0x0 for every name that exists — a live Sepolia name currently sits at version 9. The guard therefore never derives an id; it asks the registry, which resolves the version itself. script/js/ens-probe.mjs proves this against names discovered from mint events, and a Sepolia fork test asserts the delegation path rather than assuming it. Registering the name itself was a second detour: the ENSv2 registrar on Sepolia prices in a test USDC that has a public mint, which is why wochenzeitung.eth could be acquired properly and the guard never had to be switched off. script/js/ens-register.mjs makes that path reproducible. transformers.js cannot be bundled here. env.js reads fs at import time to decide whether it is running on a server; Turbopack compiles a bare node builtin in a browser bundle to void 0, and Object.keys(void 0) kills the library before the first model byte arrives. resolveAlias does not help — the substitution happens before resolution. So stage 3 loads the library from jsDelivr and the weights from huggingface.co at runtime, about 400 MB on first use and only after the button is pressed. That is a real third-party dependency and it is stated on the page. Stages 1 and 2 depend on nothing but the chain and the bytes. CIDs are computed without kubo. A bundle below the chunk size is a single raw block, so its CID is sha256 with a four-byte header — verified against kubo 0.43.0, with its own vectors in the test suite. Above that size it throws rather than guessing a CID that points nowhere. publish.mjs refuses to broadcast until the CID this repo computes equals the one kubo computes, the bytes read back out of IPFS rebuild the root, and the deployed contract accepts a proof at the right index while rejecting the same segment at a different one. Availability is treated as a race, not a hierarchy. Public gateways, a Pinata dedicated gateway, and the app's own copy are all tried at once, and the first source whose bytes rebuild the on-chain root wins. A source serving anything else loses the race instead of being believed — which is why the app may ship its own copy at all: it buys reachability, not trust, and the screen names which source answered. Outage and forgery stay separate: "nobody answered" proves nothing, "someone answered with wrong bytes" is an accusation. One deliberate exception to the no-server rule: a browser must not hold a pinning key, so api/pin.mjs is a Vercel function that pins on the author's behalf before the transaction is offered. The order is the whole fix — earlier, the screen asked authors to download the bundle and pin it by hand, and nobody does that, which is how three statements ended up on chain with CIDs no node serves. The function accepts a bundle only if it rebuilds the root the client states, using the same verifyBundle a verifier runs, and pins the received bytes verbatim rather than a re-serialised object, because a round trip through an object could move the CID. The browser then compares the CID it gets back with the one it computed itself and refuses to register if they differ. It is a second point of centralisation next to the registry owner: it can refuse, it cannot forge. Partner technologies: ENS (ENSv2 on Sepolia) supplies the identity layer without which a proof of authorship convinces no reader; IPFS holds the full text addressed by its own hash, so the chain only has to carry 32 bytes; Vercel hosts the static export plus the single pinning function. AI usage, declared honestly: contracts, frontend and tests were developed with Claude Code. Architecture, design decisions and verification by me.