From d682754da244e366fc88ee01af03d939720c9ceb Mon Sep 17 00:00:00 2001 From: macjayz Date: Sun, 1 Nov 2026 10:15:27 +0100 Subject: [PATCH] examples: run the Airnode demo through a live stack and dashboard dashboard.ts tells the same story end to end: the app's ordinary call through @heightproof/web3 looks fine, background verification against AirnodeHub disagrees, the worker re-checks the signature and raises D2, and the incident shows in the dashboard with the verified signer. The stale provider and the x402 payment setup move to setup.ts, shared by both scripts. Co-Authored-By: Claude Opus 5.5 --- examples/airnode-demo/README.md | 18 +++++ examples/airnode-demo/dashboard.ts | 104 +++++++++++++++++++++++++++++ examples/airnode-demo/demo.ts | 50 ++------------ examples/airnode-demo/package.json | 3 +- examples/airnode-demo/setup.ts | 55 +++++++++++++++ 5 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 examples/airnode-demo/dashboard.ts create mode 100644 examples/airnode-demo/setup.ts diff --git a/examples/airnode-demo/README.md b/examples/airnode-demo/README.md index 188b444..a9bfdcb 100644 --- a/examples/airnode-demo/README.md +++ b/examples/airnode-demo/README.md @@ -55,5 +55,23 @@ AIRNODE_PAYER_KEY=0x... npm run demo -w examples/airnode-demo Use a throwaway key: it only ever needs testnet funds. `BASE_RPC_URL` overrides the upstream the fake provider proxies, which defaults to `https://mainnet.base.org`. +## With the dashboard + +`demo.ts` runs the comparison in-process. `dashboard.ts` runs the same story through a real +Heightproof stack, the way Ada would: her app makes an ordinary call through +`@heightproof/web3`, gets an answer that looks fine, and Heightproof re-checks it in the +background. The worker re-verifies the Airnode signature, raises the D2 incident, and the +dashboard shows it, with the Airnode signer on the AirnodeHub observation in the Request +Explorer. + +```bash +docker compose up --build -d +AIRNODE_PAYER_KEY=0x... npm run dashboard -w examples/airnode-demo +``` + +Each run signs up a fresh workspace and prints its sign-in for the dashboard at +`http://localhost:5173`. `HEIGHTPROOF_ENDPOINT` and `HEIGHTPROOF_DASHBOARD_URL` point it at a +stack on other ports. + How signed verification works, and what a signature does and does not prove: [docs/airnode.md](../../docs/airnode.md). diff --git a/examples/airnode-demo/dashboard.ts b/examples/airnode-demo/dashboard.ts new file mode 100644 index 0000000..52b1061 --- /dev/null +++ b/examples/airnode-demo/dashboard.ts @@ -0,0 +1,104 @@ +/** + * The same story as demo.ts, end to end through a running Heightproof stack. + * + * Your app makes an ordinary call through @heightproof/web3 to "your-provider", which answers + * with HTTP 200 and looks fine. In the background Heightproof re-asks that provider and + * AirnodeHub the same question at a pinned height, the worker re-checks the Airnode signature + * and raises D2, and the incident appears in the dashboard. + * + * Needs a running stack (`docker compose up`) and a wallet holding aUSD on Base Sepolia. + * Usage: + * AIRNODE_PAYER_KEY=0x... npm run dashboard -w examples/airnode-demo + */ +import { airnodeDrpcEndpoint, createHeightproofRpcClient } from "@heightproof/web3"; +import { payingFetch, startStaleProvider } from "./setup.js"; + +const api = process.env.HEIGHTPROOF_ENDPOINT ?? "http://localhost:8080"; +const dashboard = process.env.HEIGHTPROOF_DASHBOARD_URL ?? "http://localhost:5173"; +const INCIDENT_TIMEOUT_MS = 90_000; + +type Incident = { id: string; title: string; description?: string; severity: string }; + +async function json(path: string, init: RequestInit & { token?: string } = {}): Promise { + const response = await fetch(new URL(path, api), { + ...init, + headers: { + "content-type": "application/json", + ...(init.token ? { authorization: `Bearer ${init.token}` } : {}) + } + }); + if (!response.ok) throw new Error(`${path} -> ${response.status} ${await response.text()}`); + return (await response.json()) as T; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// A fresh workspace per run, so the only incident in it is this run's. +const suffix = Date.now().toString(36); +const login = { email: `ada-${suffix}@heightproof.local`, password: `demo-${suffix}-password` }; +const signup = await json<{ + token: string; + apiKey: { key?: string; secret?: string } | string; + projects: Array<{ id: string }>; +}>("/v1/auth/signup", { + method: "POST", + body: JSON.stringify({ ...login, organizationName: "Ada's DeFi app", projectName: "ada-defi" }) +}); +const projectId = signup.projects[0].id; +const apiKey = + typeof signup.apiKey === "string" + ? signup.apiKey + : (signup.apiKey.key ?? signup.apiKey.secret ?? ""); + +const fetchImpl = payingFetch(); +const stale = await startStaleProvider(); + +const rpc = createHeightproofRpcClient({ + projectId, + apiKey, + endpoint: api, + rpcUrl: stale.url, + provider: "your-provider", + serviceName: "ada-defi", + chainId: 8453, + verify: { + endpoints: [airnodeDrpcEndpoint({ network: "base", fetchImpl })], + // Every call, for the demo. In production a small fraction is enough. + sampleRate: 1, + timeoutMs: 30_000 + } +}); + +const block = (await rpc.request({ + method: "eth_getBlockByNumber", + params: ["latest", false] +})) as { + number: string; +}; +console.log( + `\nYour app: eth_getBlockByNumber -> block ${Number.parseInt(block.number, 16)}, HTTP 200. Looks fine.` +); +console.log( + "Heightproof: re-asking your-provider and AirnodeHub at that height, in the background..." +); + +const deadline = Date.now() + INCIDENT_TIMEOUT_MS; +let incident: Incident | undefined; +while (!incident && Date.now() < deadline) { + await sleep(2_000); + const incidents = await json(`/v1/analytics/incidents?projectId=${projectId}`, { + token: signup.token + }); + incident = incidents[0]; +} +stale.server.close(); + +if (!incident) { + console.log("\nNo incident raised within 90s. Is the worker running?"); + process.exit(1); +} + +console.log(`\n🔴 Incident (${incident.severity}): ${incident.title}`); +if (incident.description) console.log(` ${incident.description}`); +console.log(`\nOpen ${dashboard} and sign in as ${login.email} / ${login.password}`); +process.exit(0); diff --git a/examples/airnode-demo/demo.ts b/examples/airnode-demo/demo.ts index 71c5e2f..9e2afdc 100644 --- a/examples/airnode-demo/demo.ts +++ b/examples/airnode-demo/demo.ts @@ -9,58 +9,20 @@ * Needs a wallet holding aUSD on Base Sepolia (see README). Usage: * AIRNODE_PAYER_KEY=0x... npm run demo -w examples/airnode-demo */ -import { createServer } from "node:http"; -import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -import { privateKeyToAccount } from "viem/accounts"; -import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; -import { registerExactEvmScheme } from "@x402/evm/exact/client"; import { RpcVerifier, airnodeDrpcEndpoint } from "@heightproof/web3"; import { detectDisagreement, type HeightproofEvent } from "@heightproof/shared"; +import { UPSTREAM, payingFetch, startStaleProvider } from "./setup.js"; -const UPSTREAM = process.env.BASE_RPC_URL ?? "https://mainnet.base.org"; -const STALE_BY = 40; -const AUSD = "0xbeA34973e5f67F18F656A2C6070C64326C791808"; - -const payerKey = process.env.AIRNODE_PAYER_KEY as `0x${string}` | undefined; -if (!payerKey) { - console.error("Set AIRNODE_PAYER_KEY to a Base Sepolia key holding aUSD. See README.md."); - process.exit(1); -} - -// The misbehaving provider. Everything is a genuine mainnet answer, just not for the block asked. -const stale = createServer((request, response) => { - let raw = ""; - request.on("data", (chunk) => (raw += chunk)); - request.on("end", async () => { - const call = JSON.parse(raw) as { method: string; params: unknown[] }; - if (call.method === "eth_getBlockByNumber" && typeof call.params[0] === "string") { - call.params[0] = `0x${(Number.parseInt(call.params[0], 16) - STALE_BY).toString(16)}`; - } - const upstream = await fetch(UPSTREAM, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, ...call }) - }); - response.writeHead(200, { "content-type": "application/json" }); - response.end(await upstream.text()); - }); -}); -await new Promise((resolve) => stale.listen(0, "127.0.0.1", resolve)); -const staleUrl = `http://127.0.0.1:${(stale.address() as AddressInfo).port}`; - -// Pays AirnodeHub's $0.01 per call in aUSD, capped at 1 aUSD per payment. -const payments = new x402Client().setSpendControls({ - allowedAssets: [{ network: "eip155:84532", asset: AUSD, maxAmountPerPayment: "1000000" }] -}); -registerExactEvmScheme(payments, { signer: privateKeyToAccount(payerKey) }); +const fetchImpl = payingFetch(); +const stale = await startStaleProvider(); const verifier = new RpcVerifier({ sampleRate: 1, timeoutMs: 30_000, endpoints: [ - { rpcUrl: staleUrl, provider: "your-provider" }, - airnodeDrpcEndpoint({ network: "base", fetchImpl: wrapFetchWithPayment(fetch, payments) }) + { rpcUrl: stale.url, provider: "your-provider" }, + airnodeDrpcEndpoint({ network: "base", fetchImpl }) ] }); @@ -70,7 +32,7 @@ if (!blockNumber) throw new Error(`could not read the chain head from ${UPSTREAM console.log(`\nAsking both providers for Base block ${blockNumber}...\n`); const run = await verifier.verify(method, ["latest", false], blockNumber); -stale.close(); +stale.server.close(); if (!run) throw new Error("verification was not run"); const [primary, peer] = run.observations; diff --git a/examples/airnode-demo/package.json b/examples/airnode-demo/package.json index 8e109e1..6eb1796 100644 --- a/examples/airnode-demo/package.json +++ b/examples/airnode-demo/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "demo": "tsx demo.ts" + "demo": "tsx demo.ts", + "dashboard": "tsx dashboard.ts" }, "dependencies": { "@heightproof/shared": "0.1.1", diff --git a/examples/airnode-demo/setup.ts b/examples/airnode-demo/setup.ts new file mode 100644 index 0000000..a3f4fac --- /dev/null +++ b/examples/airnode-demo/setup.ts @@ -0,0 +1,55 @@ +import { createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import { privateKeyToAccount } from "viem/accounts"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; + +export const UPSTREAM = process.env.BASE_RPC_URL ?? "https://mainnet.base.org"; +const STALE_BY = 40; +const AUSD = "0xbeA34973e5f67F18F656A2C6070C64326C791808"; + +/** Fetch that pays AirnodeHub's $0.01 per call in aUSD, capped at 1 aUSD per payment. */ +export function payingFetch(): typeof fetch { + const payerKey = process.env.AIRNODE_PAYER_KEY as `0x${string}` | undefined; + if (!payerKey) { + console.error("Set AIRNODE_PAYER_KEY to a Base Sepolia key holding aUSD. See README.md."); + process.exit(1); + } + + const payments = new x402Client().setSpendControls({ + allowedAssets: [{ network: "eip155:84532", asset: AUSD, maxAmountPerPayment: "1000000" }] + }); + registerExactEvmScheme(payments, { signer: privateKeyToAccount(payerKey) }); + return wrapFetchWithPayment(fetch, payments); +} + +/** + * The misbehaving provider: proxies Base mainnet, but answers every block request with the + * block from 40 blocks earlier. Everything it returns is a genuine mainnet answer, always with + * HTTP 200, just not for the block asked. + */ +export async function startStaleProvider() { + const server = createServer((request, response) => { + let raw = ""; + request.on("data", (chunk) => (raw += chunk)); + request.on("end", async () => { + const call = JSON.parse(raw) as { id?: unknown; method: string; params: unknown[] }; + if (call.method === "eth_getBlockByNumber" && call.params[0] !== "latest") { + const asked = call.params[0]; + if (typeof asked === "string") { + call.params[0] = `0x${(Number.parseInt(asked, 16) - STALE_BY).toString(16)}`; + } + } + const upstream = await fetch(UPSTREAM, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, ...call }) + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(await upstream.text()); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { server, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }; +}