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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ verify: {
```

A signature proves who answered, not that the answer is right, so it changes no detector weight.
Setup, x402 payment and the trust model: [docs/airnode.md](docs/airnode.md).
Setup, x402 payment and the trust model: [docs/airnode.md](docs/airnode.md). To see it catch a
provider serving wrong data, run [examples/airnode-demo](examples/airnode-demo).

## Run it

Expand Down
59 changes: 59 additions & 0 deletions examples/airnode-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Airnode demo: catching wrong data with signed proof

## The problem

Ada runs a DeFi app on Base. One evening users report deposits that are not showing, while
others see the right balance. Her logs show nothing: every RPC call returned HTTP 200, and her
monitoring is green.

One of her provider's nodes is serving stale data. It answers successfully, just with the wrong
block, and nothing that watches status codes and latency can see that. Without a second opinion,
she cannot tell whether the fault is her code, the chain or the provider, and the provider can
always say it is on her side.

## What this demo does

1. Starts **your-provider**, a local stand-in for that node. It proxies Base mainnet, but answers
every block request with the block from 40 blocks earlier, always with HTTP 200.
2. Makes a real, paid call through [AirnodeHub](https://airnodehub.api3.org)'s dRPC listing. Its
answer is signed by the Airnode, and Heightproof checks that the signature is valid and bound
to the exact request it sent.
3. Asks both the same question at the same pinned height and runs detector D2 on the result.

```
Asking both providers for Base block 53366354...

your-provider HTTP 200 269ms result 144aef43c1f5fbd971…
airnode-drpc HTTP 200 2233ms result 54bd736f3a74339318…
signed by 0x511AcE8648D2f64260d50D036F8f8ce622d92137
signature valid: true, bound to this request: true

🔴 D2 provider_disagreement (weight 40)
eth_getBlockByNumber at block 53366354 returned a different result than airnode-drpc; at equal
height this is a provider bug or an unpropagated reorg. Airnode-signed evidence from
0x511AcE8648D2f64260d50D036F8f8ce622d92137 can be verified independently
```

The broken provider is simulated; the AirnodeHub call, its payment and its signature are real.
Ada now knows the provider is at fault, and has evidence the provider cannot dispute.

## Run it

You need a Base Sepolia key holding aUSD, AirnodeHub's test token. Each call costs $0.01.

1. Get a little Base Sepolia ETH for gas from the
[Coinbase faucet](https://portal.cdp.coinbase.com/products/faucet).
2. Mint aUSD at [airnodehub.api3.org/faucet](https://airnodehub.api3.org/faucet), or call
`mintFromFaucet(uint256)` on `0xbeA34973e5f67F18F656A2C6070C64326C791808` yourself.
3. From the repository root:

```bash
npm install
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`.

How signed verification works, and what a signature does and does not prove:
[docs/airnode.md](../../docs/airnode.md).
144 changes: 144 additions & 0 deletions examples/airnode-demo/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* A provider serves wrong data with HTTP 200, and Heightproof catches it with signed proof.
*
* "your-provider" is a local stand-in for a misbehaving RPC: it proxies Base mainnet but
* answers block requests with a cached block from 40 blocks earlier. The other side is a real,
* paid call through AirnodeHub's dRPC listing, whose answer is signed by its Airnode. Both are
* asked the same question at the same pinned height, and detector D2 judges the result.
*
* 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";

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 verifier = new RpcVerifier({
sampleRate: 1,
timeoutMs: 30_000,
endpoints: [
{ rpcUrl: staleUrl, provider: "your-provider" },
airnodeDrpcEndpoint({ network: "base", fetchImpl: wrapFetchWithPayment(fetch, payments) })
]
});

const method = "eth_getBlockByNumber";
const blockNumber = await verifier.resolvePinHeight(undefined, UPSTREAM);
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();
if (!run) throw new Error("verification was not run");

const [primary, peer] = run.observations;
for (const observation of run.observations) {
const answer = observation.resultHash
? `${observation.resultHash.slice(0, 18)}…`
: observation.rpcErrorMessage;
console.log(
` ${observation.provider?.padEnd(14)} HTTP 200 ${observation.latencyMs}ms result ${answer}`
);
const attestation = observation.attestation;
if (attestation) {
console.log(` ${"".padEnd(14)} signed by ${attestation.signer}`);
console.log(
` ${"".padEnd(14)} signature valid: ${attestation.signatureValid}, bound to this request: ${attestation.requestBound}`
);
}
}

if (!primary?.resultHash || !peer?.resultHash) {
console.log("\nComparison incomplete: one side gave no answer.");
process.exit(1);
}

// The event Heightproof would have recorded for your provider's answer.
const event: HeightproofEvent = {
id: randomUUID(),
traceId: randomUUID().replaceAll("-", ""),
projectId: "demo",
serviceName: "airnode-demo",
environment: "demo",
timestamp: new Date().toISOString(),
kind: "evm_rpc",
request: {
method: "POST",
path: "/rpc",
route: "/rpc",
headers: {},
query: {},
auth: { present: true, scheme: "RPC", failed: false }
},
response: { statusCode: 200, latencyMs: primary.latencyMs },
evmRpc: {
method,
chainId: "8453",
provider: primary.provider,
blockNumber: run.blockNumber,
resultHash: primary.resultHash,
resultShape: primary.resultShape,
endpointHash: primary.endpointHash
}
};

const attested = peer.attestation?.signatureValid && peer.attestation.requestBound;
const signals = detectDisagreement(event, {
peerResults: [
{
provider: peer.provider,
endpointHash: peer.endpointHash,
resultHash: peer.resultHash,
attestedBy: attested ? peer.attestation?.signer : undefined
}
]
});

if (signals.length === 0) {
console.log(`\nNo incident: both providers agree at block ${run.blockNumber}.`);
} else {
for (const signal of signals)
console.log(`\n🔴 D2 ${signal.name} (weight ${signal.weight})\n ${signal.reason}`);
}
17 changes: 17 additions & 0 deletions examples/airnode-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@heightproof/example-airnode-demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"demo": "tsx demo.ts"
},
"dependencies": {
"@heightproof/shared": "0.1.1",
"@heightproof/web3": "0.1.1",
"@x402/evm": "2.29.0",
"@x402/fetch": "2.29.0",
"tsx": "^4.19.0",
"viem": "2.57.14"
}
}
Loading
Loading