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}` }; +}