From f4568637c857cef1db2b89a155f58848eb6b3f4e Mon Sep 17 00:00:00 2001 From: Agozie180 Date: Thu, 23 Jul 2026 23:44:10 +0100 Subject: [PATCH 1/3] Autonomous events, claim-consistency gate, and API hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autonomous pipeline (POST /v1/events): an agent fires one event when it acts; RealityCheck collects evidence, verifies, signs a Reality Receipt, records it in the ledger, and delivers it to the callbackUrl — signed over the exact bytes, with an SSRF guard on the callback target. The claim-consistency gate closes the receipt-laundering hole: the spoken amount in the natural-language claim is checked against on-chain evidence, so an agent that says "100 USDT" while attaching a weaker structured claim gets REFUTED, not a signed receipt for its words. Hardening: - Fact-addressed attestation ids (fetch timestamps excluded from the hash) so re-running a check cannot inflate a subject's track record - Per-claim subjects for UNVERIFIABLE verdicts instead of a shared zero hash - Sliding-window rate limiting on all POST routes (60/min/IP) - GET /v1/events redacted to summaries; full records only by unguessable id - Production boot guard: refuse to start without a stable attester seed and a durable ledger URL - Drop the duplicate confidence field from receipts (realityScore stands) Terminology: trust signals are now the verified track record — TrustSignals→TrackRecord, trustScore→realityScore, /v1/trust/:subject→/v1/track-record/:subject. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/events/callback-guard.test.ts | 50 +++ .../api/src/features/events/callback-guard.ts | 125 +++++++ apps/api/src/features/events/pipeline.ts | 181 ++++++++++ apps/api/src/features/events/routes.test.ts | 321 ++++++++++++++++++ apps/api/src/features/events/routes.ts | 128 +++++++ apps/api/src/features/events/schemas.ts | 24 ++ apps/api/src/features/events/store.ts | 74 ++++ apps/api/src/features/provenance/routes.ts | 10 +- .../features/verify/claim-consistency.test.ts | 105 ++++++ .../src/features/verify/claim-consistency.ts | 181 ++++++++++ apps/api/src/features/verify/marketplace.ts | 53 ++- apps/api/src/features/verify/routes.test.ts | 6 +- apps/api/src/features/verify/routes.ts | 6 +- apps/api/src/main.ts | 25 ++ apps/api/src/rate-limit.test.ts | 24 ++ apps/api/src/rate-limit.ts | 74 ++++ apps/api/src/server.ts | 29 ++ packages/domain/src/index.ts | 2 +- packages/reality-core/src/attestation.ts | 30 +- packages/reality-core/src/index.ts | 4 +- .../reality-core/src/reality-core.test.ts | 47 ++- packages/reality-core/src/reality-core.ts | 23 +- .../src/{trust.ts => track-record.ts} | 13 +- packages/reality-core/src/types.ts | 12 +- 24 files changed, 1493 insertions(+), 54 deletions(-) create mode 100644 apps/api/src/features/events/callback-guard.test.ts create mode 100644 apps/api/src/features/events/callback-guard.ts create mode 100644 apps/api/src/features/events/pipeline.ts create mode 100644 apps/api/src/features/events/routes.test.ts create mode 100644 apps/api/src/features/events/routes.ts create mode 100644 apps/api/src/features/events/schemas.ts create mode 100644 apps/api/src/features/events/store.ts create mode 100644 apps/api/src/features/verify/claim-consistency.test.ts create mode 100644 apps/api/src/features/verify/claim-consistency.ts create mode 100644 apps/api/src/rate-limit.test.ts create mode 100644 apps/api/src/rate-limit.ts rename packages/reality-core/src/{trust.ts => track-record.ts} (66%) diff --git a/apps/api/src/features/events/callback-guard.test.ts b/apps/api/src/features/events/callback-guard.test.ts new file mode 100644 index 0000000..dc81b60 --- /dev/null +++ b/apps/api/src/features/events/callback-guard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { checkCallbackUrl } from "./callback-guard.js"; + +describe("checkCallbackUrl", () => { + it("allows public https and http endpoints", () => { + expect(checkCallbackUrl("https://asp.example.com/hooks/rc").ok).toBe(true); + expect(checkCallbackUrl("http://203.0.113.10:8443/hook").ok).toBe(true); + }); + + it("rejects non-http protocols", () => { + expect(checkCallbackUrl("ftp://example.com/x").ok).toBe(false); + expect(checkCallbackUrl("file:///etc/passwd").ok).toBe(false); + expect(checkCallbackUrl("gopher://example.com").ok).toBe(false); + }); + + it("rejects loopback and localhost", () => { + expect(checkCallbackUrl("http://localhost/hook").ok).toBe(false); + expect(checkCallbackUrl("http://127.0.0.1/hook").ok).toBe(false); + expect(checkCallbackUrl("http://127.9.9.9/hook").ok).toBe(false); + expect(checkCallbackUrl("http://[::1]/hook").ok).toBe(false); + }); + + it("rejects the cloud metadata address", () => { + expect(checkCallbackUrl("http://169.254.169.254/latest/meta-data/").ok).toBe(false); + }); + + it("rejects RFC1918 private ranges", () => { + expect(checkCallbackUrl("http://10.0.0.5/hook").ok).toBe(false); + expect(checkCallbackUrl("http://192.168.1.1/hook").ok).toBe(false); + expect(checkCallbackUrl("http://172.16.0.1/hook").ok).toBe(false); + expect(checkCallbackUrl("http://172.31.255.255/hook").ok).toBe(false); + }); + + it("allows public IPs adjacent to private ranges", () => { + expect(checkCallbackUrl("http://172.15.0.1/hook").ok).toBe(true); + expect(checkCallbackUrl("http://172.32.0.1/hook").ok).toBe(true); + }); + + it("rejects IPv6 unique-local and link-local", () => { + expect(checkCallbackUrl("http://[fd00::1]/hook").ok).toBe(false); + expect(checkCallbackUrl("http://[fe80::1]/hook").ok).toBe(false); + expect(checkCallbackUrl("http://[::ffff:127.0.0.1]/hook").ok).toBe(false); + }); + + it("rejects internal-looking hostnames and embedded credentials", () => { + expect(checkCallbackUrl("http://db.internal/hook").ok).toBe(false); + expect(checkCallbackUrl("http://svc.local/hook").ok).toBe(false); + expect(checkCallbackUrl("https://user:pass@example.com/hook").ok).toBe(false); + }); +}); diff --git a/apps/api/src/features/events/callback-guard.ts b/apps/api/src/features/events/callback-guard.ts new file mode 100644 index 0000000..2b74a82 --- /dev/null +++ b/apps/api/src/features/events/callback-guard.ts @@ -0,0 +1,125 @@ +/** + * SSRF guard for ASP-supplied callbackUrls. + * + * POST /v1/events is unauthenticated, and the pipeline will POST the signed + * receipt to whatever callbackUrl the caller supplies. Without this guard a + * caller could point us at cloud metadata (169.254.169.254), loopback, or + * internal services and use RealityCheck as an SSRF proxy. We reject anything + * that isn't a public http(s) endpoint. + * + * This validates the URL's literal host. It does NOT defend against a public + * hostname whose DNS later resolves to a private IP (DNS-rebinding / TOCTOU); + * that needs resolve-then-pin at fetch time and is noted as a follow-up. + */ + +export interface CallbackCheck { + readonly ok: boolean; + readonly reason?: string; +} + +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); + +export function checkCallbackUrl(raw: string): CallbackCheck { + let url: URL; + try { + url = new URL(raw); + } catch { + return { ok: false, reason: "callbackUrl is not a valid URL." }; + } + + if (!ALLOWED_PROTOCOLS.has(url.protocol)) { + return { ok: false, reason: `callbackUrl protocol ${url.protocol} is not allowed; use http or https.` }; + } + + // Credentials in the URL are a common SSRF/exfil vector and never legitimate here. + if (url.username !== "" || url.password !== "") { + return { ok: false, reason: "callbackUrl must not contain credentials." }; + } + + const host = url.hostname.toLowerCase(); + if (host === "") { + return { ok: false, reason: "callbackUrl must have a host." }; + } + + // Block obvious internal names outright. + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal") || host.endsWith(".local")) { + return { ok: false, reason: "callbackUrl host resolves to an internal/loopback address." }; + } + + const ip = parseIpLiteral(host); + if (ip !== null && !isPublicIp(ip)) { + return { ok: false, reason: "callbackUrl host is a private, loopback, or link-local address." }; + } + + return { ok: true }; +} + +interface ParsedIp { + readonly family: 4 | 6; + readonly v4?: [number, number, number, number]; + readonly v6Raw?: string; +} + +/** Parse a bracketed or bare IP literal; null if the host is a DNS name. */ +function parseIpLiteral(host: string): ParsedIp | null { + const stripped = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + const v4 = stripped.split("."); + if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p))) { + const octets = v4.map((p) => Number.parseInt(p, 10)); + if (octets.every((n) => n >= 0 && n <= 255)) { + return { family: 4, v4: octets as [number, number, number, number] }; + } + } + if (stripped.includes(":")) return { family: 6, v6Raw: stripped.toLowerCase() }; + return null; +} + +function isPublicIp(ip: ParsedIp): boolean { + if (ip.family === 4 && ip.v4) { + const [a, b] = ip.v4; + if (a === 10) return false; // 10.0.0.0/8 + if (a === 127) return false; // loopback + if (a === 0) return false; // 0.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12 + if (a === 192 && b === 168) return false; // 192.168.0.0/16 + if (a === 169 && b === 254) return false; // link-local incl. 169.254.169.254 metadata + if (a === 100 && b >= 64 && b <= 127) return false; // 100.64.0.0/10 CGNAT + if (a >= 224) return false; // multicast / reserved + return true; + } + // IPv6: block loopback, unspecified, unique-local (fc00::/7), link-local + // (fe80::/10), and IPv4-mapped internal addresses. + const v6 = ip.v6Raw ?? ""; + if (v6 === "::1" || v6 === "::") return false; + if (v6.startsWith("fe8") || v6.startsWith("fe9") || v6.startsWith("fea") || v6.startsWith("feb")) return false; + if (v6.startsWith("fc") || v6.startsWith("fd")) return false; + // IPv4-mapped (::ffff:a.b.c.d). Node normalizes the dotted tail to hex + // (::ffff:7f00:1), so decode the trailing 32 bits from either form. + if (v6.startsWith("::ffff:")) { + const mapped = mappedV4(v6.slice("::ffff:".length)); + if (mapped !== null) return isPublicIp(mapped); + } + return true; +} + +/** Decode the IPv4 tail of an ::ffff: address, dotted or hex (a:b). */ +function mappedV4(tail: string): ParsedIp | null { + const dotted = parseIpLiteral(tail); + if (dotted !== null && dotted.family === 4) return dotted; + const [hiGroup, loGroup, ...rest] = tail.split(":"); + if ( + rest.length === 0 && + hiGroup !== undefined && + loGroup !== undefined && + /^[0-9a-f]{1,4}$/.test(hiGroup) && + /^[0-9a-f]{1,4}$/.test(loGroup) + ) { + const hi = Number.parseInt(hiGroup, 16); + const lo = Number.parseInt(loGroup, 16); + return { + family: 4, + v4: [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff], + }; + } + return null; +} diff --git a/apps/api/src/features/events/pipeline.ts b/apps/api/src/features/events/pipeline.ts new file mode 100644 index 0000000..5b0e298 --- /dev/null +++ b/apps/api/src/features/events/pipeline.ts @@ -0,0 +1,181 @@ +import type { RealityCheck } from "@realitycheck/core"; +import type { Claim } from "@realitycheck/domain"; +import type { RealityCore } from "@realitycheck/reality-core"; +import { claimFromEnvelope, resultToReceipt, unverifiableFromEnvelope } from "../verify/marketplace.js"; +import { enforceClaimConsistency } from "../verify/claim-consistency.js"; +import { verifyBodySchema } from "../verify/schemas.js"; +import type { DeliveryRecord, EventRecord } from "./store.js"; + +/** Injectable HTTP client so tests never touch the network. */ +export type DeliveryFetch = ( + url: string, + init: { method: string; headers: Record; body: string }, +) => Promise<{ ok: boolean; status: number }>; + +export interface PipelineDeps { + readonly engine: RealityCheck; + readonly core: RealityCore; + readonly fetchImpl?: DeliveryFetch; + /** Injectable backoff sleeper so tests don't wait on real timers. */ + readonly sleep?: (ms: number) => Promise; + /** Called with pipeline errors — wire to the server logger. */ + readonly onError?: (error: unknown, eventId: string) => void; +} + +const DELIVERY_ATTEMPTS = 3; +const DELIVERY_TIMEOUT_MS = 5_000; +const DELIVERY_BACKOFF_MS = [250, 1_000]; + +function defaultFetch(): DeliveryFetch { + return async (url, init) => { + const res = await fetch(url, { ...init, signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS) }); + return { ok: res.ok, status: res.status }; + }; +} + +const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Only transient failures are worth retrying; a 4xx (other than 429) won't fix itself. */ +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +/** + * The autonomous pipeline: an agent event goes in, a signed Reality Receipt + * comes out — no human in the loop. Verify against reality, record the + * attestation in the ledger, then push the receipt to the ASP's callbackUrl. + * + * The same honesty rules apply as everywhere else: no objective evidence in + * the event means UNVERIFIABLE, never a guess. Pipeline failures mark the + * event FAILED — they are infrastructure errors, not verdicts. + */ +export async function runEventPipeline(record: EventRecord, deps: PipelineDeps): Promise { + try { + record.status = "VERIFYING"; + const candidate = claimFromEnvelope(record.event); + const objective = candidate === null ? null : verifyBodySchema.safeParse(candidate); + const result = objective?.success + ? enforceClaimConsistency(record.event.claim, await deps.engine.verify(objective.data as Claim)) + : unverifiableFromEnvelope(record.event, record.id); + + const attestation = await deps.core.record(result); + const receipt = resultToReceipt({ + agentName: record.event.agentName, + naturalLanguageClaim: record.event.claim, + result, + attestation, + }); + + record.result = result; + record.attestation = attestation; + record.receipt = receipt; + // The receipt exists and is in the durable ledger — the event is done. + // Delivery is a separate, best-effort concern; a slow or dead callbackUrl + // must never hold the verdict hostage. + record.completedAtIso = new Date().toISOString(); + record.delivery = initialDelivery(record.event.callbackUrl); + record.status = "COMPLETED"; + } catch (error) { + record.status = "FAILED"; + record.error = error instanceof Error ? error.message : "pipeline failed"; + record.completedAtIso = new Date().toISOString(); + deps.onError?.(error, record.id); + return; + } + + // Delivery runs after COMPLETED so pollers see the receipt immediately. + if (record.event.callbackUrl !== undefined) { + await deliver(record, deps); + } +} + +/** The delivery state a freshly-completed event starts in. */ +function initialDelivery(callbackUrl: string | undefined): DeliveryRecord { + if (callbackUrl === undefined) { + return { + url: "", + status: "SKIPPED", + attempts: 0, + detail: "No callbackUrl on the event; receipt available via GET /v1/events/:id.", + deliveredAtIso: null, + }; + } + return { + url: callbackUrl, + status: "PENDING", + attempts: 0, + detail: "Receipt signed; delivery in progress.", + deliveredAtIso: null, + }; +} + +/** + * Push the signed receipt to the callbackUrl with bounded, backed-off retries. + * Mutates record.delivery in place; the event is already COMPLETED regardless + * of the outcome. Retries only transient failures (network / 5xx / 429). + */ +async function deliver(record: EventRecord, deps: PipelineDeps): Promise { + const url = record.event.callbackUrl; + if (url === undefined) return; + const send = deps.fetchImpl ?? defaultFetch(); + const sleep = deps.sleep ?? defaultSleep; + + const body = JSON.stringify({ + eventId: record.id, + receipt: record.receipt, + attestation: record.attestation, + }); + // Sign the exact bytes we send with the attester key. The ASP verifies this + // against the attesterPublicKey it already knows (GET /v1/health), so it + // knows the delivery genuinely came from RealityCheck and wasn't forged or + // tampered with in flight. + const signed = deps.core.signPayload(body); + const signatureHeaders = { + "content-type": "application/json", + "x-realitycheck-signature": signed.signature, + "x-realitycheck-public-key": signed.publicKey, + }; + + let lastDetail = ""; + for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) { + try { + const res = await send(url, { + method: "POST", + headers: signatureHeaders, + body, + }); + if (res.ok) { + record.delivery = { + url, + status: "DELIVERED", + attempts: attempt, + detail: `Receipt delivered (HTTP ${res.status}).`, + deliveredAtIso: new Date().toISOString(), + }; + return; + } + lastDetail = `HTTP ${res.status}`; + if (!isRetryableStatus(res.status)) { + record.delivery = { + url, + status: "FAILED", + attempts: attempt, + detail: `Delivery rejected (${lastDetail}); not retryable. Receipt remains available via GET /v1/events/:id.`, + deliveredAtIso: null, + }; + return; + } + } catch (error) { + lastDetail = error instanceof Error ? error.message : "request failed"; + } + const backoff = DELIVERY_BACKOFF_MS[attempt - 1]; + if (attempt < DELIVERY_ATTEMPTS && backoff !== undefined) await sleep(backoff); + } + record.delivery = { + url, + status: "FAILED", + attempts: DELIVERY_ATTEMPTS, + detail: `Delivery failed after ${DELIVERY_ATTEMPTS} attempts: ${lastDetail}. Receipt remains available via GET /v1/events/:id.`, + deliveredAtIso: null, + }; +} diff --git a/apps/api/src/features/events/routes.test.ts b/apps/api/src/features/events/routes.test.ts new file mode 100644 index 0000000..986f870 --- /dev/null +++ b/apps/api/src/features/events/routes.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "vitest"; +import { RealityCheck } from "@realitycheck/core"; +import { VerifierRegistry } from "@realitycheck/chains"; +import { RealityCore, verifySignature } from "@realitycheck/reality-core"; +import { loadConfig } from "@realitycheck/config"; +import { buildServer } from "../../server.js"; +import type { DeliveryFetch } from "./pipeline.js"; +import type { EventRecord } from "./store.js"; + +const TX = `0x${"cd".repeat(32)}`; + +// Empty registry → INDETERMINATE verdicts, zero network. The delivery fetch is +// injected, so callback behaviour is fully observable without sockets. The +// onSettled seam turns the fire-and-forget pipeline into something awaitable. +async function makeServer(fetchImpl?: DeliveryFetch) { + const engine = new RealityCheck(new VerifierRegistry()); + const core = await RealityCore.create(loadConfig({})); + + let resolveSettled!: (record: EventRecord) => void; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + + const server = buildServer({ + engine, + core, + chains: [], + events: { + ...(fetchImpl !== undefined ? { fetchImpl } : {}), + sleep: async () => {}, // no real backoff in tests + onSettled: (record) => resolveSettled(record), + }, + }); + return { server, settled }; +} + +describe("POST /v1/events", () => { + it("returns 400 for an event without agentName", async () => { + const { server } = await makeServer(); + const res = await server.inject({ + method: "POST", + url: "/v1/events", + payload: { claim: "I transferred funds." }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("INVALID_CLAIM"); + await server.close(); + }); + + it("accepts an event with 202 and completes the pipeline autonomously", async () => { + const { server, settled } = await makeServer(); + const res = await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Wallet Agent", + claim: "I transferred funds to the recipient.", + metadata: { claim: { kind: "TX_SUCCESS", chain: "xlayer-mainnet", txHash: TX } }, + }, + }); + expect(res.statusCode).toBe(202); + const accepted = res.json(); + expect(accepted.eventId).toMatch(/^evt_/); + expect(accepted.poll).toBe(`/v1/events/${accepted.eventId}`); + + const record = await settled; + expect(record.status).toBe("COMPLETED"); + expect(record.receipt?.agentName).toBe("Wallet Agent"); + expect(record.attestation?.id).toMatch(/^rc_/); + // No callbackUrl → delivery is skipped, never attempted. + expect(record.delivery?.status).toBe("SKIPPED"); + + // The receipt is retrievable by polling. + const poll = await server.inject({ method: "GET", url: accepted.poll }); + expect(poll.statusCode).toBe(200); + expect(poll.json().status).toBe("COMPLETED"); + + // The verification landed in the durable attestation ledger. + const attestations = await server.inject({ method: "GET", url: "/v1/attestations?limit=5" }); + expect(attestations.json().some((a: { id: string }) => a.id === record.attestation?.id)).toBe( + true, + ); + await server.close(); + }); + + it("pushes the signed receipt to the callbackUrl when given", async () => { + const calls: { url: string; body: string; headers: Record }[] = []; + const fetchImpl: DeliveryFetch = async (url, init) => { + calls.push({ url, body: init.body, headers: init.headers }); + return { ok: true, status: 200 }; + }; + const { server, settled } = await makeServer(fetchImpl); + await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + metadata: { claim: { kind: "TX_SUCCESS", chain: "xlayer-mainnet", txHash: TX } }, + callbackUrl: "https://asp.example.com/hooks/realitycheck", + }, + }); + + const record = await settled; + expect(record.delivery?.status).toBe("DELIVERED"); + expect(record.delivery?.attempts).toBe(1); + expect(calls).toHaveLength(1); + const delivered = JSON.parse(calls[0]!.body); + expect(delivered.eventId).toBe(record.id); + expect(delivered.receipt.agentName).toBe("Payment Agent"); + expect(delivered.attestation.signature).toBeDefined(); + + // The delivery is signed with the attester key so the ASP can authenticate + // it against the same public key it already uses for attestations. + const sig = calls[0]!.headers["x-realitycheck-signature"]; + const pub = calls[0]!.headers["x-realitycheck-public-key"]; + expect(sig).toBeDefined(); + const info = await server.inject({ method: "GET", url: "/v1/health" }); + expect(pub).toBe(info.json().core.attesterPublicKey); + expect(verifySignature(calls[0]!.body, sig!, pub!)).toBe(true); + // A tampered body must fail verification. + expect(verifySignature(`${calls[0]!.body} `, sig!, pub!)).toBe(false); + await server.close(); + }); + + it("marks the event COMPLETED before a slow callback delivery finishes", async () => { + let releaseDelivery!: () => void; + const deliveryGate = new Promise((resolve) => { + releaseDelivery = resolve; + }); + const fetchImpl: DeliveryFetch = async () => { + await deliveryGate; // hold delivery open + return { ok: true, status: 200 }; + }; + const { server, settled } = await makeServer(fetchImpl); + const res = await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + metadata: { claim: { kind: "TX_SUCCESS", chain: "xlayer-mainnet", txHash: TX } }, + callbackUrl: "https://asp.example.com/hooks/slow", + }, + }); + const { eventId, poll } = res.json(); + + // While delivery is still blocked, the receipt is already COMPLETED and + // delivery is visibly PENDING — the verdict never waits on the callback. + let completed = false; + for (let i = 0; i < 20 && !completed; i += 1) { + const snap = await server.inject({ method: "GET", url: poll }); + const record = snap.json(); + if (record.status === "COMPLETED") { + completed = true; + expect(record.receipt).not.toBeNull(); + expect(record.delivery.status).toBe("PENDING"); + } else { + await new Promise((r) => setTimeout(r, 10)); + } + } + expect(completed).toBe(true); + + releaseDelivery(); + const record = await settled; + expect(record.id).toBe(eventId); + expect(record.delivery?.status).toBe("DELIVERED"); + await server.close(); + }); + + it("does not retry a non-retryable 4xx callback response", async () => { + let attempts = 0; + const fetchImpl: DeliveryFetch = async () => { + attempts += 1; + return { ok: false, status: 400 }; + }; + const { server, settled } = await makeServer(fetchImpl); + await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + callbackUrl: "https://asp.example.com/hooks/bad-request", + }, + }); + const record = await settled; + expect(record.status).toBe("COMPLETED"); + expect(record.delivery?.status).toBe("FAILED"); + expect(attempts).toBe(1); // 400 is terminal — no retries + await server.close(); + }); + + it("retries delivery and records failure without failing the event", async () => { + let attempts = 0; + const fetchImpl: DeliveryFetch = async () => { + attempts += 1; + return { ok: false, status: 503 }; + }; + const { server, settled } = await makeServer(fetchImpl); + await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + callbackUrl: "https://asp.example.com/hooks/down", + }, + }); + + const record = await settled; + // The verification itself still completed — only delivery failed. + expect(record.status).toBe("COMPLETED"); + expect(record.delivery?.status).toBe("FAILED"); + expect(attempts).toBe(3); + expect(record.receipt).not.toBeNull(); + await server.close(); + }); + + it("answers UNVERIFIABLE for events without objective evidence", async () => { + const { server, settled } = await makeServer(); + await server.inject({ + method: "POST", + url: "/v1/events", + payload: { agentName: "Research Agent", claim: "I used fresh market data." }, + }); + const record = await settled; + expect(record.status).toBe("COMPLETED"); + expect(record.receipt?.result).toBe("UNVERIFIABLE"); + expect(record.receipt?.transactionHash).toBeUndefined(); + await server.close(); + }); + + it("400s when an attached claim is malformed instead of silently UNVERIFIABLE", async () => { + const { server } = await makeServer(); + const res = await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + // TX_SUCCESS with a bad txHash — a claim WAS attached, it's just wrong. + metadata: { claim: { kind: "TX_SUCCESS", chain: "xlayer-mainnet", txHash: "not-a-hash" } }, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("INVALID_CLAIM"); + await server.close(); + }); + + it("400s a callbackUrl pointed at an internal address (SSRF guard)", async () => { + const seen: string[] = []; + const fetchImpl: DeliveryFetch = async (url) => { + seen.push(url); + return { ok: true, status: 200 }; + }; + const { server } = await makeServer(fetchImpl); + const res = await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + callbackUrl: "http://169.254.169.254/latest/meta-data/", + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("CALLBACK_URL_FORBIDDEN"); + // The guard runs before ingest, so the pipeline never fires a request. + expect(seen).toHaveLength(0); + await server.close(); + }); + + it("400s when an attached claim is an array, not an object", async () => { + const { server } = await makeServer(); + const res = await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Payment Agent", + claim: "Payment sent.", + metadata: { claim: [] }, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("INVALID_CLAIM"); + await server.close(); + }); + + it("lists recent events newest first, as redacted summaries", async () => { + const { server, settled } = await makeServer(); + await server.inject({ + method: "POST", + url: "/v1/events", + payload: { + agentName: "Agent A", + claim: "Acted.", + callbackUrl: "https://asp.example.com/hooks/secret-path", + }, + }); + await settled; + const res = await server.inject({ method: "GET", url: "/v1/events" }); + expect(res.statusCode).toBe(200); + const [summary] = res.json(); + expect(summary.agentName).toBe("Agent A"); + expect(summary.status).toBe("COMPLETED"); + // The public feed never exposes another caller's callbackUrl, metadata, + // or evidence — those live only behind the unguessable per-event id. + expect(JSON.stringify(summary)).not.toContain("secret-path"); + expect(summary.event).toBeUndefined(); + await server.close(); + }); + + it("404s for an unknown event id", async () => { + const { server } = await makeServer(); + const res = await server.inject({ method: "GET", url: "/v1/events/evt_missing" }); + expect(res.statusCode).toBe(404); + expect(res.json().error).toBe("EVENT_NOT_FOUND"); + await server.close(); + }); +}); diff --git a/apps/api/src/features/events/routes.ts b/apps/api/src/features/events/routes.ts new file mode 100644 index 0000000..afd0435 --- /dev/null +++ b/apps/api/src/features/events/routes.ts @@ -0,0 +1,128 @@ +import type { FastifyInstance } from "fastify"; +import type { RealityCheck } from "@realitycheck/core"; +import type { RealityCore } from "@realitycheck/reality-core"; +import { claimAttached, claimFromEnvelope } from "../verify/marketplace.js"; +import { toValidationError, verifyBodySchema } from "../verify/schemas.js"; +import { checkCallbackUrl } from "./callback-guard.js"; +import { agentEventSchema } from "./schemas.js"; +import { runEventPipeline, type DeliveryFetch } from "./pipeline.js"; +import { EventStore, type EventRecord } from "./store.js"; + +export interface EventRoutesOptions { + readonly engine: RealityCheck; + readonly core: RealityCore; + /** Test seam: injectable HTTP client for callback delivery. */ + readonly fetchImpl?: DeliveryFetch; + /** Test seam: injectable backoff sleeper (no-op in tests). */ + readonly sleep?: (ms: number) => Promise; + /** Test seam: awaitable pipeline completion hook (fires after delivery settles). */ + readonly onSettled?: (record: EventRecord) => void; +} + +/** + * Autonomous verification routes: + * POST /v1/events agent acted → 202 + eventId; verification runs on its own + * GET /v1/events/:id pipeline state + signed receipt once complete + * GET /v1/events recent events, newest first + * + * This is the "always on" surface: the ASP tells RealityCheck that its agent + * acted, and everything after that — evidence, verdict, signed receipt, + * ledger record, callback delivery — happens without anyone asking twice. + */ +export function registerEventRoutes(server: FastifyInstance, options: EventRoutesOptions): void { + const store = new EventStore(); + + server.post("/v1/events", async (request, reply) => { + const traceId = request.id; + const parsed = agentEventSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send(toValidationError(traceId, parsed.error)); + } + + // If the ASP attached an objective claim, it must be well-formed. A + // malformed claim is an integration bug, not an absence of evidence — we + // reject it up front instead of silently returning UNVERIFIABLE, which + // would mask the mistake. No attached claim at all is fine: the event is + // verified as UNVERIFIABLE downstream. + const candidate = claimFromEnvelope(parsed.data); + if (claimAttached(parsed.data)) { + const objective = candidate === null ? null : verifyBodySchema.safeParse(candidate); + if (objective === null) { + return reply.code(400).send({ + error: "INVALID_CLAIM", + traceId, + issues: [ + { + path: "metadata.claim", + message: + "Attached claim must be an object matching a supported claim schema. Omit it entirely for an UNVERIFIABLE verdict.", + }, + ], + }); + } + if (!objective.success) { + return reply.code(400).send(toValidationError(traceId, objective.error)); + } + } + + // Reject callbacks pointed at internal/loopback/metadata addresses before + // we ever fire an outbound request — the endpoint is unauthenticated, so + // this is what stops it being used as an SSRF proxy. + if (parsed.data.callbackUrl !== undefined) { + const guard = checkCallbackUrl(parsed.data.callbackUrl); + if (!guard.ok) { + return reply.code(400).send({ + error: "CALLBACK_URL_FORBIDDEN", + traceId, + message: guard.reason, + }); + } + } + + const record = store.create(parsed.data); + // Fire-and-forget: the ASP gets its 202 immediately; the pipeline never + // throws (failures are captured on the record). + void runEventPipeline(record, { + engine: options.engine, + core: options.core, + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + ...(options.sleep !== undefined ? { sleep: options.sleep } : {}), + onError: (error, eventId) => server.log.error({ err: error, eventId }, "event pipeline failed"), + }) + .catch((error) => server.log.error({ err: error, eventId: record.id }, "event pipeline crashed")) + .finally(() => options.onSettled?.(record)); + + return reply.code(202).send({ + eventId: record.id, + status: record.status, + receivedAtIso: record.receivedAtIso, + poll: `/v1/events/${record.id}`, + traceId, + }); + }); + + server.get<{ Params: { id: string } }>("/v1/events/:id", async (request, reply) => { + const record = store.get(request.params.id); + if (record === null) { + return reply.code(404).send({ error: "EVENT_NOT_FOUND", id: request.params.id }); + } + return record; + }); + + server.get<{ Querystring: { limit?: string } }>("/v1/events", async (request) => { + const limit = request.query.limit !== undefined ? Number.parseInt(request.query.limit, 10) : 25; + // The list is a public feed, so it exposes summaries only. Full records — + // including the caller's callbackUrl, metadata, and evidence — are served + // solely by id, which is an unguessable UUID known only to the submitter. + return store.list(Number.isFinite(limit) ? limit : 25).map((record) => ({ + id: record.id, + status: record.status, + agentName: record.event.agentName, + action: record.event.action ?? null, + verdict: record.result?.verdict ?? null, + receiptResult: record.receipt?.result ?? null, + receivedAtIso: record.receivedAtIso, + completedAtIso: record.completedAtIso, + })); + }); +} diff --git a/apps/api/src/features/events/schemas.ts b/apps/api/src/features/events/schemas.ts new file mode 100644 index 0000000..c9748d7 --- /dev/null +++ b/apps/api/src/features/events/schemas.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** + * POST /v1/events — the autonomous intake. An Agent Service Provider fires + * this the moment its agent acts; RealityCheck takes it from there: collect + * evidence, verify, sign a Reality Receipt, and (when a callbackUrl is given) + * push the receipt back the instant it exists. + * + * The body is the marketplace envelope plus delivery details. The objective + * claim travels in metadata.claim exactly as it does on POST /verify, so an + * ASP integrates once and uses either route. + */ +export const agentEventSchema = z.object({ + agentName: z.string().min(1, "agentName is required"), + claim: z.string().min(1, "claim is required"), + /** What the agent did, e.g. "swap" | "transfer" | "payment". Display only. */ + action: z.string().min(1).optional(), + metadata: z.record(z.unknown()).optional(), + evidence: z.record(z.unknown()).optional(), + /** Where to push the signed receipt when verification completes. */ + callbackUrl: z.string().url("callbackUrl must be a valid URL").optional(), +}); + +export type AgentEvent = z.infer; diff --git a/apps/api/src/features/events/store.ts b/apps/api/src/features/events/store.ts new file mode 100644 index 0000000..31a4c72 --- /dev/null +++ b/apps/api/src/features/events/store.ts @@ -0,0 +1,74 @@ +import crypto from "node:crypto"; +import type { RealityReceipt, VerificationResult } from "@realitycheck/domain"; +import type { Attestation } from "@realitycheck/reality-core"; +import type { AgentEvent } from "./schemas.js"; + +/** Lifecycle of an autonomous verification, oldest state first. */ +export type EventStatus = "RECEIVED" | "VERIFYING" | "COMPLETED" | "FAILED"; + +/** How the receipt push to the ASP's callbackUrl went. */ +export interface DeliveryRecord { + readonly url: string; + readonly status: "PENDING" | "DELIVERED" | "FAILED" | "SKIPPED"; + readonly attempts: number; + readonly detail: string; + readonly deliveredAtIso: string | null; +} + +/** One agent action moving through the autonomous pipeline. */ +export interface EventRecord { + readonly id: string; + status: EventStatus; + readonly event: AgentEvent; + readonly receivedAtIso: string; + completedAtIso: string | null; + result: VerificationResult | null; + attestation: Attestation | null; + receipt: RealityReceipt | null; + delivery: DeliveryRecord | null; + /** Populated when the pipeline itself failed (never a verdict). */ + error: string | null; +} + +/** + * Bounded in-memory intake log. The durable audit trail is the attestation + * ledger — this store only tracks in-flight pipeline state and lets an ASP + * poll GET /v1/events/:id while (or after) its receipt is produced. + */ +export class EventStore { + private readonly records = new Map(); + + public constructor(private readonly capacity = 500) {} + + public create(event: AgentEvent): EventRecord { + const record: EventRecord = { + id: `evt_${crypto.randomUUID()}`, + status: "RECEIVED", + event, + receivedAtIso: new Date().toISOString(), + completedAtIso: null, + result: null, + attestation: null, + receipt: null, + delivery: null, + error: null, + }; + this.records.set(record.id, record); + // Evict oldest beyond capacity; Map preserves insertion order. + while (this.records.size > this.capacity) { + const oldest = this.records.keys().next().value; + if (oldest === undefined) break; + this.records.delete(oldest); + } + return record; + } + + public get(id: string): EventRecord | null { + return this.records.get(id) ?? null; + } + + public list(limit = 25): EventRecord[] { + const capped = Math.min(Math.max(limit, 1), 100); + return [...this.records.values()].reverse().slice(0, capped); + } +} diff --git a/apps/api/src/features/provenance/routes.ts b/apps/api/src/features/provenance/routes.ts index 0467701..320ec64 100644 --- a/apps/api/src/features/provenance/routes.ts +++ b/apps/api/src/features/provenance/routes.ts @@ -2,11 +2,11 @@ import type { FastifyInstance } from "fastify"; import type { Attestation, RealityCore } from "@realitycheck/reality-core"; /** - * Provenance & trust endpoints backed by Reality Core: - * GET /v1/attestations recent attestations (optionally ?subject=) + * Provenance endpoints backed by Reality Core: + * GET /v1/attestations recent attestations (optionally ?subject=) * GET /v1/attestations/:id a single attestation record * POST /v1/attestations/verify independently re-check a supplied attestation - * GET /v1/trust/:subject trust signals for a subject (tx hash / address) + * GET /v1/track-record/:subject verified track record for a subject (tx hash / address) */ export function registerProvenanceRoutes(server: FastifyInstance, core: RealityCore): void { server.get<{ Querystring: { subject?: string; limit?: string } }>( @@ -36,8 +36,8 @@ export function registerProvenanceRoutes(server: FastifyInstance, core: RealityC return core.verifyAttestation(body); }); - server.get<{ Params: { subject: string } }>("/v1/trust/:subject", async (request) => { - return core.getTrust(request.params.subject); + server.get<{ Params: { subject: string } }>("/v1/track-record/:subject", async (request) => { + return core.getTrackRecord(request.params.subject); }); // Aggregate statistics over the whole ledger — powers the Reality Dashboard. diff --git a/apps/api/src/features/verify/claim-consistency.test.ts b/apps/api/src/features/verify/claim-consistency.test.ts new file mode 100644 index 0000000..6100d5c --- /dev/null +++ b/apps/api/src/features/verify/claim-consistency.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { VerificationResult } from "@realitycheck/domain"; +import { + enforceClaimConsistency, + extractSpokenAmount, + toBaseUnits, +} from "./claim-consistency.js"; + +const USDT = "0x1e4a5963abfd975d8c9021ce480b42188849d41d"; +const TX = `0x${"ab".repeat(32)}`; +const TO = `0x${"cd".repeat(20)}`; + +/** A VERIFIED TOKEN_TRANSFER result whose evidence shows `received` base units. */ +function verifiedTokenTransfer(received: string, minAmount = "1"): VerificationResult { + return { + claim: { kind: "TOKEN_TRANSFER", chain: "xlayer-mainnet", txHash: TX, token: USDT, to: TO, minAmount }, + verdict: "VERIFIED", + reason: `Received ${received} base units at ${TO} in block 1.`, + evidence: { + source: "xlayer-mainnet:test", + chainId: 196, + blockNumber: "1", + raw: { + token: USDT, + tokenSymbol: "USDT", + tokenDecimals: 6, + to: TO, + receivedAmount: received, + minAmount, + matchedTransferLogs: 1, + }, + fetchedAtIso: new Date().toISOString(), + latencyMs: 1, + }, + checkedBy: "xlayer-mainnet@test", + }; +} + +describe("extractSpokenAmount", () => { + it("reads ' ' mentions", () => { + expect(extractSpokenAmount("I paid 100 USDT to the vendor wallet.")).toEqual({ + amount: "100", + symbol: "USDT", + }); + expect(extractSpokenAmount("sent 1,250.5 usdc")).toEqual({ amount: "1250.5", symbol: "USDC" }); + }); + + it("never reads digits out of tx hashes or bare numbers", () => { + expect(extractSpokenAmount(`I sent tx ${TX}`)).toBeNull(); + expect(extractSpokenAmount("I paid 100 to the vendor")).toBeNull(); + expect(extractSpokenAmount("The transaction went through.")).toBeNull(); + }); +}); + +describe("toBaseUnits", () => { + it("scales human decimals to base units", () => { + expect(toBaseUnits("100", 6)).toBe(100_000_000n); + expect(toBaseUnits("120.011823", 6)).toBe(120_011_823n); + expect(toBaseUnits("0.000001", 6)).toBe(1n); + }); +}); + +describe("enforceClaimConsistency", () => { + it("REFUTES when the agent spoke a bigger number than the chain shows", () => { + // The receipt-laundering attack: say "100 USDT", attach minAmount "1". + // Structured claim passes (1 base unit moved ≥ 1) — the gate must catch the words. + const result = enforceClaimConsistency( + "I paid 100 USDT to the vendor wallet.", + verifiedTokenTransfer("42105574"), + ); + expect(result.verdict).toBe("REFUTED"); + expect(result.reason).toContain("Agent claimed 100 USDT"); + expect(result.reason).toContain("42.105574 USDT"); + expect(result.checkedBy).toContain("claim-consistency"); + }); + + it("keeps VERIFIED — and says the words were checked — when evidence covers the claim", () => { + const result = enforceClaimConsistency( + "I paid 100 USDT to the supplier.", + verifiedTokenTransfer("120011823"), + ); + expect(result.verdict).toBe("VERIFIED"); + expect(result.reason).toContain("100 USDT"); + expect(result.reason).toContain("covered by the evidence"); + }); + + it("downgrades to INDETERMINATE when the spoken token doesn't match the evidence", () => { + const result = enforceClaimConsistency( + "I paid 100 USDC to the vendor.", + verifiedTokenTransfer("120011823"), + ); + expect(result.verdict).toBe("INDETERMINATE"); + expect(result.reason).toContain("USDC"); + }); + + it("changes nothing when the sentence names no amount", () => { + const base = verifiedTokenTransfer("120011823"); + expect(enforceClaimConsistency("I paid the supplier.", base)).toBe(base); + }); + + it("never touches non-VERIFIED verdicts", () => { + const refuted: VerificationResult = { ...verifiedTokenTransfer("1"), verdict: "REFUTED" }; + expect(enforceClaimConsistency("I paid 100 USDT.", refuted)).toBe(refuted); + }); +}); diff --git a/apps/api/src/features/verify/claim-consistency.ts b/apps/api/src/features/verify/claim-consistency.ts new file mode 100644 index 0000000..96102b9 --- /dev/null +++ b/apps/api/src/features/verify/claim-consistency.ts @@ -0,0 +1,181 @@ +import type { VerificationResult } from "@realitycheck/domain"; + +/** + * The claim-consistency gate — the piece that makes the natural-language claim + * mean something. + * + * An agent envelope carries two claims: what the agent *said* ("I paid 100 + * USDT") and the structured claim it attached (`metadata.claim`). Verifying + * only the structured claim leaves a hole: an agent could say "100 USDT" while + * attaching `minAmount: "1"`, get a VERIFIED verdict on the weak structured + * claim, and walk away with a signed receipt certifying the strong spoken one. + * + * This gate reads the amount the agent actually spoke and compares it against + * the on-chain evidence. If the evidence shows less than the agent claimed, + * the verdict is REFUTED — no matter what the structured claim said. + * RealityCheck never signs a receipt for words the evidence contradicts. + * + * The gate only acts when the comparison is unambiguous: a spoken amount with + * a token symbol that matches the evidence's token. When it cannot interpret + * the sentence with certainty, it changes nothing — the structured claim's + * verdict stands on its own, and the gate never invents a refutation. + */ + +const CONSISTENCY_VERSION = "claim-consistency/0.1.0"; + +/** An amount + token symbol the agent spoke, e.g. { amount: "100", symbol: "USDT" }. */ +export interface SpokenAmount { + readonly amount: string; + readonly symbol: string; +} + +/** + * Extract the first " " mention from a natural-language + * claim. Hex blobs (tx hashes, addresses) are stripped first so their digits + * never read as amounts. Returns null when the sentence names no recognizable + * amount — bare numbers without a symbol are deliberately ignored, because + * "100" could mean dollars, tokens, or basis points, and this gate never + * guesses. + */ +export function extractSpokenAmount(text: string): SpokenAmount | null { + const cleaned = text.replace(/0x[0-9a-fA-F]+/g, " "); + const match = cleaned.match( + /(\d+(?:[,_]\d{3})*(?:\.\d+)?)\s*(usdt|usdc|dai|okb|weth|wbtc|eth|btc)\b/i, + ); + if (match === null) return null; + const amount = (match[1] ?? "").replace(/[,_]/g, ""); + const symbol = (match[2] ?? "").toUpperCase(); + if (amount === "" || symbol === "") return null; + return { amount, symbol }; +} + +/** Scale a human decimal amount ("120.011823") into base units at `decimals`. */ +export function toBaseUnits(human: string, decimals: number): bigint | null { + const match = human.match(/^(\d+)(?:\.(\d+))?$/); + if (match === null || decimals < 0) return null; + const whole = match[1] ?? "0"; + const frac = (match[2] ?? "").slice(0, decimals).padEnd(decimals, "0"); + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(frac === "" ? "0" : frac); +} + +/** Format base units back into a human decimal string for the reason text. */ +function fromBaseUnits(base: bigint, decimals: number): string { + if (decimals <= 0) return base.toString(); + const scale = 10n ** BigInt(decimals); + const whole = base / scale; + const frac = (base % scale).toString().padStart(decimals, "0").replace(/0+$/, ""); + return frac ? `${whole.toString()}.${frac}` : whole.toString(); +} + +/** The evidence facts the gate can compare a spoken amount against. */ +interface EvidenceFacts { + readonly symbol: string; + readonly decimals: number; + /** What actually moved / is held, in base units. */ + readonly actual: bigint; + /** How to describe the actual figure, e.g. "arrived on-chain". */ + readonly verb: string; +} + +function asString(value: unknown): string | null { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return null; +} + +function bigintOf(value: unknown): bigint | null { + const str = asString(value); + if (str === null || !/^\d+$/.test(str)) return null; + return BigInt(str); +} + +/** Pull comparable facts out of the verification evidence, per claim kind. */ +function factsFor(result: VerificationResult): EvidenceFacts | null { + const raw = result.evidence?.raw as Record | undefined; + if (raw === undefined) return null; + + if (result.claim.kind === "TOKEN_TRANSFER") { + const symbol = asString(raw["tokenSymbol"]); + const decimalsStr = asString(raw["tokenDecimals"]); + const actual = bigintOf(raw["receivedAmount"]); + if (symbol === null || decimalsStr === null || actual === null) return null; + const decimals = Number.parseInt(decimalsStr, 10); + if (!Number.isFinite(decimals) || decimals < 0) return null; + return { symbol, decimals, actual, verb: "arrived on-chain" }; + } + + if (result.claim.kind === "TX_TRANSFER") { + const tx = raw["transaction"] as Record | undefined; + const actual = bigintOf(tx?.["value"]); + if (actual === null) return null; + return { symbol: "OKB", decimals: 18, actual, verb: "was transferred on-chain" }; + } + + if (result.claim.kind === "BALANCE_AT_LEAST") { + const actual = bigintOf(raw["balanceWei"]); + if (actual === null) return null; + return { symbol: "OKB", decimals: 18, actual, verb: "is held on-chain" }; + } + + return null; +} + +/** + * Enforce consistency between what the agent said and what the evidence shows. + * + * Only a VERIFIED verdict can be downgraded — REFUTED and INDETERMINATE are + * already honest. When the spoken amount exceeds what the chain shows, the + * result becomes REFUTED with a reason that names both figures. When the + * spoken amount is covered by the evidence, the reason records that the + * sentence itself was checked, so the receipt reader knows the words — not + * just the attached structured claim — match reality. + */ +export function enforceClaimConsistency( + naturalLanguageClaim: string, + result: VerificationResult, +): VerificationResult { + if (result.verdict !== "VERIFIED") return result; + + const spoken = extractSpokenAmount(naturalLanguageClaim); + if (spoken === null) return result; + + const facts = factsFor(result); + if (facts === null) return result; + // A spoken symbol that doesn't match the evidence's token is ambiguous + // ("paid 100 USDT" but the tx moved USDC) — the structured verdict stands, + // but we cannot certify the sentence, so we say so without guessing a number. + if (spoken.symbol !== facts.symbol.toUpperCase()) { + return { + ...result, + verdict: "INDETERMINATE", + reason: + `${result.reason} However, the agent's claim names ${spoken.amount} ${spoken.symbol} ` + + `while the evidence concerns ${facts.symbol} — the spoken claim cannot be certified against this evidence.`, + checkedBy: `${result.checkedBy}+${CONSISTENCY_VERSION}`, + }; + } + + const claimed = toBaseUnits(spoken.amount, facts.decimals); + if (claimed === null) return result; + + if (facts.actual >= claimed) { + return { + ...result, + reason: + `${result.reason} The agent's own words — ${spoken.amount} ${spoken.symbol} — ` + + `are covered by the evidence: ${fromBaseUnits(facts.actual, facts.decimals)} ${facts.symbol} ${facts.verb}.`, + checkedBy: `${result.checkedBy}+${CONSISTENCY_VERSION}`, + }; + } + + return { + ...result, + verdict: "REFUTED", + reason: + `Agent claimed ${spoken.amount} ${spoken.symbol}, but only ` + + `${fromBaseUnits(facts.actual, facts.decimals)} ${facts.symbol} ${facts.verb}. ` + + `The spoken claim contradicts the evidence, so RealityCheck will not certify it — ` + + `even though the attached structured claim alone would have passed.`, + checkedBy: `${result.checkedBy}+${CONSISTENCY_VERSION}`, + }; +} diff --git a/apps/api/src/features/verify/marketplace.ts b/apps/api/src/features/verify/marketplace.ts index 2ae0660..eb239c1 100644 --- a/apps/api/src/features/verify/marketplace.ts +++ b/apps/api/src/features/verify/marketplace.ts @@ -1,5 +1,5 @@ +import crypto from "node:crypto"; import type { - Claim, RealityReceipt, VerificationModuleKind, VerificationResult, @@ -11,8 +11,8 @@ const XLAYER_EXPLORER = "https://web3.okx.com/explorer/xlayer"; export interface MarketplaceEnvelope { readonly agentName: string; readonly claim: string; - readonly metadata?: Record; - readonly evidence?: Record; + readonly metadata?: Record | undefined; + readonly evidence?: Record | undefined; } export interface MarketplaceVerificationResponse extends VerificationResult { @@ -27,14 +27,35 @@ export function isMarketplaceEnvelope(input: unknown): input is MarketplaceEnvel return typeof body.agentName === "string" && typeof body.claim === "string"; } -export function claimFromEnvelope(envelope: MarketplaceEnvelope): Claim | null { - const candidates = [envelope.metadata?.claim, envelope.metadata?.objectiveClaim, envelope.evidence?.claim]; - for (const candidate of candidates) { - if (candidate !== null && typeof candidate === "object") return candidate as Claim; +/** + * Pull an attached objective claim out of an envelope. A claim is only a claim + * if it's a plain object — arrays and primitives are rejected so a stray + * `metadata.claim: []` can't masquerade as one. Returns the raw candidate; + * only `verifyBodySchema.safeParse` turns it into a `Claim`. Returns null when + * nothing claim-shaped is attached; the caller decides whether that means + * UNVERIFIABLE (no claim) or a 400 (a claim was attached but malformed — see + * claimAttached). + */ +export function claimFromEnvelope(envelope: MarketplaceEnvelope): Record | null { + for (const candidate of claimCandidates(envelope)) { + if (isPlainObject(candidate)) return candidate; } return null; } +/** True when the envelope carries any claim-shaped key at all, even a bad one. */ +export function claimAttached(envelope: MarketplaceEnvelope): boolean { + return claimCandidates(envelope).some((candidate) => candidate !== undefined && candidate !== null); +} + +function claimCandidates(envelope: MarketplaceEnvelope): unknown[] { + return [envelope.metadata?.claim, envelope.metadata?.objectiveClaim, envelope.evidence?.claim]; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function unverifiableFromEnvelope( envelope: MarketplaceEnvelope, traceId: string, @@ -44,7 +65,14 @@ export function unverifiableFromEnvelope( claim: { kind: "TX_SUCCESS", chain: "unresolved-agent-claim", - txHash: `0x${"0".repeat(64)}`, + // A distinct per-claim subject (hash of agent + words) so unverifiable + // claims from different agents never share a track record. An all-zero + // sentinel here would aggregate every unverifiable claim from every + // agent into one meaningless history. + txHash: `0x${crypto + .createHash("sha256") + .update(`${envelope.agentName}\n${envelope.claim}`, "utf8") + .digest("hex")}`, }, verdict: "INDETERMINATE", reason: @@ -85,7 +113,6 @@ export function resultToReceipt(args: { evidenceCollected: raw, verificationMethod: moduleFor(result), result: mapReceiptResult(result.verdict), - confidence: scoreFor(result.verdict), reasoning: result.reason, timestamp: result.evidence?.fetchedAtIso ?? attestation.createdAtIso, proofLinks: [attestation.id, ...explorerLinks], @@ -112,13 +139,17 @@ function moduleFor(result: VerificationResult): VerificationModuleKind { return "API"; } -/** A real 32-byte tx hash — not the all-zero sentinel used for unresolved claims. */ +/** + * A real 32-byte tx hash from a claim that was actually checked on-chain + * (chainId > 0). Unverifiable claims carry a synthetic per-claim subject in + * txHash for track-record grouping — it must never surface as a transaction. + */ function realTxHash(result: VerificationResult): string | undefined { + if (!result.evidence || result.evidence.chainId <= 0) return undefined; const claim = result.claim as unknown as Record; const txHash = typeof claim.txHash === "string" ? claim.txHash : undefined; if (txHash === undefined) return undefined; if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) return undefined; - if (/^0x0+$/.test(txHash)) return undefined; return txHash; } diff --git a/apps/api/src/features/verify/routes.test.ts b/apps/api/src/features/verify/routes.test.ts index 9f786e6..26c97bb 100644 --- a/apps/api/src/features/verify/routes.test.ts +++ b/apps/api/src/features/verify/routes.test.ts @@ -52,15 +52,15 @@ describe("POST /v1/verify", () => { await server.close(); }); - it("exposes trust signals for a subject after recording", async () => { + it("exposes the verified track record for a subject after recording", async () => { const server = await makeServer(); await server.inject({ method: "POST", url: "/v1/verify", payload: { kind: "TX_SUCCESS", chain: "xlayer-mainnet", txHash: TX }, }); - const trust = await server.inject({ method: "GET", url: `/v1/trust/${TX}` }); - expect(trust.json().totalChecks).toBe(1); + const record = await server.inject({ method: "GET", url: `/v1/track-record/${TX}` }); + expect(record.json().totalChecks).toBe(1); await server.close(); }); diff --git a/apps/api/src/features/verify/routes.ts b/apps/api/src/features/verify/routes.ts index a2f977d..fffcfe5 100644 --- a/apps/api/src/features/verify/routes.ts +++ b/apps/api/src/features/verify/routes.ts @@ -8,6 +8,7 @@ import { resultToReceipt, unverifiableFromEnvelope, } from "./marketplace.js"; +import { enforceClaimConsistency } from "./claim-consistency.js"; import { marketplaceVerifyBodySchema, toValidationError, verifyBodySchema } from "./schemas.js"; /** @@ -66,7 +67,10 @@ export function registerVerifyRoutes( const candidate = claimFromEnvelope(parsed.data); const objective = candidate === null ? null : verifyBodySchema.safeParse(candidate); const result = objective?.success - ? await engine.verify(objective.data as Claim) + ? enforceClaimConsistency( + parsed.data.claim, + await engine.verify(objective.data as Claim), + ) : unverifiableFromEnvelope(parsed.data, traceId); return recordAndRespond(reply, result, parsed.data.agentName, parsed.data.claim, traceId); } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 171d6f3..dac1e9f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,9 +4,34 @@ import { RealityCheck } from "@realitycheck/core"; import { RealityCore } from "@realitycheck/reality-core"; import { buildServer } from "./server.js"; +/** + * In production, silent fallbacks would break the product's own guarantees: + * an ephemeral attester key means every receipt fails signature checks after a + * restart, and an in-memory ledger silently discards the audit trail. Fail + * loudly at boot instead of degrading quietly. + */ +function assertProductionDurability(config: ReturnType): void { + if (config.nodeEnv !== "production") return; + const problems: string[] = []; + if (config.core.attesterSeedHex === undefined) { + problems.push( + "REALITY_ATTESTER_SEED is not set — the signing identity would change on every restart, invalidating all previously issued receipts", + ); + } + if (config.core.ledgerUrl === ":memory:") { + problems.push( + "LEDGER_URL is :memory: — every attestation would be lost on restart; use file:... or libsql://...", + ); + } + if (problems.length > 0) { + throw new Error(`Refusing to start in production:\n- ${problems.join("\n- ")}`); + } +} + /** Compose config → registry → engine → Reality Core → server, then listen. */ export async function startApi(): Promise { const config = loadConfig(); + assertProductionDurability(config); const registry = buildRegistry(config); const engine = new RealityCheck(registry); const core = await RealityCore.create(config); diff --git a/apps/api/src/rate-limit.test.ts b/apps/api/src/rate-limit.test.ts new file mode 100644 index 0000000..011d3ce --- /dev/null +++ b/apps/api/src/rate-limit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { RateLimiter } from "./rate-limit.js"; + +describe("RateLimiter", () => { + it("allows up to max requests per window, then 429s with a retry hint", () => { + let now = 0; + const limiter = new RateLimiter({ max: 3, windowMs: 60_000, now: () => now }); + + expect(limiter.check("ip-1").allowed).toBe(true); + expect(limiter.check("ip-1").allowed).toBe(true); + expect(limiter.check("ip-1").allowed).toBe(true); + + const denied = limiter.check("ip-1"); + expect(denied.allowed).toBe(false); + expect(denied.retryAfterSec).toBeGreaterThan(0); + + // Other clients are unaffected. + expect(limiter.check("ip-2").allowed).toBe(true); + + // The window rolls over and the client is welcome again. + now = 60_001; + expect(limiter.check("ip-1").allowed).toBe(true); + }); +}); diff --git a/apps/api/src/rate-limit.ts b/apps/api/src/rate-limit.ts new file mode 100644 index 0000000..a0de234 --- /dev/null +++ b/apps/api/src/rate-limit.ts @@ -0,0 +1,74 @@ +/** + * Dependency-free sliding-window rate limiter. + * + * The write surface (POST /verify, /v1/verify, /v1/events) is unauthenticated + * by design — agents integrate with one call — so without a cap anyone could + * flood the ledger with junk verifications or use event callbacks as a free + * outbound-POST cannon. This bounds both per client IP. + * + * In-memory on purpose: limits reset on restart, which is fine — the limiter + * protects the running process, not a quota. Entries are pruned lazily so the + * map cannot grow unboundedly under an address-rotation attack. + */ + +export interface RateLimiterOptions { + /** Max requests allowed per window. */ + readonly max: number; + /** Window length in milliseconds. */ + readonly windowMs: number; + /** Injectable clock for tests. */ + readonly now?: () => number; +} + +export interface RateDecision { + readonly allowed: boolean; + /** Seconds until the window has room again (0 when allowed). */ + readonly retryAfterSec: number; +} + +interface Window { + count: number; + startedAt: number; +} + +const MAX_TRACKED_KEYS = 10_000; + +export class RateLimiter { + private readonly windows = new Map(); + private readonly max: number; + private readonly windowMs: number; + private readonly now: () => number; + + public constructor(options: RateLimiterOptions) { + this.max = options.max; + this.windowMs = options.windowMs; + this.now = options.now ?? Date.now; + } + + public check(key: string): RateDecision { + const now = this.now(); + this.pruneIfCrowded(now); + + const window = this.windows.get(key); + if (window === undefined || now - window.startedAt >= this.windowMs) { + this.windows.set(key, { count: 1, startedAt: now }); + return { allowed: true, retryAfterSec: 0 }; + } + if (window.count < this.max) { + window.count += 1; + return { allowed: true, retryAfterSec: 0 }; + } + return { + allowed: false, + retryAfterSec: Math.max(1, Math.ceil((window.startedAt + this.windowMs - now) / 1000)), + }; + } + + /** Drop expired windows once the map gets large; O(n) only when crowded. */ + private pruneIfCrowded(now: number): void { + if (this.windows.size < MAX_TRACKED_KEYS) return; + for (const [key, window] of this.windows) { + if (now - window.startedAt >= this.windowMs) this.windows.delete(key); + } + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index df21a5d..e8d1369 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,11 +5,17 @@ import type { RealityCheck } from "@realitycheck/core"; import type { RealityCore } from "@realitycheck/reality-core"; import { registerVerifyRoutes } from "./features/verify/routes.js"; import { registerProvenanceRoutes } from "./features/provenance/routes.js"; +import { registerEventRoutes, type EventRoutesOptions } from "./features/events/routes.js"; +import { RateLimiter } from "./rate-limit.js"; export interface BuildServerOptions { readonly engine: RealityCheck; readonly core: RealityCore; readonly chains: string[]; + /** Test seams for the autonomous event pipeline (delivery fetch, backoff sleep, settle hook). */ + readonly events?: Pick; + /** Requests allowed per IP per minute on write routes (0 disables; default 60). */ + readonly writeRateLimitPerMin?: number; } /** @@ -19,6 +25,9 @@ export interface BuildServerOptions { */ export function buildServer(options: BuildServerOptions): FastifyInstance { const server = Fastify({ logger: true, genReqId: () => crypto.randomUUID() }); + const writeLimitPerMin = options.writeRateLimitPerMin ?? 60; + const writeLimiter = + writeLimitPerMin > 0 ? new RateLimiter({ max: writeLimitPerMin, windowMs: 60_000 }) : null; server.addHook("onRequest", (req, reply, done) => { reply.header("Access-Control-Allow-Origin", "*"); @@ -28,6 +37,21 @@ export function buildServer(options: BuildServerOptions): FastifyInstance { reply.code(204).send(); return; } + // Writes append to the ledger and can trigger outbound callbacks, so they + // are capped per client IP. Reads stay unlimited. + if (writeLimiter !== null && req.method === "POST") { + const decision = writeLimiter.check(req.ip); + if (!decision.allowed) { + reply + .code(429) + .header("Retry-After", String(decision.retryAfterSec)) + .send({ + error: "RATE_LIMITED", + message: `Too many verification requests; retry in ${decision.retryAfterSec}s.`, + }); + return; + } + } done(); }); @@ -40,5 +64,10 @@ export function buildServer(options: BuildServerOptions): FastifyInstance { registerVerifyRoutes(server, options.engine, options.core); registerProvenanceRoutes(server, options.core); + registerEventRoutes(server, { + engine: options.engine, + core: options.core, + ...options.events, + }); return server; } diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 34c03c9..48053d8 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -132,7 +132,6 @@ export interface RealityReceipt { readonly evidenceCollected: Readonly> | null; readonly verificationMethod: VerificationModuleKind; readonly result: "VERIFIED" | "MISMATCH" | "UNVERIFIABLE"; - readonly confidence: number | null; readonly reasoning: string; readonly timestamp: string; readonly proofLinks: readonly string[]; @@ -140,5 +139,6 @@ export interface RealityReceipt { readonly explorerLinks?: readonly string[]; readonly executionLogs?: readonly string[]; readonly supportingFiles?: readonly string[]; + /** 100 when evidence matches, 0 on mismatch, null when unverifiable. */ readonly realityScore: number | null; } diff --git a/packages/reality-core/src/attestation.ts b/packages/reality-core/src/attestation.ts index a3e1808..0a5abf1 100644 --- a/packages/reality-core/src/attestation.ts +++ b/packages/reality-core/src/attestation.ts @@ -2,7 +2,7 @@ import type { Claim, VerificationResult } from "@realitycheck/domain"; import type { Attestation, AttestationCheck, AttestationPayload } from "./types.js"; import { Attester, canonicalize, sha256Hex, verifySignature } from "./crypto.js"; -/** Derive the trust subject (what the claim is about) from a claim. */ +/** Derive the subject (what the claim is about) from a claim. */ export function subjectOf(claim: Claim): string { switch (claim.kind) { case "TX_SUCCESS": @@ -30,9 +30,14 @@ function payloadOf(result: VerificationResult): AttestationPayload { } /** - * Turn a verification result into a signed attestation. Content-addressed: - * `id` is derived from the hash of the canonical payload, so identical facts - * always produce the same id. + * Turn a verification result into a signed attestation. + * + * The signature covers the full canonical payload (including fetch timestamps), + * but the `id` is derived from the *fact hash* — the payload minus volatile + * observation fields (`fetchedAtIso`, `latencyMs`). Re-checking the same claim + * against the same evidence therefore produces the same id, so the ledger's + * INSERT OR IGNORE genuinely dedupes: repeating a check cannot inflate a + * subject's track record. */ export function buildAttestation(result: VerificationResult, attester: Attester): Attestation { const payload = payloadOf(result); @@ -40,7 +45,7 @@ export function buildAttestation(result: VerificationResult, attester: Attester) const payloadHash = sha256Hex(canonical); const signature = attester.sign(canonical); return { - id: `rc_${payloadHash}`, + id: `rc_${factHash(payload)}`, version: 1, subject: payload.subject, verdict: payload.verdict, @@ -54,6 +59,21 @@ export function buildAttestation(result: VerificationResult, attester: Attester) }; } +/** Hash of the payload with volatile observation fields removed. */ +function factHash(payload: AttestationPayload): string { + const evidence = payload.evidence; + const stableEvidence = + evidence === null + ? null + : { + source: evidence.source, + chainId: evidence.chainId, + blockNumber: evidence.blockNumber, + raw: evidence.raw, + }; + return sha256Hex(canonicalize({ ...payload, evidence: stableEvidence })); +} + /** * Independently re-check an attestation: recompute the hash from its inline * payload and verify the signature. Needs nothing but the attestation itself. diff --git a/packages/reality-core/src/index.ts b/packages/reality-core/src/index.ts index 336304d..478b8cd 100644 --- a/packages/reality-core/src/index.ts +++ b/packages/reality-core/src/index.ts @@ -4,7 +4,7 @@ export { buildAttestation, checkAttestation, subjectOf } from "./attestation.js" export { Attester, canonicalize, sha256Hex, verifySignature } from "./crypto.js"; export { LibsqlLedger } from "./ledger.js"; export type { Ledger } from "./ledger.js"; -export { computeTrustSignals } from "./trust.js"; +export { computeTrackRecord } from "./track-record.js"; export { NoopAnchor, XLayerAnchor, buildAnchor } from "./anchor.js"; export type { ProvenanceAnchor } from "./anchor.js"; export type { @@ -13,5 +13,5 @@ export type { AttestationPayload, AnchorReceipt, LedgerStats, - TrustSignals, + TrackRecord, } from "./types.js"; diff --git a/packages/reality-core/src/reality-core.test.ts b/packages/reality-core/src/reality-core.test.ts index 5e72881..492d5a7 100644 --- a/packages/reality-core/src/reality-core.test.ts +++ b/packages/reality-core/src/reality-core.test.ts @@ -3,7 +3,7 @@ import type { VerificationResult } from "@realitycheck/domain"; import { Attester } from "./crypto.js"; import { buildAttestation, checkAttestation } from "./attestation.js"; import { LibsqlLedger } from "./ledger.js"; -import { computeTrustSignals } from "./trust.js"; +import { computeTrackRecord } from "./track-record.js"; const TX = `0x${"ab".repeat(32)}`; @@ -57,10 +57,39 @@ describe("attestation", () => { expect(a.publicKeyHex).toBe(b.publicKeyHex); expect(a.sign("hello")).toBe(b.sign("hello")); }); + + it("re-checking the same fact yields the same id — repeats cannot inflate a track record", async () => { + const ledger = new LibsqlLedger(":memory:"); + await ledger.init(); + const attester = new Attester("33".repeat(32)); + + // Same claim, same evidence, different observation times → same fact. + const base = result("VERIFIED"); + const evidence = { + source: "xlayer-mainnet:test", + chainId: 196, + blockNumber: "100", + raw: { status: "success" }, + fetchedAtIso: "2026-07-01T00:00:00.000Z", + latencyMs: 10, + }; + const later = { ...evidence, fetchedAtIso: "2026-07-02T00:00:00.000Z", latencyMs: 99 }; + + const first = buildAttestation({ ...base, evidence }, attester); + const second = buildAttestation({ ...base, evidence: later }, attester); + expect(second.id).toBe(first.id); + + await ledger.append(first); + await ledger.append(second); + expect(await ledger.count()).toBe(1); + + const record = await computeTrackRecord(ledger, TX); + expect(record.totalChecks).toBe(1); + }); }); -describe("ledger + trust (in-memory libSQL)", () => { - it("persists attestations and computes trust signals", async () => { +describe("ledger + track record (in-memory libSQL)", () => { + it("persists attestations and computes the track record", async () => { const ledger = new LibsqlLedger(":memory:"); await ledger.init(); const attester = new Attester("22".repeat(32)); @@ -71,12 +100,12 @@ describe("ledger + trust (in-memory libSQL)", () => { expect(await ledger.count()).toBe(3); - const signals = await computeTrustSignals(ledger, TX); - expect(signals.totalChecks).toBe(3); - expect(signals.verified).toBe(1); - expect(signals.refuted).toBe(1); - expect(signals.indeterminate).toBe(1); - expect(signals.trustScore).toBe(33); // 1 of 3 verified + const record = await computeTrackRecord(ledger, TX); + expect(record.totalChecks).toBe(3); + expect(record.verified).toBe(1); + expect(record.refuted).toBe(1); + expect(record.indeterminate).toBe(1); + expect(record.realityScore).toBe(33); // 1 of 3 verified }); it("round-trips an attestation through get()", async () => { diff --git a/packages/reality-core/src/reality-core.ts b/packages/reality-core/src/reality-core.ts index 127d098..e6b0541 100644 --- a/packages/reality-core/src/reality-core.ts +++ b/packages/reality-core/src/reality-core.ts @@ -4,8 +4,8 @@ import { Attester } from "./crypto.js"; import { buildAttestation, checkAttestation } from "./attestation.js"; import { LibsqlLedger, type Ledger } from "./ledger.js"; import { buildAnchor, type ProvenanceAnchor } from "./anchor.js"; -import { computeTrustSignals } from "./trust.js"; -import type { Attestation, AttestationCheck, LedgerStats, TrustSignals } from "./types.js"; +import { computeTrackRecord } from "./track-record.js"; +import type { Attestation, AttestationCheck, LedgerStats, TrackRecord } from "./types.js"; export interface RealityCoreDeps { readonly ledger: Ledger; @@ -23,7 +23,7 @@ export interface CoreInfo { * Reality Core — the shared substrate beneath RealityCheck's products. It turns * a one-off verification into durable provenance: a signed, content-addressed * attestation, optionally anchored on-chain, appended to a ledger, and it - * derives trust signals from that accumulated history. + * derives a verified track record from that accumulated history. */ export class RealityCore { public constructor(private readonly deps: RealityCoreDeps) {} @@ -63,8 +63,8 @@ export class RealityCore { return this.deps.ledger.list(options); } - public getTrust(subject: string): Promise { - return computeTrustSignals(this.deps.ledger, subject); + public getTrackRecord(subject: string): Promise { + return computeTrackRecord(this.deps.ledger, subject); } public getStats(): Promise { @@ -75,6 +75,19 @@ export class RealityCore { return checkAttestation(attestation); } + /** + * Sign an arbitrary canonical payload with the attester key. Used to + * authenticate outbound webhook deliveries: the ASP verifies the signature + * against the same public key (`attesterPublicKey`) it already uses for + * attestations, so a delivery can't be forged or replayed by a third party. + */ + public signPayload(canonical: string): { signature: string; publicKey: string } { + return { + signature: this.deps.attester.sign(canonical), + publicKey: this.deps.attester.publicKeyHex, + }; + } + public async info(): Promise { return { attesterPublicKey: this.deps.attester.publicKeyHex, diff --git a/packages/reality-core/src/trust.ts b/packages/reality-core/src/track-record.ts similarity index 66% rename from packages/reality-core/src/trust.ts rename to packages/reality-core/src/track-record.ts index 652931f..7c65370 100644 --- a/packages/reality-core/src/trust.ts +++ b/packages/reality-core/src/track-record.ts @@ -1,15 +1,16 @@ import type { Verdict } from "@realitycheck/domain"; import type { Ledger } from "./ledger.js"; -import type { TrustSignals } from "./types.js"; +import type { TrackRecord } from "./types.js"; /** - * Compute trust signals for a subject from its attestation history. + * Compute the verified track record for a subject from its attestation history. * * The score is deliberately conservative: it is the share of all recorded * checks that came back VERIFIED, so both refutations and unknowns pull it - * down. Reputation is earned by repeatedly matching reality, not asserted. + * down. A track record is earned by repeatedly matching reality — it is never + * asserted. */ -export async function computeTrustSignals(ledger: Ledger, subject: string): Promise { +export async function computeTrackRecord(ledger: Ledger, subject: string): Promise { const key = subject.toLowerCase(); const rows = await ledger.history(key); @@ -23,7 +24,7 @@ export async function computeTrustSignals(ledger: Ledger, subject: string): Prom } const total = rows.length; - const trustScore = total === 0 ? 0 : Math.round((100 * verified) / total); + const realityScore = total === 0 ? 0 : Math.round((100 * verified) / total); const first = rows[0]; return { @@ -32,7 +33,7 @@ export async function computeTrustSignals(ledger: Ledger, subject: string): Prom verified, refuted, indeterminate, - trustScore, + realityScore, lastVerdict: first ? (first.verdict as Verdict) : null, lastCheckedIso: first ? first.createdAtIso : null, }; diff --git a/packages/reality-core/src/types.ts b/packages/reality-core/src/types.ts index a02acc5..8cf0e16 100644 --- a/packages/reality-core/src/types.ts +++ b/packages/reality-core/src/types.ts @@ -2,7 +2,7 @@ import type { Claim, Verdict, VerificationResult } from "@realitycheck/domain"; /** The exact object that gets canonicalized, hashed and signed. */ export interface AttestationPayload { - /** What the claim is about — a tx hash or address — for trust grouping. */ + /** What the claim is about — a tx hash or address — for grouping history. */ readonly subject: string; readonly claim: Claim; readonly verdict: Verdict; @@ -22,7 +22,7 @@ export interface AnchorReceipt { * observed a given verdict. Content-addressed by the payload hash. */ export interface Attestation { - /** Content address: "rc_" + payloadHash. */ + /** Content address: "rc_" + hash of the fact (payload minus volatile fetch fields). */ readonly id: string; readonly version: 1; readonly subject: string; @@ -71,15 +71,15 @@ export interface LedgerStats { readonly hourly: readonly { readonly hour: string; readonly count: number }[]; } -/** Trust signals derived from the ledger for one subject. */ -export interface TrustSignals { +/** Verified track record derived from the ledger for one subject. */ +export interface TrackRecord { readonly subject: string; readonly totalChecks: number; readonly verified: number; readonly refuted: number; readonly indeterminate: number; - /** 0–100. Confidence that this subject reflects reality, from its history. */ - readonly trustScore: number; + /** 0–100. How consistently this subject has matched reality, from its history. */ + readonly realityScore: number; readonly lastVerdict: Verdict | null; readonly lastCheckedIso: string | null; } From c799caade2c359464ffdd74dca98cf9eca9200bf Mon Sep 17 00:00:00 2001 From: Agozie180 Date: Thu, 23 Jul 2026 23:44:22 +0100 Subject: [PATCH 2/3] Make the homepage demo real; human amounts in the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero receipts are no longer fabricated: on load the homepage fires two real POST /verify calls against permanent X Layer transactions, so what judges see is signed by the live attester and re-checkable. The cached fallbacks are labeled as such and carry no invented signature, gas, or confirmation figures — RealityCheck never displays proof it doesn't have. Console: amounts are typed as humans write them ("100", not base units), converted at submit time with a decimals field; envelope/autonomous presets get an explicit "back to manual claims" exit instead of silently reverting. Positioning: "RealityCheck is the verification layer for AI agents." Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/src/app/app/page.tsx | 239 ++- apps/dashboard/src/app/docs/page.tsx | 102 +- apps/dashboard/src/app/globals.css | 1905 +++++++++++---------- apps/dashboard/src/app/layout.tsx | 4 +- apps/dashboard/src/app/page.tsx | 97 +- apps/dashboard/src/components/Receipt.tsx | 1 - apps/dashboard/src/lib/receipt.ts | 102 +- apps/dashboard/src/lib/types.ts | 4 +- 8 files changed, 1373 insertions(+), 1081 deletions(-) diff --git a/apps/dashboard/src/app/app/page.tsx b/apps/dashboard/src/app/app/page.tsx index 492a45b..0e780f6 100644 --- a/apps/dashboard/src/app/app/page.tsx +++ b/apps/dashboard/src/app/app/page.tsx @@ -6,7 +6,7 @@ import { DEFAULT_CHAIN, type Attestation, type ClaimKind, - type TrustSignals, + type TrackRecord, type VerificationResult, } from "@/lib/types"; import { mapVerdict, receiptFromResult } from "@/lib/receipt"; @@ -41,12 +41,25 @@ interface Preset { token?: string; txHash?: string; to?: string; - minAmount?: string; + /** Human token amount, e.g. "100" — converted to base units on submit. */ + amount?: string; /** Marketplace agent envelope sent as-is instead of a built claim. */ envelope?: { agentName: string; claim: string }; + /** Fire POST /v1/events and let the pipeline verify + deliver on its own. */ + autonomous?: boolean; } const PRESETS: Preset[] = [ + { + label: "Autonomous · AI acts, receipt appears", + hint: "The agent fires one event the moment it pays. RealityCheck detects it, verifies against X Layer, and the signed receipt arrives on its own.", + kind: "TOKEN_TRANSFER", + token: USDT_XLAYER, + txHash: GOOD_TX, + to: GOOD_TO, + amount: "100", + autonomous: true, + }, { label: "Honest payment · expect VERIFIED", hint: "Agent claims ≥ 100 USDT — 120.01 actually arrived.", @@ -54,7 +67,7 @@ const PRESETS: Preset[] = [ token: USDT_XLAYER, txHash: GOOD_TX, to: GOOD_TO, - minAmount: "100000000", + amount: "100", }, { label: "Came up short · expect MISMATCH", @@ -63,7 +76,7 @@ const PRESETS: Preset[] = [ token: USDT_XLAYER, txHash: SHORT_TX, to: SHORT_TO, - minAmount: "100000000", + amount: "100", }, { label: "No evidence · expect UNVERIFIABLE", @@ -76,6 +89,20 @@ const PRESETS: Preset[] = [ }, ]; +/** + * Convert a human token amount ("100", "42.5") to base units at `decimals`. + * Judges type token amounts; nobody should have to know what a base unit is. + * Non-numeric input is passed through untouched so the API's validation error + * (not a silent mangle) is what the user sees. + */ +function toBaseUnits(human: string, decimals: number): string { + const match = human.trim().match(/^(\d+)(?:\.(\d+))?$/); + if (match === null) return human.trim(); + const whole = match[1] ?? "0"; + const frac = (match[2] ?? "").slice(0, decimals).padEnd(decimals, "0"); + return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt(frac === "" ? "0" : frac)).toString(); +} + interface Health { core: { attesterPublicKey: string; anchor: string; ledgerRecords: number }; } @@ -84,15 +111,18 @@ export default function ConsolePage() { const [kind, setKind] = useState("TOKEN_TRANSFER"); const [txHash, setTxHash] = useState(""); const [to, setTo] = useState(""); - const [minValueWei, setMinValueWei] = useState(""); + const [minValueOkb, setMinValueOkb] = useState(""); const [token, setToken] = useState(""); const [minAmount, setMinAmount] = useState(""); + // Token decimals for converting the human amount to base units. USDT/USDC on + // X Layer use 6; adjustable for other tokens. + const [tokenDecimals, setTokenDecimals] = useState("6"); const [address, setAddress] = useState(""); - const [minWei, setMinWei] = useState(""); + const [minOkb, setMinOkb] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); - const [trust, setTrust] = useState(null); + const [trackRecord, setTrackRecord] = useState(null); const [health, setHealth] = useState(null); const [online, setOnline] = useState(null); @@ -101,33 +131,46 @@ export default function ConsolePage() { // When a marketplace preset is active, submit sends this envelope verbatim. // Any manual field edit clears it and returns to raw-claim mode. const [envelope, setEnvelope] = useState(null); + // Autonomous mode: submit fires POST /v1/events and the pipeline takes over. + const [autonomous, setAutonomous] = useState(false); + const [pipelinePhase, setPipelinePhase] = useState(null); const buildClaim = useCallback((): Record => { const base = { chain: DEFAULT_CHAIN, kind }; if (kind === "TX_SUCCESS") return { ...base, txHash: txHash.trim() }; - if (kind === "TOKEN_TRANSFER") + if (kind === "TOKEN_TRANSFER") { + const decimals = Number.parseInt(tokenDecimals, 10); return { ...base, txHash: txHash.trim(), token: token.trim(), to: to.trim(), - minAmount: minAmount.trim(), + minAmount: toBaseUnits(minAmount, Number.isFinite(decimals) ? decimals : 6), }; + } if (kind === "TX_TRANSFER") - return { ...base, txHash: txHash.trim(), to: to.trim(), minValueWei: minValueWei.trim() }; - return { ...base, address: address.trim(), minWei: minWei.trim() }; - }, [kind, txHash, token, to, minValueWei, minAmount, address, minWei]); + return { + ...base, + txHash: txHash.trim(), + to: to.trim(), + minValueWei: toBaseUnits(minValueOkb, 18), + }; + return { ...base, address: address.trim(), minWei: toBaseUnits(minOkb, 18) }; + }, [kind, txHash, token, to, minValueOkb, minAmount, tokenDecimals, address, minOkb]); function applyPreset(p: Preset) { setKind(p.kind); setTxHash(p.txHash ?? ""); setToken(p.token ?? ""); setTo(p.to ?? ""); - setMinAmount(p.minAmount ?? ""); + setMinAmount(p.amount ?? ""); + setTokenDecimals("6"); setEnvelope(p.envelope ?? null); + setAutonomous(p.autonomous ?? false); setResult(null); setError(null); - setTrust(null); + setTrackRecord(null); + setPipelinePhase(null); } const refresh = useCallback(async () => { @@ -154,8 +197,13 @@ export default function ConsolePage() { setLoading(true); setError(null); setResult(null); - setTrust(null); + setTrackRecord(null); + setPipelinePhase(null); try { + if (autonomous) { + await submitAutonomous(); + return; + } const res = await fetch(`${API_BASE}/verify`, { method: "POST", headers: { "content-type": "application/json" }, @@ -180,8 +228,8 @@ export default function ConsolePage() { if (flash.current === verification.attestation.id) flash.current = null; }, 6000); const subject = verification.attestation.subject; - const trustRes = await fetch(`${API_BASE}/v1/trust/${subject}`); - if (trustRes.ok) setTrust((await trustRes.json()) as TrustSignals); + const recordRes = await fetch(`${API_BASE}/v1/track-record/${subject}`); + if (recordRes.ok) setTrackRecord((await recordRes.json()) as TrackRecord); void refresh(); } catch (e) { setError(e instanceof Error ? e.message : "Request failed."); @@ -190,6 +238,60 @@ export default function ConsolePage() { } } + /** + * The autonomous path: fire one event as the agent would, then watch the + * pipeline finish on its own — detect, verify, sign, record, deliver. + */ + async function submitAutonomous() { + setPipelinePhase("Agent event received…"); + const res = await fetch(`${API_BASE}/v1/events`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agentName: "Payment Agent", + claim: "I paid at least 100 USDT to the supplier wallet.", + action: "token_transfer", + metadata: { claim: buildClaim() }, + }), + }); + const accepted = await res.json(); + if (!res.ok) { + setError(accepted?.error ?? `Event rejected (${res.status}).`); + return; + } + + setPipelinePhase("RealityCheck is collecting evidence from X Layer…"); + // Poll the pipeline until it settles; it usually takes one round-trip. + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((r) => setTimeout(r, 500)); + const poll = await fetch(`${API_BASE}/v1/events/${accepted.eventId}`); + if (!poll.ok) continue; + const record = await poll.json(); + if (record.status === "FAILED") { + setError(record.error ?? "Verification pipeline failed."); + return; + } + if (record.status === "COMPLETED") { + setPipelinePhase("Receipt signed and recorded — no one asked twice."); + const verification = { + ...record.result, + attestation: record.attestation, + receipt: record.receipt, + } as VerificationResult; + setResult(verification); + flash.current = record.attestation.id; + setTimeout(() => { + if (flash.current === record.attestation.id) flash.current = null; + }, 6000); + const recordRes = await fetch(`${API_BASE}/v1/track-record/${record.attestation.subject}`); + if (recordRes.ok) setTrackRecord((await recordRes.json()) as TrackRecord); + void refresh(); + return; + } + } + setError("Verification is taking longer than expected — check the live feed."); + } + const receipt = result ? receiptFromResult(result) : null; return ( @@ -213,7 +315,7 @@ export default function ConsolePage() {
- Try it now — real X Layer transactions, plus the case with no evidence: + Try it now — fire an autonomous agent event, or verify real X Layer transactions by hand:
{PRESETS.map((p) => (
-
- {KIND_ORDER.map((k) => ( + {envelope === null && !autonomous ? ( +
+ {KIND_ORDER.map((k) => ( + + ))} +
+ ) : ( +
- ))} -
+
+ )} - {envelope ? ( + {autonomous ? ( +
+
+ Autonomous mode · POST /v1/events +

+ Payment Agent fires one event the moment it acts. RealityCheck + takes it from there: evidence, verdict, signed receipt, audit trail. +

+

+ AI acts. RealityCheck watches. Reality is the judge. +

+
+
+ ) : envelope ? (
Marketplace agent envelope @@ -275,12 +405,21 @@ export default function ConsolePage() { onChange={(e) => setTo(e.target.value)} placeholder="Recipient wallet (0x…)" /> - setMinAmount(e.target.value)} - placeholder="Amount it claimed to pay (base units, e.g. 6-dp USDT)" - /> +
+ setMinAmount(e.target.value)} + placeholder="Amount it claimed to pay (e.g. 100)" + /> + setTokenDecimals(e.target.value)} + placeholder="Decimals" + title="Token decimals — 6 for USDT/USDC on X Layer" + /> +
)} {kind === "TX_TRANSFER" && ( @@ -293,9 +432,9 @@ export default function ConsolePage() { /> setMinValueWei(e.target.value)} - placeholder="Amount it claimed to send (in wei)" + value={minValueOkb} + onChange={(e) => setMinValueOkb(e.target.value)} + placeholder="Amount it claimed to send (in OKB, e.g. 0.5)" /> )} @@ -309,9 +448,9 @@ export default function ConsolePage() { /> setMinWei(e.target.value)} - placeholder="Amount it should hold (in wei)" + value={minOkb} + onChange={(e) => setMinOkb(e.target.value)} + placeholder="Amount it should hold (in OKB, e.g. 10)" /> )} @@ -319,21 +458,28 @@ export default function ConsolePage() { )} + {pipelinePhase &&

{pipelinePhase}

} {error &&

{error}

} {receipt && (
- {trust && ( + {trackRecord && (
-

+

This wallet or transaction has been checked{" "} - {trust.totalChecks}×: {trust.verified} verified,{" "} - {trust.refuted} mismatched. Track record score{" "} - {trust.trustScore}/100. + {trackRecord.totalChecks}×: {trackRecord.verified} verified,{" "} + {trackRecord.refuted} mismatched. Track record score{" "} + {trackRecord.realityScore}/100.

)} @@ -372,7 +518,8 @@ export default function ConsolePage() { )}

- Every receipt here is signed and can be re-checked by anyone. Nothing is taken on trust. + Every receipt here is signed and can be re-checked by anyone. Every verdict is backed + by evidence.

diff --git a/apps/dashboard/src/app/docs/page.tsx b/apps/dashboard/src/app/docs/page.tsx index aac7531..e00f913 100644 --- a/apps/dashboard/src/app/docs/page.tsx +++ b/apps/dashboard/src/app/docs/page.tsx @@ -6,9 +6,15 @@ import { SiteFooter } from "@/components/SiteFooter"; export const metadata = { title: "RealityCheck · API", description: - "The independent verification layer for OKX.AI agents. Submit a claim, receive a signed Reality Receipt.", + "RealityCheck is the verification layer for AI agents. Submit a claim, receive a signed Reality Receipt.", }; +// Real, permanent X Layer mainnet values — every example below is copy-paste +// runnable against the live API and the live chain. +const DOC_TX = "0xa6985182d801017af2b0924b76bc2ec8c8858eb927b6ab78bf0e314b6dd7b198"; +const DOC_TOKEN = "0x1e4a5963abfd975d8c9021ce480b42188849d41d"; +const DOC_TO = "0x5f8f80636e48f8210e8b9898f9f5145cdf198a9e"; + const REQUEST = `POST ${"{base}"}/verify Content-Type: application/json @@ -19,9 +25,9 @@ Content-Type: application/json "claim": { "kind": "TOKEN_TRANSFER", "chain": "xlayer-mainnet", - "txHash": "0xa6985182…6dd7b198", - "token": "0x1e4a5963abfd975d8c9021ce480b42188849d41d", - "to": "0x5f8f8063…f198a9e", + "txHash": "${DOC_TX}", + "token": "${DOC_TOKEN}", + "to": "${DOC_TO}", "minAmount": "100000000" } } @@ -32,16 +38,15 @@ const RESPONSE = `{ "agentName": "Wallet Agent", "claim": "I paid 100 USDT to the vendor wallet.", "result": "MISMATCH", - "confidence": 0, - "evidenceSource": "xlayer-mainnet:Transfer logs", + "evidenceSource": "xlayer-mainnet:rpc.xlayer.tech", "verificationMethod": "ON_CHAIN", - "transactionHash": "0xa6985182…6dd7b198", + "transactionHash": "${DOC_TX}", "realityScore": 0 }, "attestation": { - "id": "rc_9f2a71c8e4b3d6a1", + "id": "rc_…", "algorithm": "ed25519", - "signature": "0xa1b2c3d4…" + "signature": "…" } }`; @@ -55,9 +60,44 @@ Content-Type: application/json // Result: UNVERIFIABLE until source freshness evidence is supplied.`; +const EVENT_REQUEST = `POST ${"{base}"}/v1/events +Content-Type: application/json + +{ + "agentName": "Payment Agent", + "action": "token_transfer", + "claim": "I paid 100 USDT to the vendor wallet.", + "metadata": { + "claim": { + "kind": "TOKEN_TRANSFER", + "chain": "xlayer-mainnet", + "txHash": "${DOC_TX}", + "token": "${DOC_TOKEN}", + "to": "${DOC_TO}", + "minAmount": "100000000" + } + }, + "callbackUrl": "https://your-asp.example.com/hooks/realitycheck" +}`; + +const EVENT_RESPONSE = `202 Accepted +{ + "eventId": "evt_5f0c…", + "status": "RECEIVED", + "poll": "/v1/events/evt_5f0c…" +} + +// Moments later, RealityCheck POSTs to your callbackUrl: +{ + "eventId": "evt_5f0c…", + "receipt": { "result": "MISMATCH", "realityScore": 0, … }, + "attestation": { "id": "rc_…", "signature": "…" } +}`; + export default function DocsPage() { const request = REQUEST.replace("{base}", API_BASE); const unverifiable = UNVERIFIABLE.replace("{base}", API_BASE); + const eventRequest = EVENT_REQUEST.replace("{base}", API_BASE); return ( <> @@ -67,15 +107,15 @@ export default function DocsPage() {

API first

-

Attach proof to every AI response.

+

Attach proof to every AI action.

- Future OKX.AI agents call RealityCheck after completing work. RealityCheck checks - objective evidence, returns a standardized Reality Receipt, and says UNVERIFIABLE - when evidence is missing. + OKX.AI agents fire one event when they act. RealityCheck detects it, checks + objective evidence, signs a standardized Reality Receipt, and delivers it to your + webhook — and says UNVERIFIABLE when evidence is missing.

+
✓ Autonomous route: /v1/events — AI acts, RealityCheck watches
✓ One marketplace route: /verify
-
✓ Legacy raw-claim route preserved: /v1/verify
✓ Receipts are Ed25519-signed and re-checkable
@@ -88,14 +128,34 @@ export default function DocsPage() {

Endpoints

+
POST/v1/eventsAgent acted — verification runs on its own
+
GET/v1/events/:idPipeline state + signed receipt
POST/verifyMarketplace claim in, Reality Receipt out
-
POST/v1/verifyLegacy raw objective claim
+
POST/v1/verifyRaw objective claim, no envelope
GET/v1/attestationsList signed receipts
POST/v1/attestations/verifyRe-check a receipt signature
+
GET/v1/track-record/:subjectVerified track record for a tx or wallet
GET/v1/statsVerification Center metrics
+
+

Autonomous verification

+

+ Fire one event when your agent acts. RealityCheck collects evidence, verifies the + claim against reality, signs a Reality Receipt, records it in the audit ledger, + and pushes the receipt to your callback — automatic, invisible, always on. +

+
+
POST /v1/eventsAI acts → RealityCheck watches
+
{eventRequest}
+
+
+
202 · then your webhook firesreceipt delivered
+
{EVENT_RESPONSE}
+
+
+

Marketplace Verification

Agents can send natural-language claims plus objective evidence metadata.

@@ -123,6 +183,18 @@ export default function DocsPage() {
+
+

Words are checked, not just data

+

+ The claim sentence itself is enforced against the evidence. An agent that says + “I paid 100 USDT” while attaching a structured claim for a smaller + amount does not get a VERIFIED receipt: the consistency gate reads the spoken + amount, compares it to what actually moved on-chain, and refutes the claim when + reality shows less. A receipt certifies the agent's words — so the words are + what gets verified. +

+
+

Verification modules

diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 62c130d..a6be7a2 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -1,951 +1,954 @@ -/* ========================================================================= - RealityCheck — design system - Light, restrained, infrastructure-grade. White surfaces, one accent, - verdict colour used only on verdicts. Shared chrome across every route. - ========================================================================= */ - -:root { - --bg: #f6f8fc; - --surface: #ffffff; - --surface-2: #fbfcfe; - - --ink: #0a1428; - --ink-2: #3a475c; - --muted: #6b7688; - --line: #e7ebf2; - --line-2: #f0f3f8; - - --accent: #3452ff; - --accent-ink: #233bd6; - --accent-soft: #eef1ff; - - --ok: #0a7c42; - --ok-solid: #12a150; - --ok-bg: #e9f7ef; - --ok-line: #c2e6d1; - - --bad: #c02535; - --bad-solid: #e5484d; - --bad-bg: #fdecec; - --bad-line: #f4c9cb; - - --warn: #8a5a00; - --warn-solid: #d99a1a; - --warn-bg: #fbf3df; - --warn-line: #ecd9a8; - - --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; - - --r: 10px; - --r-lg: 16px; - --shadow-sm: 0 1px 2px rgba(10, 20, 40, 0.05); - --shadow: 0 1px 2px rgba(10, 20, 40, 0.04), 0 6px 20px rgba(10, 20, 40, 0.06); - --shadow-lg: 0 2px 6px rgba(10, 20, 40, 0.05), 0 20px 48px rgba(10, 20, 40, 0.10); - - --maxw: 1120px; -} - -* { box-sizing: border-box; margin: 0; padding: 0; } -html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } -body { - font-family: var(--sans); - background: var(--bg); - color: var(--ink); - line-height: 1.5; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} -a { color: inherit; text-decoration: none; } - -.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } -.container { max-width: var(--maxw); margin: 0 auto; padding: 0 24px; } - -.eyebrow { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--accent); -} -.lead { color: var(--ink-2); font-size: 17px; line-height: 1.55; } - -.live-dot { - display: inline-block; - width: 7px; height: 7px; - border-radius: 50%; - background: var(--ok-solid); - box-shadow: 0 0 0 3px rgba(18, 161, 80, 0.16); - animation: pulse 1.8s ease-in-out infinite; -} -@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } - -/* ---- buttons ---- */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - font-family: var(--sans); - font-weight: 600; - font-size: 15px; - line-height: 1; - padding: 12px 18px; - border-radius: 9px; - border: 1px solid transparent; - cursor: pointer; - transition: transform 0.06s ease, box-shadow 0.15s ease, background 0.15s ease, - border-color 0.15s ease, color 0.15s ease; -} -.btn:active { transform: translateY(1px); } -.btn-sm { font-size: 13.5px; padding: 8px 14px; border-radius: 8px; } -.btn.primary { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); } -.btn.primary:hover { background: var(--accent-ink); } -.btn.ghost { background: var(--surface); color: var(--ink); border-color: var(--line); } -.btn.ghost:hover { border-color: #cdd5e4; } -.btn.link { background: transparent; color: var(--accent); padding-left: 4px; padding-right: 4px; } -.btn.link:hover { color: var(--accent-ink); } - -/* ========================================================================= - SHARED CHROME — top nav + footer - ========================================================================= */ -.wordmark { font-weight: 800; font-size: 18px; letter-spacing: -0.02em; color: var(--ink); } -.wordmark span { color: var(--accent); } - -.topnav { - position: sticky; - top: 0; - z-index: 50; - background: rgba(246, 248, 252, 0.8); - backdrop-filter: saturate(180%) blur(12px); - border-bottom: 1px solid var(--line); -} -.topnav-inner { - max-width: var(--maxw); - margin: 0 auto; - padding: 0 24px; - height: 60px; - display: flex; - align-items: center; - gap: 28px; -} -.topnav-links { - display: flex; - gap: 6px; - margin-left: 8px; -} -.topnav-links a { - font-size: 14px; - font-weight: 500; - color: var(--ink-2); - padding: 7px 11px; - border-radius: 7px; - transition: color 0.12s ease, background 0.12s ease; -} -.topnav-links a:hover { color: var(--ink); background: var(--line-2); } -.topnav-links a.on { color: var(--accent); } -.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } - -.net-pill { - display: inline-flex; - align-items: center; - gap: 7px; - font-size: 12.5px; - font-weight: 600; - color: var(--ink-2); - padding: 6px 11px; - border-radius: 999px; - background: var(--surface); - border: 1px solid var(--line); -} -.net-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--ok-solid); } -.net-pill.down { color: var(--bad); } -.net-pill.down i { background: var(--bad-solid); } - -.appfooter { border-top: 1px solid var(--line); margin-top: 72px; background: var(--surface-2); } -.appfooter-inner { - max-width: var(--maxw); - margin: 0 auto; - padding: 34px 24px; - display: flex; - align-items: flex-start; - gap: 24px; - flex-wrap: wrap; -} -.appfooter-brand p { margin-top: 8px; color: var(--muted); font-size: 13.5px; max-width: 42ch; } -.appfooter-links { margin-left: auto; display: flex; gap: 22px; font-size: 14px; color: var(--ink-2); } -.appfooter-links a:hover { color: var(--accent); } - -@media (max-width: 720px) { - .topnav-links { display: none; } - .net-pill { display: none; } -} - -/* ========================================================================= - HOME - ========================================================================= */ -.home section { padding: 0; } -.home .band { padding: 84px 0; } -.home .band + .band { border-top: 1px solid var(--line); } - -/* hero */ -.hero-grid { - display: grid; - grid-template-columns: 1.05fr 0.95fr; - gap: 56px; - align-items: center; -} -.hero-copy .eyebrow { margin-bottom: 18px; } -.hero-h1 { - font-size: clamp(34px, 4.6vw, 54px); - line-height: 1.05; - letter-spacing: -0.032em; - font-weight: 800; - color: var(--ink); -} -.hero-sub { - margin-top: 20px; - max-width: 44ch; - color: var(--ink-2); - font-size: 18px; - line-height: 1.55; -} -.hero-cta { margin-top: 30px; display: flex; gap: 12px; flex-wrap: wrap; } -.hero-meta { - margin-top: 26px; - display: flex; - gap: 22px; - flex-wrap: wrap; - color: var(--muted); - font-size: 13.5px; -} -.hero-meta span { display: inline-flex; align-items: center; gap: 7px; } -.hero-meta i { color: var(--ok-solid); font-style: normal; font-weight: 700; } -.hero-visual { display: flex; justify-content: center; } - -@media (max-width: 900px) { - .hero-grid { grid-template-columns: 1fr; gap: 40px; } - .hero-visual { justify-content: flex-start; } -} - -.engine-stack { - width: 100%; - max-width: 440px; - display: flex; - flex-direction: column; - gap: 14px; -} -.engine-trace { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 14px; - box-shadow: var(--shadow-sm); -} -.engine-trace-head { - display: flex; - align-items: center; - gap: 9px; - color: var(--ink-2); - font-size: 13px; - font-weight: 700; - margin-bottom: 12px; -} -.engine-steps { - display: grid; - grid-template-columns: 1fr; - gap: 7px; -} -.engine-step { - position: relative; - min-height: 32px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - color: var(--muted); - background: var(--surface-2); - border: 1px solid var(--line-2); - border-radius: 8px; - padding: 7px 10px; - font-size: 12.5px; - font-weight: 700; - transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; -} -.engine-step::after { - content: ""; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--line); -} -.engine-step.on { - color: var(--ink); - background: #f8faff; - border-color: #dce4ff; -} -.engine-step.on::after { background: var(--accent); } -.engine-step.verified.on { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); } -.engine-step.mismatch.on { color: var(--bad); border-color: var(--bad-line); background: var(--bad-bg); } -.engine-step.verified.on::after { background: var(--ok-solid); } -.engine-step.mismatch.on::after { background: var(--bad-solid); } -/* section heading */ -.sec-head { max-width: 640px; margin-bottom: 36px; } -.sec-head h2 { - font-size: clamp(26px, 3.2vw, 34px); - letter-spacing: -0.025em; - line-height: 1.12; - font-weight: 800; -} -.sec-head p { margin-top: 12px; color: var(--muted); font-size: 16px; } - -/* claim vs reality — two receipts */ -.proof.band { background: var(--surface-2); } -.proof-two { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; - align-items: start; - justify-items: center; -} -.proof-col { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 12px; } -.proof-tag { font-size: 13px; font-weight: 600; color: var(--ink-2); } -.proof-note { - margin-top: 28px; - color: var(--ink-2); - font-size: 15px; - max-width: 60ch; -} -@media (max-width: 820px) { .proof-two { grid-template-columns: 1fr; } } - -/* how it works — 3 steps */ -.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } -.step { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 24px; -} -.step-n { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; height: 28px; - border-radius: 8px; - background: var(--accent-soft); - color: var(--accent); - font-weight: 800; - font-size: 14px; - font-variant-numeric: tabular-nums; -} -.step h3 { margin: 16px 0 8px; font-size: 17px; letter-spacing: -0.01em; } -.step p { color: var(--muted); font-size: 14.5px; line-height: 1.55; } -.step code { font-family: var(--mono); font-size: 12.5px; color: var(--accent-ink); } -@media (max-width: 820px) { .steps { grid-template-columns: 1fr; } } - -/* live strip */ -.live.band { background: var(--surface-2); } -.live-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 16px; - margin-bottom: 22px; -} -.live-head h2 { font-size: clamp(22px, 2.6vw, 28px); letter-spacing: -0.02em; } -.live-head a { color: var(--accent); font-weight: 600; font-size: 14px; } -.live-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } -.stat { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r); - padding: 20px; -} -.stat-v { - display: block; - font-size: 30px; - font-weight: 800; - letter-spacing: -0.02em; - font-variant-numeric: tabular-nums; -} -.stat-v em { font-style: normal; font-size: 15px; color: var(--muted); margin-left: 3px; } -.stat.ok .stat-v { color: var(--ok); } -.stat.bad .stat-v { color: var(--bad); } -.stat-l { display: block; margin-top: 6px; font-size: 13.5px; color: var(--muted); } -@media (max-width: 820px) { .live-grid { grid-template-columns: repeat(2, 1fr); } } -@media (max-width: 460px) { .live-grid { grid-template-columns: 1fr; } } - -/* ========================================================================= - THE RECEIPT — clean data card (no gimmicks) - ========================================================================= */ -.rcpt { - position: relative; - width: 100%; - max-width: 420px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-lg); - box-shadow: var(--shadow); - overflow: hidden; -} -.rcpt-verified { border-top: 3px solid var(--ok-solid); } -.rcpt-mismatch { border-top: 3px solid var(--bad-solid); } -.rcpt-unverifiable { border-top: 3px solid var(--warn-solid); } - -.rcpt-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--line-2); -} -.rcpt-brand { font-weight: 700; font-size: 14px; letter-spacing: -0.01em; } -.rcpt-brand span { color: var(--accent); } -.rcpt-brand em { - display: block; - font-style: normal; - font-weight: 600; - font-size: 10.5px; - letter-spacing: 0.07em; - text-transform: uppercase; - color: var(--muted); - margin-top: 3px; -} -.rcpt-stamp { - font-weight: 700; - font-size: 12px; - letter-spacing: 0.05em; - padding: 5px 10px; - border-radius: 6px; - border: 1px solid; -} -.rcpt-stamp.verified { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); } -.rcpt-stamp.mismatch { color: var(--bad); border-color: var(--bad-line); background: var(--bad-bg); } -.rcpt-stamp.unverifiable { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); } - -.rcpt-k { - font-size: 10.5px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--muted); -} -.rcpt-claim { padding: 16px 20px; border-bottom: 1px solid var(--line-2); } -.rcpt-claim p { margin-top: 6px; font-size: 15px; font-weight: 500; color: var(--ink); } - -.rcpt-vs { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 10px; - padding: 16px 20px; -} -.rcpt-vs-col { display: flex; flex-direction: column; gap: 5px; } -.rcpt-vs-col.right { text-align: right; align-items: flex-end; } -.rcpt-amt { - font-size: 21px; - font-weight: 800; - letter-spacing: -0.02em; - font-variant-numeric: tabular-nums; -} -.rcpt-mismatch .rcpt-vs-col.right .rcpt-amt { color: var(--bad); } -.rcpt-verified .rcpt-vs-col.right .rcpt-amt { color: var(--ok); } -.rcpt-vs-mid { - font-size: 11px; - font-weight: 700; - color: var(--muted); - background: var(--bg); - border-radius: 6px; - padding: 4px 8px; -} - -.rcpt-match { - display: flex; - flex-direction: column; - gap: 3px; - margin: 0 20px 16px; - padding: 11px 14px; - border-radius: var(--r); - font-weight: 700; - font-size: 14px; -} -.rcpt-match span { font-weight: 500; font-size: 12.5px; } -.rcpt-match.ok { background: var(--ok-bg); color: var(--ok); } -.rcpt-match.ok span { color: #2c7a52; } -.rcpt-match.bad { background: var(--bad-bg); color: var(--bad); } -.rcpt-match.bad span { color: #a5424a; } - -.rcpt-rows { padding: 4px 20px 12px; } -.rcpt-rows > div { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 9px 0; - border-bottom: 1px solid var(--line-2); - font-size: 13px; -} -.rcpt-rows > div:last-child { border-bottom: none; } -.rcpt-rows dt { color: var(--muted); } -.rcpt-rows dd { font-weight: 600; text-align: right; } -.rcpt-rows dd.mono { font-weight: 500; } -.rcpt-token { color: var(--muted); font-weight: 600; font-size: 12px; } - -.rcpt-score { - display: flex; - align-items: center; - gap: 14px; - margin: 4px 20px 16px; - padding: 14px; - background: var(--surface-2); - border: 1px solid var(--line-2); - border-radius: var(--r); -} -.rcpt-score-num { display: flex; align-items: baseline; } -.rcpt-score-num .n { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } -.rcpt-score-num .d { font-size: 13px; color: var(--muted); margin-left: 2px; } -.rcpt-verified .rcpt-score-num .n { color: var(--ok); } -.rcpt-mismatch .rcpt-score-num .n { color: var(--bad); } -.rcpt-score-body { flex: 1; } -.rcpt-score-bar { margin-top: 7px; height: 6px; border-radius: 999px; background: #e3e8f1; overflow: hidden; } -.rcpt-score-bar span { display: block; height: 100%; border-radius: 999px; background: var(--ok-solid); } -.rcpt-mismatch .rcpt-score-bar span { background: var(--bad-solid); } - -/* proof strip — every full receipt shows it is signed and re-checkable */ -.rcpt-proof { - margin: 0 20px 16px; - padding: 13px 14px; - background: linear-gradient(180deg, #f6f8ff, var(--bg)); - border: 1px solid #e2e8fb; - border-radius: 10px; -} -.rcpt-proof-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} -.rcpt-proof-seal { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12.5px; - font-weight: 800; - letter-spacing: -0.01em; - color: var(--accent); -} -.rcpt-proof-algo { - font-family: var(--mono); - font-size: 10.5px; - font-weight: 700; - letter-spacing: 0.04em; - color: var(--muted); - background: var(--surface); - border: 1px solid var(--line); - border-radius: 999px; - padding: 2px 8px; -} -.rcpt-proof-rows { - margin: 10px 0 0; - display: flex; - flex-direction: column; - gap: 4px; -} -.rcpt-proof-rows > div { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - font-size: 12px; -} -.rcpt-proof-rows dt { color: var(--muted); } -.rcpt-proof-rows dd { color: var(--ink-2); } -.rcpt-proof-btn { - width: 100%; - margin-top: 11px; - font-family: var(--sans); - font-size: 12.5px; - font-weight: 700; - color: var(--accent); - background: var(--surface); - border: 1px solid #cdd9ff; - border-radius: 8px; - padding: 9px 10px; - cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease; -} -.rcpt-proof-btn:hover { border-color: var(--accent); box-shadow: 0 4px 12px rgba(47, 84, 255, 0.14); } -.rcpt-proof-btn:disabled { opacity: 0.6; cursor: default; } -.rcpt-proof-result { - margin-top: 9px; - font-size: 12.5px; - font-weight: 600; - line-height: 1.4; -} -.rcpt-proof-result.ok { color: var(--ok); } -.rcpt-proof-result.bad { color: var(--bad); } -.rcpt-proof-note { - margin-top: 9px; - font-size: 11.5px; - line-height: 1.45; - color: var(--muted); -} -.rcpt-proof-note a { color: var(--accent); font-weight: 600; } -.rcpt-proof-note a:hover { text-decoration: underline; } - -.rcpt-foot { border-top: 1px solid var(--line-2); padding: 14px 20px 16px; } -.rcpt-explorer { display: inline-block; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 12px; } -.rcpt-explorer:hover { text-decoration: underline; } -.rcpt-actions { display: flex; gap: 8px; flex-wrap: wrap; } -.rcpt-actions button { - flex: 1; - min-width: 90px; - font-family: var(--sans); - font-size: 12.5px; - font-weight: 600; - color: var(--ink-2); - background: var(--surface); - border: 1px solid var(--line); - border-radius: 8px; - padding: 9px 10px; - cursor: pointer; - transition: border-color 0.15s ease, color 0.15s ease; -} -.rcpt-actions button:hover { border-color: var(--accent); color: var(--accent); } - -.rcpt.compact { max-width: 400px; } -.rcpt.compact .rcpt-score { margin-bottom: 16px; } - -/* ========================================================================= - DOCS (/docs) - ========================================================================= */ -.docs { padding: 56px 0 0; } -.docs-grid { display: grid; grid-template-columns: 1fr 1.15fr; gap: 48px; align-items: start; } -.docs-head { position: sticky; top: 84px; } -.docs-head .eyebrow { margin-bottom: 16px; } -.docs-head h1 { font-size: clamp(30px, 4vw, 44px); letter-spacing: -0.03em; line-height: 1.06; font-weight: 800; } -.docs-head p { margin-top: 16px; color: var(--ink-2); font-size: 16px; max-width: 42ch; } -.docs-facts { margin-top: 26px; display: flex; flex-direction: column; gap: 12px; } -.docs-fact { display: flex; gap: 10px; align-items: flex-start; font-size: 14px; color: var(--ink-2); } -.docs-fact i { color: var(--ok-solid); font-style: normal; font-weight: 800; margin-top: 1px; } - -.docs-body { display: flex; flex-direction: column; gap: 28px; } -.docs-block h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 10px; } -.docs-block p { color: var(--muted); font-size: 14.5px; margin-bottom: 12px; } - -.endpoints { border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; background: var(--surface); } -.endpoint { - display: flex; - align-items: center; - gap: 12px; - padding: 13px 16px; - border-bottom: 1px solid var(--line-2); - font-size: 13.5px; -} -.endpoint:last-child { border-bottom: none; } -.endpoint .verb { - font-family: var(--mono); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.03em; - padding: 3px 7px; - border-radius: 5px; - background: var(--accent-soft); - color: var(--accent-ink); -} -.endpoint .verb.get { background: var(--ok-bg); color: var(--ok); } -.endpoint .path { font-family: var(--mono); color: var(--ink); font-weight: 600; } -.endpoint .desc { margin-left: auto; color: var(--muted); } - -.code { - background: #0b1020; - border-radius: var(--r-lg); - overflow: hidden; - box-shadow: var(--shadow); -} -.code-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} -.code-head .mono { color: #cdd5e6; font-size: 12.5px; font-weight: 600; } -.code-head .mono.ok { color: #5cd99a; } -.code-head .tag { color: #7b869c; font-size: 12px; } -.code pre { margin: 0; padding: 16px; overflow-x: auto; } -.code code { font-family: var(--mono); font-size: 12.5px; line-height: 1.75; color: #b7c2da; white-space: pre; } - -@media (max-width: 900px) { - .docs-grid { grid-template-columns: 1fr; gap: 32px; } - .docs-head { position: static; } -} - -/* ========================================================================= - APP PAGE SHELL (dashboard + console share the same wrapper) - ========================================================================= */ -.page { max-width: var(--maxw); margin: 0 auto; padding: 40px 24px 0; } -.page-head { margin-bottom: 28px; } -.page-head h1 { font-size: clamp(24px, 3vw, 32px); letter-spacing: -0.025em; font-weight: 800; } -.page-head p { margin-top: 10px; color: var(--muted); font-size: 15px; max-width: 60ch; } - -/* verdict chip palette (shared everywhere) */ -.chip { - font-size: 10.5px; - font-weight: 800; - letter-spacing: 0.03em; - padding: 3px 8px; - border-radius: 6px; -} -.chip.verified { background: var(--ok-bg); color: var(--ok); } -.chip.mismatch, .chip.refuted { background: var(--bad-bg); color: var(--bad); } -.chip.unverifiable, .chip.indeterminate { background: var(--warn-bg); color: var(--warn); } - -/* ========================================================================= - DASHBOARD (/dashboard) - ========================================================================= */ -.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; } -.kpi { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r); - padding: 18px 18px 14px; - position: relative; - overflow: hidden; -} -.kpi.accent { border-top: 3px solid var(--accent); } -.kpi.ok { border-top: 3px solid var(--ok-solid); } -.kpi.bad { border-top: 3px solid var(--bad-solid); } -.kpi-l { font-size: 13px; color: var(--muted); } -.kpi-v { - display: block; - margin-top: 6px; - font-size: 28px; - font-weight: 800; - letter-spacing: -0.02em; - font-variant-numeric: tabular-nums; -} -.kpi.ok .kpi-v { color: var(--ok); } -.kpi.bad .kpi-v { color: var(--bad); } -.kpi-v em { font-style: normal; font-size: 14px; color: var(--muted); margin-left: 3px; } -.kpi-s { display: block; margin-top: 4px; font-size: 12.5px; color: var(--muted); } -.spark { display: block; width: 100%; height: 30px; margin-top: 8px; } - -.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.card { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 20px; -} -.card.wide { grid-column: 1 / -1; } -.card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } -.muted { color: var(--muted); font-size: 14px; } -.card-note { margin-top: 14px; color: var(--muted); font-size: 13px; } - -.dist-bar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--line-2); } -.dist-bar .seg { height: 100%; } -.seg.verified { background: var(--ok-solid); } -.seg.refuted { background: var(--bad-solid); } -.seg.indeterminate { background: var(--warn-solid); } -.dist-legend { list-style: none; display: flex; gap: 20px; flex-wrap: wrap; margin-top: 14px; font-size: 13.5px; color: var(--ink-2); } -.dist-legend li { display: flex; align-items: center; gap: 7px; } -.dist-legend i { width: 10px; height: 10px; border-radius: 3px; } -.dist-legend i.verified { background: var(--ok-solid); } -.dist-legend i.refuted { background: var(--bad-solid); } -.dist-legend i.indeterminate { background: var(--warn-solid); } -.dist-legend b { font-weight: 700; } - -.nets { display: flex; gap: 10px; flex-wrap: wrap; } -.net { - display: inline-flex; - align-items: center; - gap: 7px; - font-size: 13.5px; - font-weight: 600; - padding: 7px 12px; - border-radius: 999px; - background: var(--surface-2); - border: 1px solid var(--line); -} -.net i { width: 7px; height: 7px; border-radius: 50%; background: var(--ok-solid); } - -.actions { list-style: none; display: flex; flex-direction: column; gap: 12px; } -.actions li { display: flex; align-items: center; gap: 14px; font-size: 14px; } -.act-name { width: 150px; color: var(--ink-2); font-weight: 600; } -.act-track { flex: 1; height: 8px; border-radius: 999px; background: var(--line-2); overflow: hidden; } -.act-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); } -.act-count { width: 48px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; } - -.dfeed { list-style: none; display: flex; flex-direction: column; } -.dfeed li { - display: flex; - align-items: center; - gap: 12px; - padding: 11px 2px; - border-bottom: 1px solid var(--line-2); - font-size: 13.5px; -} -.dfeed li:last-child { border-bottom: none; } -.dfeed-act { font-weight: 600; } -.dfeed-sub { color: var(--ink-2); } -.dfeed-time { margin-left: auto; color: var(--muted); font-size: 12.5px; } - -/* ========================================================================= - CONSOLE (/app) - ========================================================================= */ -.cons-grid { display: grid; grid-template-columns: 1.25fr 0.75fr; gap: 24px; align-items: start; } -@media (max-width: 900px) { .cons-grid { grid-template-columns: 1fr; } } - -.cons-panel, .cons-feed { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--r-lg); - padding: 20px; -} -.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } -.ph-title { font-weight: 700; font-size: 14px; display: inline-flex; align-items: center; gap: 8px; } -.ph-end { font-size: 12px; color: var(--muted); font-family: var(--mono); } - -/* one-click real-transaction presets */ -.presets { - margin-bottom: 16px; - padding: 14px; - background: var(--accent-soft); - border: 1px solid #d6e0ff; - border-radius: var(--r); -} -.presets-lead { - display: block; - font-size: 12.5px; - font-weight: 700; - color: var(--accent); - margin-bottom: 10px; -} -.presets-row { display: flex; gap: 8px; flex-wrap: wrap; } -.preset { - flex: 1; - min-width: 190px; - font-family: var(--sans); - font-size: 12.5px; - font-weight: 600; - text-align: left; - color: var(--ink); - background: var(--surface); - border: 1px solid #cdd9ff; - border-radius: 9px; - padding: 10px 12px; - cursor: pointer; - transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.06s ease; -} -.preset:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(52, 82, 255, 0.14); } -.preset:active { transform: translateY(1px); } - -.kindbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } -.kind { - font-family: var(--sans); - font-size: 13px; - font-weight: 600; - color: var(--ink-2); - background: var(--surface-2); - border: 1px solid var(--line); - border-radius: 999px; - padding: 8px 14px; - cursor: pointer; - transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; -} -.kind:hover { border-color: #cdd5e4; } -.kind.on { background: var(--ink); color: #fff; border-color: var(--ink); } - -.fields { display: flex; flex-direction: column; gap: 10px; } -.field { - font-family: var(--mono); - font-size: 13.5px; - color: var(--ink); - background: var(--surface-2); - border: 1px solid var(--line); - border-radius: 10px; - padding: 12px 14px; - outline: none; - transition: border-color 0.12s ease, background 0.12s ease; -} -.field:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft); } - -.envelope-preview { - background: var(--surface-2); - border: 1px dashed var(--line); - border-radius: 10px; - padding: 14px 16px; -} -.envelope-preview p { margin: 6px 0 0; font-size: 14px; color: var(--ink); } -.envelope-preview .envelope-note { font-size: 13px; color: var(--muted); } - -.verifybtn { - width: 100%; - margin-top: 14px; - font-family: var(--sans); - font-size: 15px; - font-weight: 700; - color: #fff; - background: var(--accent); - border: none; - border-radius: 10px; - padding: 13px; - cursor: pointer; - box-shadow: var(--shadow-sm); - transition: background 0.15s ease; -} -.verifybtn:hover { background: var(--accent-ink); } -.verifybtn:disabled { opacity: 0.6; cursor: default; } -.cons-err { margin-top: 12px; color: var(--bad); font-size: 14px; } - -.cons-result { margin-top: 22px; display: flex; flex-direction: column; align-items: center; gap: 16px; } -.reverify { - width: 100%; - max-width: 420px; - background: var(--surface-2); - border: 1px solid var(--line); - border-radius: var(--r); - padding: 14px 16px; -} -.reverify-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; color: var(--ink-2); } -.reverify-row button { - font-family: var(--sans); - font-size: 12.5px; - font-weight: 600; - color: var(--accent); - background: var(--surface); - border: 1px solid var(--line); - border-radius: 8px; - padding: 7px 11px; - cursor: pointer; - white-space: nowrap; -} -.reverify-ok { margin-top: 10px; color: var(--ok); font-size: 13px; } -.reverify-bad { margin-top: 10px; color: var(--bad); font-size: 13px; } -.reverify-trust { margin-top: 10px; color: var(--muted); font-size: 13px; } - -.cons-feed .feed-empty { color: var(--muted); font-size: 14px; padding: 8px 0; } -.feed { list-style: none; display: flex; flex-direction: column; } -.feed-row { - display: flex; - align-items: center; - gap: 10px; - padding: 11px 4px; - border-bottom: 1px solid var(--line-2); - font-size: 13px; -} -.feed-row:last-child { border-bottom: none; } -.feed-row.flash { animation: flash 1.4s ease; } -@keyframes flash { 0% { background: var(--accent-soft); } 100% { background: transparent; } } -.feed-sub { color: var(--ink-2); } -.feed-time { margin-left: auto; color: var(--muted); font-size: 12px; } -.feed-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12.5px; } - -@media (max-width: 820px) { - .kpis { grid-template-columns: repeat(2, 1fr); } - .dash-grid { grid-template-columns: 1fr; } -} -@media (max-width: 480px) { - .kpis { grid-template-columns: 1fr; } -} +/* ========================================================================= + RealityCheck — design system + Light, restrained, infrastructure-grade. White surfaces, one accent, + verdict colour used only on verdicts. Shared chrome across every route. + ========================================================================= */ + +:root { + --bg: #f6f8fc; + --surface: #ffffff; + --surface-2: #fbfcfe; + + --ink: #0a1428; + --ink-2: #3a475c; + --muted: #6b7688; + --line: #e7ebf2; + --line-2: #f0f3f8; + + --accent: #3452ff; + --accent-ink: #233bd6; + --accent-soft: #eef1ff; + + --ok: #0a7c42; + --ok-solid: #12a150; + --ok-bg: #e9f7ef; + --ok-line: #c2e6d1; + + --bad: #c02535; + --bad-solid: #e5484d; + --bad-bg: #fdecec; + --bad-line: #f4c9cb; + + --warn: #8a5a00; + --warn-solid: #d99a1a; + --warn-bg: #fbf3df; + --warn-line: #ecd9a8; + + --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; + + --r: 10px; + --r-lg: 16px; + --shadow-sm: 0 1px 2px rgba(10, 20, 40, 0.05); + --shadow: 0 1px 2px rgba(10, 20, 40, 0.04), 0 6px 20px rgba(10, 20, 40, 0.06); + --shadow-lg: 0 2px 6px rgba(10, 20, 40, 0.05), 0 20px 48px rgba(10, 20, 40, 0.10); + + --maxw: 1120px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } +body { + font-family: var(--sans); + background: var(--bg); + color: var(--ink); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +a { color: inherit; text-decoration: none; } + +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.container { max-width: var(--maxw); margin: 0 auto; padding: 0 24px; } + +.eyebrow { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.lead { color: var(--ink-2); font-size: 17px; line-height: 1.55; } + +.live-dot { + display: inline-block; + width: 7px; height: 7px; + border-radius: 50%; + background: var(--ok-solid); + box-shadow: 0 0 0 3px rgba(18, 161, 80, 0.16); + animation: pulse 1.8s ease-in-out infinite; +} +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + +/* ---- buttons ---- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: var(--sans); + font-weight: 600; + font-size: 15px; + line-height: 1; + padding: 12px 18px; + border-radius: 9px; + border: 1px solid transparent; + cursor: pointer; + transition: transform 0.06s ease, box-shadow 0.15s ease, background 0.15s ease, + border-color 0.15s ease, color 0.15s ease; +} +.btn:active { transform: translateY(1px); } +.btn-sm { font-size: 13.5px; padding: 8px 14px; border-radius: 8px; } +.btn.primary { background: var(--accent); color: #fff; box-shadow: var(--shadow-sm); } +.btn.primary:hover { background: var(--accent-ink); } +.btn.ghost { background: var(--surface); color: var(--ink); border-color: var(--line); } +.btn.ghost:hover { border-color: #cdd5e4; } +.btn.link { background: transparent; color: var(--accent); padding-left: 4px; padding-right: 4px; } +.btn.link:hover { color: var(--accent-ink); } + +/* ========================================================================= + SHARED CHROME — top nav + footer + ========================================================================= */ +.wordmark { font-weight: 800; font-size: 18px; letter-spacing: -0.02em; color: var(--ink); } +.wordmark span { color: var(--accent); } + +.topnav { + position: sticky; + top: 0; + z-index: 50; + background: rgba(246, 248, 252, 0.8); + backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--line); +} +.topnav-inner { + max-width: var(--maxw); + margin: 0 auto; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + gap: 28px; +} +.topnav-links { + display: flex; + gap: 6px; + margin-left: 8px; +} +.topnav-links a { + font-size: 14px; + font-weight: 500; + color: var(--ink-2); + padding: 7px 11px; + border-radius: 7px; + transition: color 0.12s ease, background 0.12s ease; +} +.topnav-links a:hover { color: var(--ink); background: var(--line-2); } +.topnav-links a.on { color: var(--accent); } +.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } + +.net-pill { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12.5px; + font-weight: 600; + color: var(--ink-2); + padding: 6px 11px; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line); +} +.net-pill i { width: 7px; height: 7px; border-radius: 50%; background: var(--ok-solid); } +.net-pill.down { color: var(--bad); } +.net-pill.down i { background: var(--bad-solid); } + +.appfooter { border-top: 1px solid var(--line); margin-top: 72px; background: var(--surface-2); } +.appfooter-inner { + max-width: var(--maxw); + margin: 0 auto; + padding: 34px 24px; + display: flex; + align-items: flex-start; + gap: 24px; + flex-wrap: wrap; +} +.appfooter-brand p { margin-top: 8px; color: var(--muted); font-size: 13.5px; max-width: 42ch; } +.appfooter-links { margin-left: auto; display: flex; gap: 22px; font-size: 14px; color: var(--ink-2); } +.appfooter-links a:hover { color: var(--accent); } + +@media (max-width: 720px) { + .topnav-links { display: none; } + .net-pill { display: none; } +} + +/* ========================================================================= + HOME + ========================================================================= */ +.home section { padding: 0; } +.home .band { padding: 84px 0; } +.home .band + .band { border-top: 1px solid var(--line); } + +/* hero */ +.hero-grid { + display: grid; + grid-template-columns: 1.05fr 0.95fr; + gap: 56px; + align-items: center; +} +.hero-copy .eyebrow { margin-bottom: 18px; } +.hero-h1 { + font-size: clamp(34px, 4.6vw, 54px); + line-height: 1.05; + letter-spacing: -0.032em; + font-weight: 800; + color: var(--ink); +} +.hero-sub { + margin-top: 20px; + max-width: 44ch; + color: var(--ink-2); + font-size: 18px; + line-height: 1.55; +} +.hero-cta { margin-top: 30px; display: flex; gap: 12px; flex-wrap: wrap; } +.hero-meta { + margin-top: 26px; + display: flex; + gap: 22px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13.5px; +} +.hero-meta span { display: inline-flex; align-items: center; gap: 7px; } +.hero-meta i { color: var(--ok-solid); font-style: normal; font-weight: 700; } +.hero-visual { display: flex; justify-content: center; } + +@media (max-width: 900px) { + .hero-grid { grid-template-columns: 1fr; gap: 40px; } + .hero-visual { justify-content: flex-start; } +} + +.engine-stack { + width: 100%; + max-width: 440px; + display: flex; + flex-direction: column; + gap: 14px; +} +.engine-trace { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 14px; + box-shadow: var(--shadow-sm); +} +.engine-trace-head { + display: flex; + align-items: center; + gap: 9px; + color: var(--ink-2); + font-size: 13px; + font-weight: 700; + margin-bottom: 12px; +} +.engine-steps { + display: grid; + grid-template-columns: 1fr; + gap: 7px; +} +.engine-step { + position: relative; + min-height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--muted); + background: var(--surface-2); + border: 1px solid var(--line-2); + border-radius: 8px; + padding: 7px 10px; + font-size: 12.5px; + font-weight: 700; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} +.engine-step::after { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--line); +} +.engine-step.on { + color: var(--ink); + background: #f8faff; + border-color: #dce4ff; +} +.engine-step.on::after { background: var(--accent); } +.engine-step.verified.on { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); } +.engine-step.mismatch.on { color: var(--bad); border-color: var(--bad-line); background: var(--bad-bg); } +.engine-step.verified.on::after { background: var(--ok-solid); } +.engine-step.mismatch.on::after { background: var(--bad-solid); } +/* section heading */ +.sec-head { max-width: 640px; margin-bottom: 36px; } +.sec-head h2 { + font-size: clamp(26px, 3.2vw, 34px); + letter-spacing: -0.025em; + line-height: 1.12; + font-weight: 800; +} +.sec-head p { margin-top: 12px; color: var(--muted); font-size: 16px; } + +/* claim vs reality — two receipts */ +.proof.band { background: var(--surface-2); } +.proof-two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; + justify-items: center; +} +.proof-col { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 12px; } +.proof-tag { font-size: 13px; font-weight: 600; color: var(--ink-2); } +.proof-note { + margin-top: 28px; + color: var(--ink-2); + font-size: 15px; + max-width: 60ch; +} +@media (max-width: 820px) { .proof-two { grid-template-columns: 1fr; } } + +/* how it works — 3 steps */ +.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } +.step { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 24px; +} +.step-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; height: 28px; + border-radius: 8px; + background: var(--accent-soft); + color: var(--accent); + font-weight: 800; + font-size: 14px; + font-variant-numeric: tabular-nums; +} +.step h3 { margin: 16px 0 8px; font-size: 17px; letter-spacing: -0.01em; } +.step p { color: var(--muted); font-size: 14.5px; line-height: 1.55; } +.step code { font-family: var(--mono); font-size: 12.5px; color: var(--accent-ink); } +@media (max-width: 820px) { .steps { grid-template-columns: 1fr; } } + +/* live strip */ +.live.band { background: var(--surface-2); } +.live-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} +.live-head h2 { font-size: clamp(22px, 2.6vw, 28px); letter-spacing: -0.02em; } +.live-head a { color: var(--accent); font-weight: 600; font-size: 14px; } +.live-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } +.stat { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r); + padding: 20px; +} +.stat-v { + display: block; + font-size: 30px; + font-weight: 800; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.stat-v em { font-style: normal; font-size: 15px; color: var(--muted); margin-left: 3px; } +.stat.ok .stat-v { color: var(--ok); } +.stat.bad .stat-v { color: var(--bad); } +.stat-l { display: block; margin-top: 6px; font-size: 13.5px; color: var(--muted); } +@media (max-width: 820px) { .live-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 460px) { .live-grid { grid-template-columns: 1fr; } } + +/* ========================================================================= + THE RECEIPT — clean data card (no gimmicks) + ========================================================================= */ +.rcpt { + position: relative; + width: 100%; + max-width: 420px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-lg); + box-shadow: var(--shadow); + overflow: hidden; +} +.rcpt-verified { border-top: 3px solid var(--ok-solid); } +.rcpt-mismatch { border-top: 3px solid var(--bad-solid); } +.rcpt-unverifiable { border-top: 3px solid var(--warn-solid); } + +.rcpt-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--line-2); +} +.rcpt-brand { font-weight: 700; font-size: 14px; letter-spacing: -0.01em; } +.rcpt-brand span { color: var(--accent); } +.rcpt-brand em { + display: block; + font-style: normal; + font-weight: 600; + font-size: 10.5px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--muted); + margin-top: 3px; +} +.rcpt-stamp { + font-weight: 700; + font-size: 12px; + letter-spacing: 0.05em; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid; +} +.rcpt-stamp.verified { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); } +.rcpt-stamp.mismatch { color: var(--bad); border-color: var(--bad-line); background: var(--bad-bg); } +.rcpt-stamp.unverifiable { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); } + +.rcpt-k { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +.rcpt-claim { padding: 16px 20px; border-bottom: 1px solid var(--line-2); } +.rcpt-claim p { margin-top: 6px; font-size: 15px; font-weight: 500; color: var(--ink); } + +.rcpt-vs { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 10px; + padding: 16px 20px; +} +.rcpt-vs-col { display: flex; flex-direction: column; gap: 5px; } +.rcpt-vs-col.right { text-align: right; align-items: flex-end; } +.rcpt-amt { + font-size: 21px; + font-weight: 800; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.rcpt-mismatch .rcpt-vs-col.right .rcpt-amt { color: var(--bad); } +.rcpt-verified .rcpt-vs-col.right .rcpt-amt { color: var(--ok); } +.rcpt-vs-mid { + font-size: 11px; + font-weight: 700; + color: var(--muted); + background: var(--bg); + border-radius: 6px; + padding: 4px 8px; +} + +.rcpt-match { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0 20px 16px; + padding: 11px 14px; + border-radius: var(--r); + font-weight: 700; + font-size: 14px; +} +.rcpt-match span { font-weight: 500; font-size: 12.5px; } +.rcpt-match.ok { background: var(--ok-bg); color: var(--ok); } +.rcpt-match.ok span { color: #2c7a52; } +.rcpt-match.bad { background: var(--bad-bg); color: var(--bad); } +.rcpt-match.bad span { color: #a5424a; } + +.rcpt-rows { padding: 4px 20px 12px; } +.rcpt-rows > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--line-2); + font-size: 13px; +} +.rcpt-rows > div:last-child { border-bottom: none; } +.rcpt-rows dt { color: var(--muted); } +.rcpt-rows dd { font-weight: 600; text-align: right; } +.rcpt-rows dd.mono { font-weight: 500; } +.rcpt-token { color: var(--muted); font-weight: 600; font-size: 12px; } + +.rcpt-score { + display: flex; + align-items: center; + gap: 14px; + margin: 4px 20px 16px; + padding: 14px; + background: var(--surface-2); + border: 1px solid var(--line-2); + border-radius: var(--r); +} +.rcpt-score-num { display: flex; align-items: baseline; } +.rcpt-score-num .n { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } +.rcpt-score-num .d { font-size: 13px; color: var(--muted); margin-left: 2px; } +.rcpt-verified .rcpt-score-num .n { color: var(--ok); } +.rcpt-mismatch .rcpt-score-num .n { color: var(--bad); } +.rcpt-score-body { flex: 1; } +.rcpt-score-bar { margin-top: 7px; height: 6px; border-radius: 999px; background: #e3e8f1; overflow: hidden; } +.rcpt-score-bar span { display: block; height: 100%; border-radius: 999px; background: var(--ok-solid); } +.rcpt-mismatch .rcpt-score-bar span { background: var(--bad-solid); } + +/* proof strip — every full receipt shows it is signed and re-checkable */ +.rcpt-proof { + margin: 0 20px 16px; + padding: 13px 14px; + background: linear-gradient(180deg, #f6f8ff, var(--bg)); + border: 1px solid #e2e8fb; + border-radius: 10px; +} +.rcpt-proof-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.rcpt-proof-seal { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--accent); +} +.rcpt-proof-algo { + font-family: var(--mono); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 999px; + padding: 2px 8px; +} +.rcpt-proof-rows { + margin: 10px 0 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.rcpt-proof-rows > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} +.rcpt-proof-rows dt { color: var(--muted); } +.rcpt-proof-rows dd { color: var(--ink-2); } +.rcpt-proof-btn { + width: 100%; + margin-top: 11px; + font-family: var(--sans); + font-size: 12.5px; + font-weight: 700; + color: var(--accent); + background: var(--surface); + border: 1px solid #cdd9ff; + border-radius: 8px; + padding: 9px 10px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.rcpt-proof-btn:hover { border-color: var(--accent); box-shadow: 0 4px 12px rgba(47, 84, 255, 0.14); } +.rcpt-proof-btn:disabled { opacity: 0.6; cursor: default; } +.rcpt-proof-result { + margin-top: 9px; + font-size: 12.5px; + font-weight: 600; + line-height: 1.4; +} +.rcpt-proof-result.ok { color: var(--ok); } +.rcpt-proof-result.bad { color: var(--bad); } +.rcpt-proof-note { + margin-top: 9px; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted); +} +.rcpt-proof-note a { color: var(--accent); font-weight: 600; } +.rcpt-proof-note a:hover { text-decoration: underline; } + +.rcpt-foot { border-top: 1px solid var(--line-2); padding: 14px 20px 16px; } +.rcpt-explorer { display: inline-block; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 12px; } +.rcpt-explorer:hover { text-decoration: underline; } +.rcpt-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.rcpt-actions button { + flex: 1; + min-width: 90px; + font-family: var(--sans); + font-size: 12.5px; + font-weight: 600; + color: var(--ink-2); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 10px; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; +} +.rcpt-actions button:hover { border-color: var(--accent); color: var(--accent); } + +.rcpt.compact { max-width: 400px; } +.rcpt.compact .rcpt-score { margin-bottom: 16px; } + +/* ========================================================================= + DOCS (/docs) + ========================================================================= */ +.docs { padding: 56px 0 0; } +.docs-grid { display: grid; grid-template-columns: 1fr 1.15fr; gap: 48px; align-items: start; } +.docs-head { position: sticky; top: 84px; } +.docs-head .eyebrow { margin-bottom: 16px; } +.docs-head h1 { font-size: clamp(30px, 4vw, 44px); letter-spacing: -0.03em; line-height: 1.06; font-weight: 800; } +.docs-head p { margin-top: 16px; color: var(--ink-2); font-size: 16px; max-width: 42ch; } +.docs-facts { margin-top: 26px; display: flex; flex-direction: column; gap: 12px; } +.docs-fact { display: flex; gap: 10px; align-items: flex-start; font-size: 14px; color: var(--ink-2); } +.docs-fact i { color: var(--ok-solid); font-style: normal; font-weight: 800; margin-top: 1px; } + +.docs-body { display: flex; flex-direction: column; gap: 28px; } +.docs-block h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 10px; } +.docs-block p { color: var(--muted); font-size: 14.5px; margin-bottom: 12px; } + +.endpoints { border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; background: var(--surface); } +.endpoint { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 16px; + border-bottom: 1px solid var(--line-2); + font-size: 13.5px; +} +.endpoint:last-child { border-bottom: none; } +.endpoint .verb { + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + padding: 3px 7px; + border-radius: 5px; + background: var(--accent-soft); + color: var(--accent-ink); +} +.endpoint .verb.get { background: var(--ok-bg); color: var(--ok); } +.endpoint .path { font-family: var(--mono); color: var(--ink); font-weight: 600; } +.endpoint .desc { margin-left: auto; color: var(--muted); } + +.code { + background: #0b1020; + border-radius: var(--r-lg); + overflow: hidden; + box-shadow: var(--shadow); +} +.code-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.code-head .mono { color: #cdd5e6; font-size: 12.5px; font-weight: 600; } +.code-head .mono.ok { color: #5cd99a; } +.code-head .tag { color: #7b869c; font-size: 12px; } +.code pre { margin: 0; padding: 16px; overflow-x: auto; } +.code code { font-family: var(--mono); font-size: 12.5px; line-height: 1.75; color: #b7c2da; white-space: pre; } + +@media (max-width: 900px) { + .docs-grid { grid-template-columns: 1fr; gap: 32px; } + .docs-head { position: static; } +} + +/* ========================================================================= + APP PAGE SHELL (dashboard + console share the same wrapper) + ========================================================================= */ +.page { max-width: var(--maxw); margin: 0 auto; padding: 40px 24px 0; } +.page-head { margin-bottom: 28px; } +.page-head h1 { font-size: clamp(24px, 3vw, 32px); letter-spacing: -0.025em; font-weight: 800; } +.page-head p { margin-top: 10px; color: var(--muted); font-size: 15px; max-width: 60ch; } + +/* verdict chip palette (shared everywhere) */ +.chip { + font-size: 10.5px; + font-weight: 800; + letter-spacing: 0.03em; + padding: 3px 8px; + border-radius: 6px; +} +.chip.verified { background: var(--ok-bg); color: var(--ok); } +.chip.mismatch, .chip.refuted { background: var(--bad-bg); color: var(--bad); } +.chip.unverifiable, .chip.indeterminate { background: var(--warn-bg); color: var(--warn); } + +/* ========================================================================= + DASHBOARD (/dashboard) + ========================================================================= */ +.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; } +.kpi { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r); + padding: 18px 18px 14px; + position: relative; + overflow: hidden; +} +.kpi.accent { border-top: 3px solid var(--accent); } +.kpi.ok { border-top: 3px solid var(--ok-solid); } +.kpi.bad { border-top: 3px solid var(--bad-solid); } +.kpi-l { font-size: 13px; color: var(--muted); } +.kpi-v { + display: block; + margin-top: 6px; + font-size: 28px; + font-weight: 800; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.kpi.ok .kpi-v { color: var(--ok); } +.kpi.bad .kpi-v { color: var(--bad); } +.kpi-v em { font-style: normal; font-size: 14px; color: var(--muted); margin-left: 3px; } +.kpi-s { display: block; margin-top: 4px; font-size: 12.5px; color: var(--muted); } +.spark { display: block; width: 100%; height: 30px; margin-top: 8px; } + +.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 20px; +} +.card.wide { grid-column: 1 / -1; } +.card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } +.muted { color: var(--muted); font-size: 14px; } +.card-note { margin-top: 14px; color: var(--muted); font-size: 13px; } + +.dist-bar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--line-2); } +.dist-bar .seg { height: 100%; } +.seg.verified { background: var(--ok-solid); } +.seg.refuted { background: var(--bad-solid); } +.seg.indeterminate { background: var(--warn-solid); } +.dist-legend { list-style: none; display: flex; gap: 20px; flex-wrap: wrap; margin-top: 14px; font-size: 13.5px; color: var(--ink-2); } +.dist-legend li { display: flex; align-items: center; gap: 7px; } +.dist-legend i { width: 10px; height: 10px; border-radius: 3px; } +.dist-legend i.verified { background: var(--ok-solid); } +.dist-legend i.refuted { background: var(--bad-solid); } +.dist-legend i.indeterminate { background: var(--warn-solid); } +.dist-legend b { font-weight: 700; } + +.nets { display: flex; gap: 10px; flex-wrap: wrap; } +.net { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13.5px; + font-weight: 600; + padding: 7px 12px; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--line); +} +.net i { width: 7px; height: 7px; border-radius: 50%; background: var(--ok-solid); } + +.actions { list-style: none; display: flex; flex-direction: column; gap: 12px; } +.actions li { display: flex; align-items: center; gap: 14px; font-size: 14px; } +.act-name { width: 150px; color: var(--ink-2); font-weight: 600; } +.act-track { flex: 1; height: 8px; border-radius: 999px; background: var(--line-2); overflow: hidden; } +.act-fill { display: block; height: 100%; border-radius: 999px; background: var(--accent); } +.act-count { width: 48px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; } + +.dfeed { list-style: none; display: flex; flex-direction: column; } +.dfeed li { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 2px; + border-bottom: 1px solid var(--line-2); + font-size: 13.5px; +} +.dfeed li:last-child { border-bottom: none; } +.dfeed-act { font-weight: 600; } +.dfeed-sub { color: var(--ink-2); } +.dfeed-time { margin-left: auto; color: var(--muted); font-size: 12.5px; } + +/* ========================================================================= + CONSOLE (/app) + ========================================================================= */ +.cons-grid { display: grid; grid-template-columns: 1.25fr 0.75fr; gap: 24px; align-items: start; } +@media (max-width: 900px) { .cons-grid { grid-template-columns: 1fr; } } + +.cons-panel, .cons-feed { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-lg); + padding: 20px; +} +.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.ph-title { font-weight: 700; font-size: 14px; display: inline-flex; align-items: center; gap: 8px; } +.ph-end { font-size: 12px; color: var(--muted); font-family: var(--mono); } + +/* one-click real-transaction presets */ +.presets { + margin-bottom: 16px; + padding: 14px; + background: var(--accent-soft); + border: 1px solid #d6e0ff; + border-radius: var(--r); +} +.presets-lead { + display: block; + font-size: 12.5px; + font-weight: 700; + color: var(--accent); + margin-bottom: 10px; +} +.presets-row { display: flex; gap: 8px; flex-wrap: wrap; } +.preset { + flex: 1; + min-width: 190px; + font-family: var(--sans); + font-size: 12.5px; + font-weight: 600; + text-align: left; + color: var(--ink); + background: var(--surface); + border: 1px solid #cdd9ff; + border-radius: 9px; + padding: 10px 12px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.06s ease; +} +.preset:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(52, 82, 255, 0.14); } +.preset:active { transform: translateY(1px); } + +.kindbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } +.kind { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--ink-2); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 999px; + padding: 8px 14px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; +} +.kind:hover { border-color: #cdd5e4; } +.kind.on { background: var(--ink); color: #fff; border-color: var(--ink); } + +.fields { display: flex; flex-direction: column; gap: 10px; } +.field { + font-family: var(--mono); + font-size: 13.5px; + color: var(--ink); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; + outline: none; + transition: border-color 0.12s ease, background 0.12s ease; +} +.field:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-soft); } +.field-row { display: flex; gap: 10px; } +.field-row .field { flex: 1; } +.field-row .field-narrow { flex: 0 0 92px; } + +.envelope-preview { + background: var(--surface-2); + border: 1px dashed var(--line); + border-radius: 10px; + padding: 14px 16px; +} +.envelope-preview p { margin: 6px 0 0; font-size: 14px; color: var(--ink); } +.envelope-preview .envelope-note { font-size: 13px; color: var(--muted); } + +.verifybtn { + width: 100%; + margin-top: 14px; + font-family: var(--sans); + font-size: 15px; + font-weight: 700; + color: #fff; + background: var(--accent); + border: none; + border-radius: 10px; + padding: 13px; + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: background 0.15s ease; +} +.verifybtn:hover { background: var(--accent-ink); } +.verifybtn:disabled { opacity: 0.6; cursor: default; } +.cons-err { margin-top: 12px; color: var(--bad); font-size: 14px; } + +.cons-result { margin-top: 22px; display: flex; flex-direction: column; align-items: center; gap: 16px; } +.reverify { + width: 100%; + max-width: 420px; + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--r); + padding: 14px 16px; +} +.reverify-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; color: var(--ink-2); } +.reverify-row button { + font-family: var(--sans); + font-size: 12.5px; + font-weight: 600; + color: var(--accent); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + padding: 7px 11px; + cursor: pointer; + white-space: nowrap; +} +.reverify-ok { margin-top: 10px; color: var(--ok); font-size: 13px; } +.reverify-bad { margin-top: 10px; color: var(--bad); font-size: 13px; } +.reverify-record { margin-top: 10px; color: var(--muted); font-size: 13px; } + +.cons-feed .feed-empty { color: var(--muted); font-size: 14px; padding: 8px 0; } +.feed { list-style: none; display: flex; flex-direction: column; } +.feed-row { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 4px; + border-bottom: 1px solid var(--line-2); + font-size: 13px; +} +.feed-row:last-child { border-bottom: none; } +.feed-row.flash { animation: flash 1.4s ease; } +@keyframes flash { 0% { background: var(--accent-soft); } 100% { background: transparent; } } +.feed-sub { color: var(--ink-2); } +.feed-time { margin-left: auto; color: var(--muted); font-size: 12px; } +.feed-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12.5px; } + +@media (max-width: 820px) { + .kpis { grid-template-columns: repeat(2, 1fr); } + .dash-grid { grid-template-columns: 1fr; } +} +@media (max-width: 480px) { + .kpis { grid-template-columns: 1fr; } +} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 90a53b3..74c1192 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -3,9 +3,9 @@ import type { ReactNode } from "react"; import "./globals.css"; export const metadata: Metadata = { - title: "RealityCheck · Independent verification for AI agents", + title: "RealityCheck · The verification layer for AI agents", description: - "Don't trust AI claims. RealityCheck verifies objective evidence and returns signed Reality Receipts for OKX.AI agents.", + "RealityCheck is the verification layer for AI agents. It checks objective evidence and returns signed Reality Receipts for OKX.AI agents.", }; export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 92a4cb5..0a0da60 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -3,9 +3,9 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { API_BASE } from "@/lib/format"; -import { type LedgerStats } from "@/lib/types"; +import { type LedgerStats, type VerificationResult } from "@/lib/types"; import { ReceiptCard } from "@/components/Receipt"; -import { DEMO_BAD, DEMO_GOOD } from "@/lib/receipt"; +import { DEMO_BAD, DEMO_GOOD, LIVE_DEMO_ENVELOPES, receiptFromResult, type Receipt } from "@/lib/receipt"; import { SiteNav } from "@/components/SiteNav"; import { SiteFooter } from "@/components/SiteFooter"; @@ -24,33 +24,35 @@ const EMPTY_STATS: LedgerStats = { hourly: [], }; -const DEMO_SEQUENCE = [ - { - label: "Wallet Agent claimed it paid the supplier.", - receipt: DEMO_GOOD, - pipeline: ["AI Claim", "Reality Engine", "Evidence", "Reality Receipt", "VERIFIED"], - }, - { - label: "Wallet Agent claimed 100 USDT. Reality disagreed.", - receipt: DEMO_BAD, - pipeline: ["AI Claim", "Reality Engine", "Evidence", "Reality Receipt", "MISMATCH"], - }, +const DEMO_LABELS = [ + "Wallet Agent claimed it paid the supplier.", + "Wallet Agent claimed 100 USDT. Reality disagreed.", ]; -function EngineTrace({ step, demoIndex }: { step: number; demoIndex: number }) { - const active = DEMO_SEQUENCE[demoIndex]; +const DEMO_PIPELINES = [ + ["AI Claim", "Reality Engine", "Evidence", "Reality Receipt", "VERIFIED"], + ["AI Claim", "Reality Engine", "Evidence", "Reality Receipt", "MISMATCH"], +]; + +// Rendered until the live verifications land; replaced by real signed +// receipts from POST /verify against permanent X Layer transactions. +const FALLBACK_RECEIPTS: Receipt[] = [DEMO_GOOD, DEMO_BAD]; + +function EngineTrace({ step, demoIndex, live }: { step: number; demoIndex: number; live: boolean }) { + const pipeline = DEMO_PIPELINES[demoIndex]; + const verdictClass = (pipeline[pipeline.length - 1] ?? "").toLowerCase(); return (
- {active.label} + {DEMO_LABELS[demoIndex]}{live ? "" : " (cached example)"}
- {active.pipeline.map((item, i) => ( + {pipeline.map((item, i) => (
{item} @@ -66,6 +68,9 @@ export default function HomePage() { const [health, setHealth] = useState(null); const [demoIndex, setDemoIndex] = useState(0); const [demoStep, setDemoStep] = useState(0); + // Real receipts, verified against X Layer on page load. Null slots fall + // back to the cached examples until (or unless) the live check lands. + const [liveReceipts, setLiveReceipts] = useState<(Receipt | null)[]>([null, null]); const load = useCallback(async () => { try { @@ -80,6 +85,32 @@ export default function HomePage() { } }, []); + // The homepage IS the demo — and the demo is real. On load, submit both + // example claims to the live API; the receipts rendered in the hero are + // signed by the live attester and re-checkable by anyone. + useEffect(() => { + let cancelled = false; + LIVE_DEMO_ENVELOPES.forEach((envelope, index) => { + fetch(`${API_BASE}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }) + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + if (cancelled || body === null) return; + const receipt = receiptFromResult(body as VerificationResult); + setLiveReceipts((prev) => prev.map((r, i) => (i === index ? receipt : r))); + }) + .catch(() => { + /* API unreachable — the cached example stays, and is labeled as such */ + }); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { void load(); const id = setInterval(load, 8000); @@ -89,8 +120,8 @@ export default function HomePage() { useEffect(() => { const id = setInterval(() => { setDemoStep((current) => { - if (current < DEMO_SEQUENCE[demoIndex].pipeline.length - 1) return current + 1; - setDemoIndex((next) => (next + 1) % DEMO_SEQUENCE.length); + if (current < (DEMO_PIPELINES[demoIndex]?.length ?? 5) - 1) return current + 1; + setDemoIndex((next) => (next + 1) % DEMO_PIPELINES.length); return 0; }); }, 1150); @@ -98,7 +129,10 @@ export default function HomePage() { }, [demoIndex]); const recorded = health?.core.ledgerRecords ?? stats.total; - const activeDemo = DEMO_SEQUENCE[demoIndex]; + const goodReceipt = liveReceipts[0] ?? FALLBACK_RECEIPTS[0]; + const badReceipt = liveReceipts[1] ?? FALLBACK_RECEIPTS[1]; + const activeReceipt = demoIndex === 0 ? goodReceipt : badReceipt; + const activeLive = liveReceipts[demoIndex] !== null; return ( <> @@ -108,11 +142,12 @@ export default function HomePage() {
-

Independent verification layer · OKX.AI

-

Don't trust AI claims. Verify them.

+

The verification layer for AI agents · OKX.AI

+

AI claims. Reality decides.

- Every OKX.AI agent can make claims. RealityCheck independently checks objective - evidence and returns a signed Reality Receipt: verified, mismatch, or unverifiable. + RealityCheck is the verification layer for AI agents. Every claim an OKX.AI agent + makes is checked against objective evidence and answered with a signed Reality + Receipt: verified, mismatch, or unverifiable.

Open the Console @@ -126,8 +161,8 @@ export default function HomePage() {
- - + +
@@ -141,12 +176,12 @@ export default function HomePage() {
- AI Claim: transferred 120.011823 USDT - + AI Claim: paid at least 100 USDT · Reality: 120.011823 +
- AI Claim: transferred 100 USDT · Reality: 42.105574 - + AI Claim: paid 100 USDT · Reality: 42.105574 +

diff --git a/apps/dashboard/src/components/Receipt.tsx b/apps/dashboard/src/components/Receipt.tsx index 4c6501c..1b6fde7 100644 --- a/apps/dashboard/src/components/Receipt.tsx +++ b/apps/dashboard/src/components/Receipt.tsx @@ -18,7 +18,6 @@ function receiptText(r: Receipt): string { `Agent: ${r.agentName ?? "OKX.AI Agent"}`, `Verdict: ${r.verdict}`, `Reality Score:${r.realityScore ?? "—"}/100`, - `Confidence: ${r.confidence ?? r.realityScore ?? "—"}`, "", `Claim: ${r.claim}`, `Claimed: ${r.claimedAmount}`, diff --git a/apps/dashboard/src/lib/receipt.ts b/apps/dashboard/src/lib/receipt.ts index bac79cc..a303aa4 100644 --- a/apps/dashboard/src/lib/receipt.ts +++ b/apps/dashboard/src/lib/receipt.ts @@ -27,7 +27,6 @@ export interface Receipt { explorerUrl: string; evidenceSource?: string; verificationMethod?: string; - confidence?: number | null; gas?: string; confirmations?: string; proofLinks?: string[]; @@ -129,7 +128,6 @@ function baseReceipt(args: { explorerUrl: explorerFor(txHash, wallet), evidenceSource: r.evidence?.source ?? "X Layer RPC", verificationMethod: "On-chain verification", - confidence: scoreFor(verdict), gas: "—", confirmations: "—", proofLinks: [explorerFor(txHash, wallet), att?.id].filter(Boolean) as string[], @@ -180,8 +178,8 @@ export function receiptFromResult(r: VerificationResult): Receipt { return baseReceipt({ r, verdict, - claimText: `I sent ${claimed} ${symbol}.`, - claimedAmount: `${claimed} ${symbol}`, + claimText: r.receipt?.claim ?? `I sent ${claimed} ${symbol}.`, + claimedAmount: `≥ ${claimed} ${symbol}`, actualAmount: raw.receivedAmount !== undefined ? `${got} ${symbol}` : "no record", txHash, wallet, @@ -189,7 +187,7 @@ export function receiptFromResult(r: VerificationResult): Receipt { token: symbol, status: verdict === "VERIFIED" ? "success" : verdict === "MISMATCH" ? "short" : "—", block: String(rc.blockNumber ?? r.evidence?.blockNumber ?? "—"), - agentName: "Wallet Agent", + agentName: r.receipt?.agentName ?? "Wallet Agent", }); } @@ -254,38 +252,82 @@ export function receiptFromResult(r: VerificationResult): Receipt { const XPLR = XLAYER_EXPLORER; const USDT_ADDR = "0x1e4a5963abfd975d8c9021ce480b42188849d41d"; +// Real, permanent X Layer mainnet transactions the live demo verifies against: +// one honest USDT payment (120.01 arrived for a ≥100 claim) and one that came +// up short (42.11 arrived for a 100 claim). +export const USDT_XLAYER = USDT_ADDR; +export const GOOD_TX = "0x5c4f1da1d4057b43f47c5542e89b3330d7d2b91a7c416872ce7c113a2337b952"; +export const GOOD_TO = "0xbc3587d95f29b88001eaad6db3662078e78cd21d"; +export const SHORT_TX = "0xa6985182d801017af2b0924b76bc2ec8c8858eb927b6ab78bf0e314b6dd7b198"; +export const SHORT_TO = "0x5f8f80636e48f8210e8b9898f9f5145cdf198a9e"; + +/** + * The two agent envelopes the homepage sends to POST /verify on load, so the + * hero receipts are real: signed by the live attester, backed by real X Layer + * evidence, re-checkable by anyone. The cached receipts below exist only for + * when the API is unreachable — and say so. + */ +export const LIVE_DEMO_ENVELOPES = [ + { + agentName: "Wallet Agent", + claim: "I paid at least 100 USDT to the supplier wallet.", + metadata: { + claim: { + kind: "TOKEN_TRANSFER", + chain: "xlayer-mainnet", + txHash: GOOD_TX, + token: USDT_ADDR, + to: GOOD_TO, + minAmount: "100000000", + }, + }, + }, + { + agentName: "Wallet Agent", + claim: "I paid 100 USDT to the vendor wallet.", + metadata: { + claim: { + kind: "TOKEN_TRANSFER", + chain: "xlayer-mainnet", + txHash: SHORT_TX, + token: USDT_ADDR, + to: SHORT_TO, + minAmount: "100000000", + }, + }, + }, +] as const; + +// Offline fallbacks only — rendered when the API cannot be reached, and +// labeled as cached. They carry no signature, attestation id, gas, or +// confirmation figures, because those would be fabricated: RealityCheck +// never displays proof it doesn't have. export const DEMO_GOOD: Receipt = { - id: "rc_demo_good", + id: "rc_cached_good", verdict: "VERIFIED", realityScore: 100, agentName: "Wallet Agent", - claim: "I paid 120.011823 USDT to the supplier wallet.", - claimedAmount: "120.011823 USDT", + claim: "I paid at least 100 USDT to the supplier wallet.", + claimedAmount: "≥ 100 USDT", actualAmount: "120.011823 USDT", match: true, - txHash: "0x5c4f1da1d4057b43f47c5542e89b3330d7d2b91a7c416872ce7c113a2337b952", - wallet: "0xbc3587d95f29b88001eaad6db3662078e78cd21d", - recipient: "0xbc3587d95f29b88001eaad6db3662078e78cd21d", + txHash: GOOD_TX, + wallet: GOOD_TO, + recipient: GOOD_TO, amount: "120.011823", token: "USDT", status: "success", block: "64,921,939", timestamp: "2026-07-10T13:42:55.000Z", network: "X Layer mainnet", - explorerUrl: `${XPLR}/tx/0x5c4f1da1d4057b43f47c5542e89b3330d7d2b91a7c416872ce7c113a2337b952`, - evidenceSource: "xlayer-mainnet:Transfer logs", + explorerUrl: `${XPLR}/tx/${GOOD_TX}`, + evidenceSource: "xlayer-mainnet:Transfer logs (cached example)", verificationMethod: "On-chain ERC-20 event verification", - confidence: 100, - gas: "0.000061 OKB", - confirmations: "2,450+", - proofLinks: [`${XPLR}/tx/0x5c4f1da1d4057b43f47c5542e89b3330d7d2b91a7c416872ce7c113a2337b952`], - attestationId: "rc_verified_onchain", - payloadHash: USDT_ADDR, - signature: "0x... signed by the RealityCheck attester", + proofLinks: [`${XPLR}/tx/${GOOD_TX}`], }; export const DEMO_BAD: Receipt = { - id: "rc_demo_bad", + id: "rc_cached_bad", verdict: "MISMATCH", realityScore: 0, agentName: "Wallet Agent", @@ -293,23 +335,17 @@ export const DEMO_BAD: Receipt = { claimedAmount: "100 USDT", actualAmount: "42.105574 USDT", match: false, - txHash: "0xa6985182d801017af2b0924b76bc2ec8c8858eb927b6ab78bf0e314b6dd7b198", - wallet: "0x5f8f80636e48f8210e8b9898f9f5145cdf198a9e", - recipient: "0x5f8f80636e48f8210e8b9898f9f5145cdf198a9e", + txHash: SHORT_TX, + wallet: SHORT_TO, + recipient: SHORT_TO, amount: "42.105574", token: "USDT", status: "short", block: "64,921,911", timestamp: "2026-07-10T13:42:27.000Z", network: "X Layer mainnet", - explorerUrl: `${XPLR}/tx/0xa6985182d801017af2b0924b76bc2ec8c8858eb927b6ab78bf0e314b6dd7b198`, - evidenceSource: "xlayer-mainnet:Transfer logs", + explorerUrl: `${XPLR}/tx/${SHORT_TX}`, + evidenceSource: "xlayer-mainnet:Transfer logs (cached example)", verificationMethod: "On-chain ERC-20 event verification", - confidence: 0, - gas: "0.000061 OKB", - confirmations: "2,450+", - proofLinks: [`${XPLR}/tx/0xa6985182d801017af2b0924b76bc2ec8c8858eb927b6ab78bf0e314b6dd7b198`], - attestationId: "rc_mismatch_onchain", - payloadHash: USDT_ADDR, - signature: "0x... signed by the RealityCheck attester", + proofLinks: [`${XPLR}/tx/${SHORT_TX}`], }; diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index 795019b..82abb0e 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -40,13 +40,13 @@ export interface AttestationCheck { reason: string; } -export interface TrustSignals { +export interface TrackRecord { subject: string; totalChecks: number; verified: number; refuted: number; indeterminate: number; - trustScore: number; + realityScore: number; lastVerdict: Verdict | null; lastCheckedIso: string | null; } From a5e775f4717a1b0aec6df50b82e2c79a4423d5a5 Mon Sep 17 00:00:00 2001 From: Agozie180 Date: Thu, 23 Jul 2026 23:44:36 +0100 Subject: [PATCH 3/3] Rewrite README as the pitch; add CI running pnpm verify The README now leads with what a judge needs in ten seconds: the one-line positioning, the verdict table, a copy-paste-runnable MISMATCH demo against a real X Layer transaction, the consistency-gate story, the autonomous pipeline, and the full API surface. DEPLOY.md documents the production durability guard. CI runs pnpm verify (lint + typecheck + test) on every push and PR so a broken tree can never reach the judged branch again. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/verify.yml | 35 ++++++++++ DEPLOY.md | 10 ++- README.md | 126 ++++++++++++++++++++++++++++------- 3 files changed, 144 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/verify.yml diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..0cce9d0 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,35 @@ +name: verify + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable corepack (pnpm) + run: corepack enable + + - uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + # lint + typecheck + test — mock RPC only, no network needed + - name: Verify + run: pnpm verify diff --git a/DEPLOY.md b/DEPLOY.md index d41cb7e..f911824 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -17,13 +17,19 @@ dashboard runs `next start -H 0.0.0.0 -p $PORT`. ### api | var | value | notes | | --- | --- | --- | +| `NODE_ENV` | `production` | enables the durability boot guard | | `REALITY_CHAIN` | `xlayer-mainnet` | default verification chain | -| `LEDGER_URL` | `libsql://.turso.io` | Turso database URL (or `:memory:`) | +| `LEDGER_URL` | `libsql://.turso.io` | **required in production** — Turso URL or `file:...`; `:memory:` is refused | | `LEDGER_AUTH_TOKEN` | *(secret)* | Turso token; required for a hosted db | -| `REALITY_ATTESTER_SEED` | *(secret)* | 32-byte hex — stable signing identity | +| `REALITY_ATTESTER_SEED` | *(secret)* | **required in production** — 32-byte hex; a stable signing identity keeps old receipts verifiable across restarts | | `REALITY_ANCHOR_ENABLED` | `false` | set `true` only with a funded key | | `REALITY_ANCHOR_PRIVATE_KEY` | *(secret)* | funded OKB key; only if anchoring | +> In production the API refuses to boot without `REALITY_ATTESTER_SEED` and a +> durable `LEDGER_URL`. An ephemeral key would invalidate every previously +> issued receipt on restart; an in-memory ledger would silently discard the +> audit trail. + ### dashboard | var | value | notes | | --- | --- | --- | diff --git a/README.md b/README.md index 817c7c8..5e8b46a 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,113 @@ # RealityCheck -> Intelligence reasons. Reality decides. +> **RealityCheck is the verification layer for AI agents.** +> AI claims. Reality decides. -RealityCheck verifies claims against reality and returns **independently verified evidence**, not opinion. The first verification domain is **on-chain state on [X Layer](https://web3.okx.com/xlayer)** (OKX's EVM zkEVM L2). The chain layer sits behind a `ChainVerifier` adapter, so supporting other chains later is a new adapter — not a rewrite. +An OKX.AI agent says *"I paid 100 USDT to the vendor wallet."* +Did it? -This repo is the first vertical slice of **Project Genesis — the Reality Layer for Autonomous Intelligence**. +RealityCheck answers that question with evidence, not opinion. Every claim an agent makes is checked against objective reality — on-chain state on **[X Layer](https://web3.okx.com/xlayer)** (OKX's zkEVM L2) — and answered with a signed **Reality Receipt**: -## What it does today +| Receipt result | Meaning | +| --- | --- | +| **VERIFIED** | Objective evidence confirms the agent's claim | +| **MISMATCH** | Objective evidence contradicts the claim — reality shows something else | +| **UNVERIFIABLE** | No objective evidence available — RealityCheck refuses to guess | + +```text +AI Claim ──▶ Reality Engine ──▶ Evidence ──▶ Reality Receipt ──▶ VERIFIED / MISMATCH / UNVERIFIABLE +``` + +## The demo in one request + +The agent claims 100 USDT. On-chain, only 42.11 arrived. This is a real, permanent X Layer mainnet transaction — run it yourself: + +```bash +curl -X POST http://localhost:4000/verify \ + -H 'content-type: application/json' \ + -d '{ + "agentName": "Wallet Agent", + "claim": "I paid 100 USDT to the vendor wallet.", + "metadata": { + "claim": { + "kind": "TOKEN_TRANSFER", + "chain": "xlayer-mainnet", + "txHash": "0xa6985182d801017af2b0924b76bc2ec8c8858eb927b6ab78bf0e314b6dd7b198", + "token": "0x1e4a5963abfd975d8c9021ce480b42188849d41d", + "to": "0x5f8f80636e48f8210e8b9898f9f5145cdf198a9e", + "minAmount": "100000000" + } + } + }' +``` + +The response is a **MISMATCH** receipt with the raw `Transfer` logs as evidence, an Ed25519-signed attestation, and an explorer link anyone can click. + +## The words are what gets verified + +Verifying only structured data leaves a hole: an agent could *say* "100 USDT" while attaching a structured claim for a tiny amount, pass the weak check, and walk away with a signed receipt for the strong sentence. + +RealityCheck closes that hole with a **claim-consistency gate**: the spoken amount in the natural-language claim is extracted and compared against what actually moved on-chain. If the agent's words claim more than reality shows, the verdict is **REFUTED — no matter what the structured claim said.** A receipt certifies the agent's words, so the words are what gets verified. -Submit a claim; RealityCheck checks it against public X Layer RPC (read-only, no keys) and returns a `verdict` (`VERIFIED` / `REFUTED` / `INDETERMINATE`) plus the raw on-chain **evidence** that backs it. +## Autonomous verification -Supported claim kinds (MVP): +Agents don't ask for verification — they act, and RealityCheck watches: -| kind | meaning | +```text +POST /v1/events agent acted → 202 immediately + └─ pipeline runs on its own: collect evidence → verify → sign receipt → record in ledger + └─ receipt POSTed to your callbackUrl, signed with the attester key +``` + +The callback delivery is signed over the exact bytes sent (`x-realitycheck-signature`), verifiable against the public key from `GET /v1/health` — a delivery cannot be forged or tampered with in flight. + +## API + +| Route | What it does | +| --- | --- | +| `POST /v1/events` | Agent acted — verification runs autonomously, receipt delivered to your webhook | +| `GET /v1/events/:id` | Pipeline state + signed receipt | +| `POST /verify` | Marketplace envelope (or raw claim) in, Reality Receipt out | +| `POST /v1/verify` | Raw objective claim, no envelope | +| `GET /v1/attestations` | Recent signed receipts (`?subject=` to filter) | +| `POST /v1/attestations/verify` | Independently re-check any receipt's hash + signature | +| `GET /v1/track-record/:subject` | Verified track record for a tx hash or wallet | +| `GET /v1/stats` | Ledger-wide verification metrics | +| `GET /v1/health` | Service status + attester public key | + +Supported objective claim kinds: + +| kind | settles the question | | --- | --- | -| `TX_SUCCESS` | transaction `txHash` executed successfully | -| `TX_TRANSFER` | `txHash` sent at least `minValueWei` **native OKB** to address `to` | -| `TOKEN_TRANSFER` | `txHash` moved at least `minAmount` of ERC-20 `token` to `to` (read from the `Transfer` event log — this is how USDT/USDC and most real value move on X Layer) | -| `BALANCE_AT_LEAST` | `address` holds at least `minWei` (native OKB) | +| `TOKEN_TRANSFER` | did at least `minAmount` of ERC-20 `token` reach `to` in `txHash`? (read from `Transfer` logs — how USDT/USDC actually move) | +| `TX_TRANSFER` | did `txHash` send at least `minValueWei` native OKB to `to`? | +| `TX_SUCCESS` | did `txHash` execute successfully? | +| `BALANCE_AT_LEAST` | does `address` hold at least `minWei`? | + +## Proof, not promises + +- **Ed25519-signed attestations** — every verification is canonicalized, hashed, and signed. `POST /v1/attestations/verify` re-checks any receipt with nothing but the receipt itself. +- **Fact-addressed ledger** — the attestation id hashes the *fact* (claim + verdict + evidence), not the observation time, so re-running the same check can never inflate a track record. +- **Optional on-chain anchoring** — with `REALITY_ANCHOR_ENABLED=true` and a funded key, each attestation hash is written into X Layer calldata, making its existence-in-time independently checkable. +- **Honest by construction** — RPC unreachable or evidence ambiguous → `UNVERIFIABLE`. RealityCheck never guesses and never fabricates evidence. -## Layout +## Architecture ``` apps/ - api/ Fastify — POST /v1/verify, GET /v1/health - dashboard/ Next.js — submit a claim, see the verdict + evidence + api/ Fastify — verify routes, autonomous event pipeline, provenance + dashboard/ Next.js — live homepage demo, console, dashboard, docs packages/ - domain/ Claim, Verdict, Evidence, VerificationResult types - config/ env-driven config loader - chains/ ChainVerifier interface + XLayerVerifier (viem) + registry - core/ RealityCheck engine (routes claim -> verifier) - testkit/ mock RPC transport for tests + domain/ Claim, Verdict, Evidence, RealityReceipt types + config/ env-driven config loader + chains/ ChainVerifier interface + XLayerVerifier (viem) + registry + core/ RealityCheck engine (routes claim → verifier) + reality-core/ attestations, Ed25519 signing, libSQL ledger, anchoring, track record + testkit/ mock RPC transport for tests ``` +The chain layer sits behind a `ChainVerifier` adapter — supporting another chain is a new adapter, not a rewrite. + ## Quick start ```bash @@ -40,15 +115,16 @@ corepack pnpm install corepack pnpm build # once — TS project references corepack pnpm verify # lint + typecheck + test (mock RPC, no network) -# run the API and hit it against real X Layer +# run the API against real X Layer corepack pnpm --filter @realitycheck/api dev -curl -X POST localhost:4000/v1/verify \ - -H 'content-type: application/json' \ - -d '{"kind":"TX_SUCCESS","chain":"xlayer-mainnet","txHash":"0x"}' +# run the dashboard (talks to the API at NEXT_PUBLIC_API_URL, default localhost:4000) +corepack pnpm --filter @realitycheck/dashboard dev ``` -Copy `.env.example` to `.env` to override RPC URLs / chain selection. +Copy `.env.example` to `.env` to override RPC URLs, ledger, attester seed, or anchoring. See [DEPLOY.md](DEPLOY.md) for the two-service Railway deployment. + +**Production note:** the API refuses to boot in production without `REALITY_ATTESTER_SEED` (stable signing identity) and a durable `LEDGER_URL` — silent fallbacks would break the product's own guarantees. ## Philosophy -Every decision is tested against one question: *does it increase the system's connection to reality?* When RPC is unreachable or the data is ambiguous, RealityCheck returns `INDETERMINATE` — it never guesses. Humility before certainty. +Every decision is tested against one question: *does it increase the system's connection to reality?* When RealityCheck cannot consult reality, it says so. Humility before certainty.