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
18 changes: 18 additions & 0 deletions examples/airnode-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
104 changes: 104 additions & 0 deletions examples/airnode-demo/dashboard.ts
Original file line number Diff line number Diff line change
@@ -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<T>(path: string, init: RequestInit & { token?: string } = {}): Promise<T> {
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<Incident[]>(`/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);
50 changes: 6 additions & 44 deletions examples/airnode-demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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 })
]
});

Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion examples/airnode-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions examples/airnode-demo/setup.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
}
Loading