Verify a signature
Recover the attester yourself — the shipped EIP-191 scheme in twenty lines, and the EIP-712 registry check that replaces it.
Argued in full in the whitepaper at §7.2.
Verification is a pure function of the payload and the signature. No oracle call, no API call, no external dependency — that is the whole point of publishing a signature rather than a number.
What is signed today#
secp256k1 over an EIP-191 message — personal_sign — not EIP-712. The message is canonical JSON: keys sorted alphabetically, every value a string.
{"chainId":"4663","confidence":"0.65","expiresAt":"1787411823","horizonDays":"90","issuedAt":"1787325423","pool":"0xbeeff033f34c046626b8d0a041844c5d1a5409dd","v":"1"}Key order is alphabetical, so serialisation never depends on insertion order. The signature covers exactly these seven fields — symbol, protocol, outcome and the rest of the API response are convenience, not claims.
Off chain#
import { recoverMessageAddress } from "viem";
function canonical(a) {
const f = {
chainId: String(a.chainId),
confidence: Number(a.confidence).toFixed(2),
expiresAt: String(a.expiry),
horizonDays: String(a.horizon),
issuedAt: String(a.issuedAt),
pool: a.pool.toLowerCase(),
v: "1",
};
return `{${Object.keys(f).sort().map((k) => `"${k}":"${f[k]}"`).join(",")}}`;
}
const a = await (await fetch(`https://dapp.cleaton.xyz/api/v1/pool/${pool}`)).json();
const signer = await recoverMessageAddress({
message: canonical(a),
signature: a.signature,
});
// The attester Cleaton publishes, from GET /api/v1/attester.
const expected = "0xb6486340a930e21b7ee505111188e929120d9eda";
const authentic = signer.toLowerCase() === expected;
const fresh = a.expiry > Math.floor(Date.now() / 1000);That is the whole check. Do not compare against the attester field in the response — an attestation that carries its own idea of who signed it proves nothing. Take the expected address from GET /api/v1/attester, or pin it.
Or let us do it#
Convenience, not authority — it is the same recovery, run on our side, and worth exactly as much as trusting the host. Use it to check your own implementation against, then stop using it.
curl -s https://dapp.cleaton.xyz/api/v1/pool/$POOL \
| curl -s -X POST https://dapp.cleaton.xyz/api/v1/verify \
-H "Content-Type: application/json" --data-binary @-
# {"valid":true,"attester":"0xb648...9eda","expired":false,"canonical":"{...}"}valid and expired are separate answers on purpose. An expired attestation is still authentically signed, and a consumer checking a historical claim — or backtesting the record — needs the first without the second.
On chain, when the registry ships#
function verify(Attestation calldata a, bytes calldata sig)
external view returns (bool valid, address attester)
{
bytes32 digest = _hashTypedDataV4(_structHash(a));
attester = ECDSA.recover(digest, sig);
valid = registeredAttester[attester]
&& block.timestamp <= a.validUntil
&& bondOf[attester] >= minBond
&& !revoked[digest];
}About 30k gas — a deliberate target. A deposit guard that costs more than the deposit it protects will not be adopted, so verification had to be cheap enough to sit in the hot path rather than in a periodic job.
Note what the check covers beyond the signature itself:
registeredAttester— the key is one the registry knowsvalidUntil— the attestation is not stalebondOf >= minBond— the attester is still economically backed!revoked— the attestation was not withdrawn early
Both schemes are secp256k1, so the on-chain move is a change of digest, not of primitive — and it is the same primitive the wallet sign-in uses, so the system carries one crypto scheme rather than two.
What verification does not tell you#
That the claim is right. A valid signature says Cleaton published this number and can be held to it. Whether the number was any good is the accuracy record’s job, and today that record is empty — no attestation has reached its horizon and been judged.
The three registry checks above are also absent off chain. There is no bond backing a published attestation yet, and nothing revokes one.