Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<db>.turso.io` | Turso database URL (or `:memory:`) |
| `LEDGER_URL` | `libsql://<db>.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 |
| --- | --- | --- |
Expand Down
126 changes: 101 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,130 @@
# 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
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<real-xlayer-tx>"}'
# 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.
50 changes: 50 additions & 0 deletions apps/api/src/features/events/callback-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
125 changes: 125 additions & 0 deletions apps/api/src/features/events/callback-guard.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading