From ea3db2ef7a0e69f90018661b8eff1de687bbf316 Mon Sep 17 00:00:00 2001 From: macjayz Date: Sat, 31 Oct 2026 21:10:44 +0100 Subject: [PATCH] feat: signed cross-provider verification with AirnodeHub Adds AirnodeHub's dRPC listing as an attesting peer in D2 verification. Every answer it gives carries an EIP-191 signature, which Heightproof checks against the exact request sent (request hash) and the exact bytes received. - shared: attestation reading, passthrough request-hash derivation, signer recovery, and worker-side re-verification (noble, no viem dep) - web3: airnodeDrpcEndpoint(), per-endpoint fetchImpl for x402 payment, responses read as text so signatures survive; non-JSON-RPC HTTP errors (an unpaid 402) become failed observations - worker: re-checks every signature against the kept body and the compared result hash before storing; D2 reasons cite verified signers - migration 003: evm_attestation (jsonb), evm_attested_by - dashboard: verified signer in the request explorer - docs/airnode.md, README, detectors, CHANGELOG A signature is attribution, not truth: it changes no detector weight. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 18 ++ apps/api/src/db.ts | 2 +- .../src/components/RequestExplorer.tsx | 1 + apps/dashboard/src/types.ts | 2 + apps/worker/src/db.ts | 24 ++- apps/worker/src/stream.test.ts | 45 ++++ apps/worker/src/stream.ts | 22 +- apps/worker/src/worker.integration.test.ts | 88 +++++++- docs/airnode.md | 85 ++++++++ docs/detectors.md | 5 + infra/migrations/003_airnode_attestation.sql | 10 + package-lock.json | 50 +++++ packages/shared/package.json | 2 + packages/shared/src/airnode.test.ts | 150 ++++++++++++++ packages/shared/src/airnode.ts | 194 ++++++++++++++++++ packages/shared/src/detectors.test.ts | 27 +++ packages/shared/src/detectors.ts | 22 +- packages/shared/src/index.ts | 31 ++- packages/web3/package.json | 4 + packages/web3/src/index.ts | 12 +- packages/web3/src/verify.test.ts | 117 ++++++++++- packages/web3/src/verify.ts | 107 +++++++++- 23 files changed, 997 insertions(+), 23 deletions(-) create mode 100644 docs/airnode.md create mode 100644 infra/migrations/003_airnode_attestation.sql create mode 100644 packages/shared/src/airnode.test.ts create mode 100644 packages/shared/src/airnode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bbfd0..2eec785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Heightproof will be documented in this file. ## Unreleased +- Added signed verification with AirnodeHub. `airnodeDrpcEndpoint()` adds AirnodeHub's dRPC listing to the D2 fan-out. Its EIP-191 attestations are checked against the exact request sent, re-checked by the worker against the kept body before anything is stored, and cited in D2 incident reasons. Each verification endpoint can take its own `fetchImpl`, for example one that pays x402 charges. Migration `003_airnode_attestation.sql` adds `evm_attestation` and `evm_attested_by`. See `docs/airnode.md`. +- Verification endpoints that refuse with a non-JSON-RPC HTTP error, such as an unpaid `402`, are recorded as failed observations instead of being parsed as answers. - Added a Helm chart (`deploy/helm/heightproof`) for the API, worker and dashboard, with optional bundled Postgres and Redis StatefulSets, generated-and-persisted secrets, advisory-locked migration init containers, optional ingress/HPA/PDB, hardened pod security defaults, and a `helm test` smoke test. - Added a production dashboard image (`infra/dashboard.prod.Dockerfile`): a static Vite build served by unprivileged nginx that proxies the API and `/live` WebSocket on the same origin. An empty `VITE_HEIGHTPROOF_API_URL` now means "same origin". - Workers deployed on Kubernetes use the pod name as their Redis consumer name, so replicas no longer collide on `worker-1`. diff --git a/README.md b/README.md index e80e3c7..c9a60a8 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,24 @@ const client = createPublicClient({ Your RPC URL never leaves your process. Heightproof records a hash of the endpoint, never the URL itself, because provider URLs embed API keys. +### Signed verification + +Add an [AirnodeHub](https://airnodehub.api3.org) endpoint to `verify` and every answer it gives +is signed. When providers disagree, the incident names the signer and keeps the signed response, +so anyone can re-verify the finding offline. The worker re-checks every signature itself instead +of trusting the SDK. + +```ts +import { airnodeDrpcEndpoint } from "@heightproof/web3"; + +verify: { + endpoints: [airnodeDrpcEndpoint({ network: "base", fetchImpl: payingFetch })] +} +``` + +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). + ## Run it ```bash diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index 7162fd9..4555df7 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -538,7 +538,7 @@ export async function getRequests(pool: pg.Pool, filters: RequestFilters = {}) { select id, trace_id, parent_span_id, timestamp, service_name, environment, kind, method, path, route, ip, user_agent, status_code, latency_ms, body_bytes, auth_present, auth_failed, graphql_operation_name, graphql_operation_type, evm_rpc_method, - evm_chain_id, evm_provider, wallet_address, contract_address, + evm_chain_id, evm_provider, evm_attested_by, wallet_address, contract_address, threat_score, threat_severity from api_events ${where} diff --git a/apps/dashboard/src/components/RequestExplorer.tsx b/apps/dashboard/src/components/RequestExplorer.tsx index 9ddf095..db957b0 100644 --- a/apps/dashboard/src/components/RequestExplorer.tsx +++ b/apps/dashboard/src/components/RequestExplorer.tsx @@ -148,6 +148,7 @@ export function RequestDetail(props: { request: RequestRecord; onClose: () => vo ["EVM RPC Method", r.evm_rpc_method], ["EVM Chain ID", r.evm_chain_id], ["EVM Provider", r.evm_provider], + ["Airnode Signer (verified)", r.evm_attested_by], ["Wallet Address", r.wallet_address], ["Contract Address", r.contract_address], ["Threat Score", r.threat_score], diff --git a/apps/dashboard/src/types.ts b/apps/dashboard/src/types.ts index df7084f..a1d6e7c 100644 --- a/apps/dashboard/src/types.ts +++ b/apps/dashboard/src/types.ts @@ -90,6 +90,8 @@ export type RequestRecord = { evm_rpc_method?: string; evm_chain_id?: string; evm_provider?: string; + /** Signer of a re-verified Airnode attestation over this answer. */ + evm_attested_by?: string; wallet_address?: string; contract_address?: string; threat_score: number; diff --git a/apps/worker/src/db.ts b/apps/worker/src/db.ts index 86c0e61..c05827b 100644 --- a/apps/worker/src/db.ts +++ b/apps/worker/src/db.ts @@ -343,7 +343,7 @@ export async function getDisagreementContext( const result = await pool.query( ` - select evm_endpoint_hash, evm_provider, evm_result_hash, evm_result_shape + select evm_endpoint_hash, evm_provider, evm_result_hash, evm_result_shape, evm_attested_by from api_events where project_id = $1 and evm_shadow_of_trace_id = $2 @@ -366,7 +366,8 @@ export async function getDisagreementContext( peerResults.push({ endpointHash: row.evm_endpoint_hash ?? undefined, provider: row.evm_provider ?? undefined, - resultHash: row.evm_result_hash + resultHash: row.evm_result_hash, + attestedBy: row.evm_attested_by ?? undefined }); } @@ -546,6 +547,15 @@ export async function getWeb3ThreatContext(pool: pg.Pool, event: HeightproofEven }; } +/** + * The signer to credit with an observation: only an attestation the worker re-verified, + * answering the request that was actually sent. + */ +function attestedBy(event: HeightproofEvent): string | null { + const attestation = event.evmRpc?.attestation; + return attestation?.signatureValid && attestation.requestBound ? attestation.signer : null; +} + /** Inserts the event row. Returns false when an event with this id is already stored. */ export async function persistEvent(db: Queryable, event: HeightproofEvent, assessment: ThreatAssessment) { const requestHeaders = redactHeaders(event.request.headers); @@ -570,7 +580,8 @@ export async function persistEvent(db: Queryable, event: HeightproofEvent, asses evm_rpc_error_code, evm_rpc_error_message, evm_endpoint_hash, evm_cost_units, evm_shadow_of_trace_id, evm_result_count, evm_throttled, error_type, error_message, error_stack, - threat_score, threat_severity, threat_signals + threat_score, threat_severity, threat_signals, + evm_attestation, evm_attested_by ) values ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, @@ -581,7 +592,8 @@ export async function persistEvent(db: Queryable, event: HeightproofEvent, asses $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, - $44, $45, $46 + $44, $45, $46, + $47, $48 ) on conflict (id) do nothing `, @@ -631,7 +643,9 @@ export async function persistEvent(db: Queryable, event: HeightproofEvent, asses event.error?.stack, assessment.score, assessment.severity, - toJsonb(assessment.signals) + toJsonb(assessment.signals), + event.evmRpc?.attestation ? toJsonb(event.evmRpc.attestation) : null, + attestedBy(event) ] ); return result.rowCount === 1; diff --git a/apps/worker/src/stream.test.ts b/apps/worker/src/stream.test.ts index 324c7a2..4692cd6 100644 --- a/apps/worker/src/stream.test.ts +++ b/apps/worker/src/stream.test.ts @@ -1,3 +1,4 @@ +import { hashRpcResult } from "@heightproof/shared"; import { describe, expect, it } from "vitest"; import { backoffDelayMs } from "./worker.js"; import { parseStreamMessage } from "./stream.js"; @@ -47,6 +48,50 @@ describe("parseStreamMessage", () => { reason: "schema_mismatch" }); }); + + describe("airnode attestations", () => { + // A real AirnodeHub dRPC answer (eth_chainId on Base) and its signature. + const signedBody = '{"id":1,"jsonrpc":"2.0","result":"0x2105"}'; + const attested = (attestation: Record) => ({ + ...event, + kind: "evm_rpc", + request: { method: "POST", path: "/rpc" }, + evmRpc: { + method: "eth_chainId", + resultHash: hashRpcResult("0x2105", "eth_chainId"), + attestation: { + scheme: "airnode", + signer: "0x511AcE8648D2f64260d50D036F8f8ce622d92137", + requestHash: "0x54a6129d7f43c1eca3275010c8dab07929f1a29f218c1a3943e20607ec43166c", + timestamp: "1793476662", + signature: + "0x6c4a5442ff116c6d37d133d17155b22743226cc10d82f29bf226b25a8bdda6db24b375f87102c888b631d700af78bab5b55b3e0919d67dafee1242e110543bca1c", + signatureValid: true, + requestBound: true, + signedBody, + ...attestation + } + } + }); + + function verdict(attestation: Record) { + const parsed = parseStreamMessage(["event", JSON.stringify(attested(attestation))]); + if (!parsed.ok) throw new Error(parsed.reason); + return parsed.event.evmRpc?.attestation?.signatureValid; + } + + it("keeps a signature it can re-check itself", () => { + expect(verdict({})).toBe(true); + }); + + it("overrules an SDK that vouched for a tampered body", () => { + expect(verdict({ signedBody: signedBody.replace("0x2105", "0x2106") })).toBe(false); + }); + + it("overrules an SDK verdict it has no body to check", () => { + expect(verdict({ signedBody: undefined })).toBe(false); + }); + }); }); describe("backoffDelayMs", () => { diff --git a/apps/worker/src/stream.ts b/apps/worker/src/stream.ts index 6c372fc..8119cf2 100644 --- a/apps/worker/src/stream.ts +++ b/apps/worker/src/stream.ts @@ -1,5 +1,5 @@ import { Redis } from "ioredis"; -import { HeightproofEvent, HeightproofEventSchema } from "@heightproof/shared"; +import { HeightproofEvent, HeightproofEventSchema, hashRpcResult, reverifyAttestation } from "@heightproof/shared"; import type { WorkerConfig } from "./config.js"; export type StreamMessage = [id: string, fields: string[]]; @@ -25,10 +25,28 @@ export function parseStreamMessage(fields: string[]): ParsedMessage { const parsed = HeightproofEventSchema.safeParse(json); return parsed.success - ? { ok: true, event: parsed.data } + ? { ok: true, event: withReverifiedAttestation(parsed.data) } : { ok: false, reason: "schema_mismatch" }; } +/** + * An attestation's verdict comes from the SDK, which runs in someone else's process. The + * worker is the trust boundary, so it re-checks the signature itself before anything + * stores or compares it. + */ +function withReverifiedAttestation(event: HeightproofEvent): HeightproofEvent { + const evmRpc = event.evmRpc; + if (!evmRpc?.attestation) return event; + + return { + ...event, + evmRpc: { + ...evmRpc, + attestation: reverifyAttestation(evmRpc.attestation, evmRpc.method, evmRpc.resultHash, hashRpcResult) + } + }; +} + /** * Copies a message to the dead-letter stream and acks the original in one MULTI, so a message * is never both lost and acked, nor dead-lettered twice. diff --git a/apps/worker/src/worker.integration.test.ts b/apps/worker/src/worker.integration.test.ts index 1598de3..a110013 100644 --- a/apps/worker/src/worker.integration.test.ts +++ b/apps/worker/src/worker.integration.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import pg from "pg"; -import type { HeightproofEvent, ThreatAssessment } from "@heightproof/shared"; +import { hashRpcResult, type HeightproofEvent, type ThreatAssessment } from "@heightproof/shared"; import { afterEach, describe, expect, it } from "vitest"; import { createIncidentIfNeeded, getStaleHeadContext, persistEvent } from "./db.js"; import { runWorker } from "./worker.js"; @@ -478,4 +478,90 @@ describe.skipIf(!enabled)("worker against real Redis and Postgres", () => { }); }); }); + + describe("airnode-attested verification", () => { + // A real AirnodeHub dRPC answer (eth_chainId on Base) and its signature. + const signer = "0x511AcE8648D2f64260d50D036F8f8ce622d92137"; + const signedBody = '{"id":1,"jsonrpc":"2.0","result":"0x2105"}'; + + function shadow( + projectId: string, + traceId: string, + provider: string, + result: string, + attested: boolean + ) { + return JSON.stringify({ + id: `it-${randomUUID()}`, + projectId, + serviceName: "integration", + timestamp: new Date().toISOString(), + kind: "evm_rpc", + request: { method: "POST", path: "/rpc" }, + response: { statusCode: 200, latencyMs: 5 }, + evmRpc: { + method: "eth_chainId", + chainId: "8453", + provider, + endpointHash: `endpoint-${provider}`, + blockNumber: "1000", + resultHash: hashRpcResult(result, "eth_chainId"), + resultShape: "value", + shadowOfTraceId: traceId, + attestation: attested + ? { + scheme: "airnode", + signer, + requestHash: "0x54a6129d7f43c1eca3275010c8dab07929f1a29f218c1a3943e20607ec43166c", + timestamp: "1793476662", + signature: + "0x6c4a5442ff116c6d37d133d17155b22743226cc10d82f29bf226b25a8bdda6db24b375f87102c888b631d700af78bab5b55b3e0919d67dafee1242e110543bca1c", + signatureValid: true, + requestBound: true, + signedBody + } + : undefined + } + }); + } + + it("stores the re-verified signer and cites it when a peer disagrees", async () => { + const worker = await startWorker(); + const projectId = `it-${randomUUID()}`; + const traceId = randomUUID().replace(/-/g, ""); + + await worker.redis.xadd( + worker.config.streamName, + "*", + "event", + shadow(projectId, traceId, "airnode-drpc", "0x2105", true) + ); + await worker.tick(); + await worker.redis.xadd( + worker.config.streamName, + "*", + "event", + shadow(projectId, traceId, "infura", "0x1", false) + ); + await worker.tick(); + + const rows = await worker.pool.query( + `select evm_provider, evm_attested_by, evm_attestation->>'signedBody' as body, threat_signals + from api_events where project_id = $1 order by evm_provider`, + [projectId] + ); + + expect(rows.rows[0]).toMatchObject({ + evm_provider: "airnode-drpc", + evm_attested_by: signer, + body: signedBody + }); + expect(rows.rows[1].evm_attested_by).toBeNull(); + + const disagreement = (rows.rows[1].threat_signals as { name: string; reason: string }[]).find( + (signal) => signal.name === "provider_disagreement" + ); + expect(disagreement?.reason).toContain(`Airnode-signed evidence from ${signer}`); + }); + }); }); diff --git a/docs/airnode.md b/docs/airnode.md new file mode 100644 index 0000000..a99bed5 --- /dev/null +++ b/docs/airnode.md @@ -0,0 +1,85 @@ +# Signed verification with AirnodeHub + +Cross-provider verification (D2) tells you that two providers disagreed. Adding an +[AirnodeHub](https://airnodehub.api3.org) endpoint to the fan-out makes that finding +**independently checkable**: every answer it gives is signed, so anyone holding the stored +evidence can confirm, offline and without trusting Heightproof, which address answered which +request and when. + +## What a signature does and does not prove + +An Airnode signs `keccak256(requestHash ‖ timestamp ‖ body)` with EIP-191 +([spec](https://airnodehub-docs.api3.org/airnode/attestation)). Heightproof checks two things: + +| Check | Meaning | +| ---------------- | -------------------------------------------------------------------------- | +| `signatureValid` | The signature recovers to the Airnode's address over the exact body received. | +| `requestBound` | The signed request hash is the one *this* request derives, so the answer cannot be a replay of another request's. | + +**A signature is attribution, not truth.** An Airnode can be stale like any other endpoint. +Heightproof compares its answer like any other peer and never treats it as the correct one; a +signature changes no detector weight. What it adds is evidence: the D2 incident names the signer, +and the signed body is kept so the finding can be re-verified later. + +## Trust boundary + +The SDK checks every attestation as it arrives, but the SDK runs in your application, so the +worker checks again before anything is stored or compared. It re-recovers the signer from the +kept body and confirms that body holds the very result whose hash was compared. A signed answer +to some other question cannot vouch for this one. An attestation over a body larger than 64 KiB +is kept without the body and is not credited, because there is nothing left to re-check. + +## Setup + +AirnodeHub's dRPC listing covers the standard EVM reads on 100+ chains. Its reads cost $0.01 per +call, paid with [x402](https://x402.org) in aUSD, API3's test dollar on Base Sepolia, while +AirnodeHub is in early access. Heightproof does not handle payment itself. You pass the endpoint +a `fetch` that pays: + +```ts +import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm"; +import { privateKeyToAccount } from "viem/accounts"; +import { airnodeDrpcEndpoint, heightproofTransport } from "@heightproof/web3"; + +// A wallet used only to pay for verification calls. Fund it from +// https://airnodehub.api3.org/faucet (minting needs a little Base Sepolia ETH for gas). +const payer = privateKeyToAccount(process.env.AIRNODE_PAYER_KEY as `0x${string}`); +const payingFetch = wrapFetchWithPaymentFromConfig(fetch, { + schemes: [{ network: "eip155:84532", client: new ExactEvmScheme(payer) }] +}); + +const transport = heightproofTransport({ + projectId: "my-project", + apiKey: process.env.HEIGHTPROOF_API_KEY!, + endpoint: "https://heightproof.internal", + rpcUrl: process.env.EVM_RPC_URL!, + chainId: 8453, + provider: "alchemy", + verify: { + endpoints: [ + { rpcUrl: process.env.SECOND_RPC_URL!, provider: "infura" }, + // dRPC network slug, not a chain ID. + airnodeDrpcEndpoint({ network: "base", fetchImpl: payingFetch }) + ], + sampleRate: 0.01, + maxCallsPerMinute: 30 + } +}); +``` + +`maxCallsPerMinute` caps the spend: with the Airnode as one of three endpoints, a ceiling of 30 +is at most 10 paid calls a minute. An unpaid or refused call (`402`) is recorded as a failed +observation and never compared. + +## Where it shows up + +- **Incidents:** a D2 reason ends with `Airnode-signed evidence from 0x… can be verified independently`. +- **Request explorer:** a verified answer shows its signer as *Airnode Signer (verified)*. +- **Postgres:** `api_events.evm_attestation` holds the full attestation, including `signedBody`. + `evm_attested_by` holds the signer, and is set only when the worker's re-check passed. + +To re-verify stored evidence yourself, take `signer`, `requestHash`, `timestamp`, `signature` and +`signedBody` from `evm_attestation` and run the check from the +[attestation spec](https://airnodehub-docs.api3.org/airnode/attestation#verifying-a-response), or +call `checkAirnodeAttestation` from `@heightproof/shared`. diff --git a/docs/detectors.md b/docs/detectors.md index eda338f..24e50ec 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -120,6 +120,11 @@ providers agree rather than auditing one specific response, and it is the only f comparison that is free of head-skew false positives. The primary is added to the fan-out automatically, so every sample yields a set of directly comparable observations. +**Signed peers.** A secondary can be an AirnodeHub endpoint whose answers are signed. Its +answer is compared like any other peer's, and a signature changes no weight. When a disagreement +involves an answer whose signature the worker re-verified, the reason names the signer and the +signed body is kept as evidence. See [airnode.md](airnode.md). + **Cost:** `sampleRate × (secondaryCount + 1)` extra calls, on endpoints you configure, plus at most one `eth_blockNumber` per `headCacheMs` to resolve a pin target. Bounded by a hard `maxCallsPerMinute` ceiling (default 60) that is checked before every fan-out, so verification can diff --git a/infra/migrations/003_airnode_attestation.sql b/infra/migrations/003_airnode_attestation.sql new file mode 100644 index 0000000..5ca26c4 --- /dev/null +++ b/infra/migrations/003_airnode_attestation.sql @@ -0,0 +1,10 @@ +-- Airnode-attested verification. +-- +-- A verification observation answered by an Airnode carries a signature over the exact +-- response it gave. `evm_attestation` keeps the whole attestation, including the signed +-- body, so the evidence can be re-verified later by anyone. `evm_attested_by` is the signer, +-- set only when the worker re-checked the signature and the request it answers. + +alter table api_events + add column if not exists evm_attestation jsonb, + add column if not exists evm_attested_by text; diff --git a/package-lock.json b/package-lock.json index d900601..3902cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1318,6 +1318,33 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6092,6 +6119,7 @@ "packages/sdk-node": { "name": "@heightproof/sdk-node", "version": "0.1.0", + "license": "MIT", "dependencies": { "@heightproof/shared": "0.1.0" }, @@ -6099,25 +6127,47 @@ "@types/express": "^4.17.21", "express": "^5.2.1" }, + "engines": { + "node": ">=20" + }, "peerDependencies": { "express": ">=4" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } } }, "packages/shared": { "name": "@heightproof/shared", "version": "0.1.0", + "license": "MIT", "dependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", "@opentelemetry/api": "^1.9.1", "zod": "^3.23.8" + }, + "engines": { + "node": ">=20" } }, "packages/web3": { "name": "@heightproof/web3", "version": "0.1.0", + "license": "MIT", "dependencies": { "@heightproof/sdk-node": "0.1.0", "@heightproof/shared": "0.1.0" }, + "devDependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": ">=20" + }, "peerDependencies": { "viem": ">=2" }, diff --git a/packages/shared/package.json b/packages/shared/package.json index 326fb31..009dc9f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -42,6 +42,8 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", "@opentelemetry/api": "^1.9.1", "zod": "^3.23.8" } diff --git a/packages/shared/src/airnode.test.ts b/packages/shared/src/airnode.test.ts new file mode 100644 index 0000000..29ef45e --- /dev/null +++ b/packages/shared/src/airnode.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + airnodePassthroughRequestHash, + checkAirnodeAttestation, + readAirnodeAttestation, + recoverAirnodeSigner, + reverifyAttestation +} from "./airnode.js"; +import { hashRpcResult } from "./rpc.js"; + +// A real response from AirnodeHub's dRPC listing (eth_chainId on Base), captured as sent. +const URL_BASE = "https://airnode-drpc.fly.dev/api/ogrpc?network=base"; +const REQUEST_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }); +const RESPONSE_BODY = '{"id":1,"jsonrpc":"2.0","result":"0x2105"}'; +const SIGNED = { + signer: "0x511AcE8648D2f64260d50D036F8f8ce622d92137", + requestHash: "0x54a6129d7f43c1eca3275010c8dab07929f1a29f218c1a3943e20607ec43166c", + timestamp: "1793476662", + signature: + "0x6c4a5442ff116c6d37d133d17155b22743226cc10d82f29bf226b25a8bdda6db24b375f87102c888b631d700af78bab5b55b3e0919d67dafee1242e110543bca1c" +}; + +describe("airnode passthrough request hash", () => { + it("derives the hash the Airnode signed for this exact request", () => { + expect( + airnodePassthroughRequestHash({ method: "post", url: URL_BASE, body: REQUEST_BODY }) + ).toBe(SIGNED.requestHash); + }); + + it("ignores the Airnode's own x-airnode-* query parameters", () => { + expect( + airnodePassthroughRequestHash({ + method: "POST", + url: `${URL_BASE}&x-airnode-sign=false`, + body: REQUEST_BODY + }) + ).toBe(SIGNED.requestHash); + }); + + it("changes with the body, so an answer cannot be replayed for another request", () => { + const other = JSON.stringify({ jsonrpc: "2.0", id: 2, method: "eth_chainId", params: [] }); + expect(airnodePassthroughRequestHash({ method: "POST", url: URL_BASE, body: other })).not.toBe( + SIGNED.requestHash + ); + }); + + it("refuses a URL that is not a passthrough route", () => { + expect( + airnodePassthroughRequestHash({ + method: "POST", + url: "https://airnode-drpc.fly.dev/", + body: "" + }) + ).toBeUndefined(); + }); +}); + +describe("airnode signature", () => { + it("recovers the listed signer over the body exactly as received", () => { + expect(recoverAirnodeSigner(SIGNED, RESPONSE_BODY)).toBe(SIGNED.signer.toLowerCase()); + expect(checkAirnodeAttestation(SIGNED, RESPONSE_BODY, SIGNED.requestHash)).toEqual({ + signatureValid: true, + requestBound: true + }); + }); + + it("rejects a body that differs by a single character", () => { + const tampered = RESPONSE_BODY.replace("0x2105", "0x2106"); + expect(checkAirnodeAttestation(SIGNED, tampered).signatureValid).toBe(false); + }); + + it("rejects a re-serialized body, which is why responses are read as text", () => { + const reserialized = JSON.stringify(JSON.parse(RESPONSE_BODY), null, 1); + expect(checkAirnodeAttestation(SIGNED, reserialized).signatureValid).toBe(false); + }); + + it("does not call an answer to a different request bound", () => { + const check = checkAirnodeAttestation(SIGNED, RESPONSE_BODY, `0x${"0".repeat(64)}`); + expect(check).toEqual({ signatureValid: true, requestBound: false }); + }); + + it("treats a malformed signature as invalid instead of throwing", () => { + expect( + checkAirnodeAttestation({ ...SIGNED, signature: `0x${"00".repeat(65)}` }, RESPONSE_BODY) + ).toEqual({ + signatureValid: false, + requestBound: false + }); + }); +}); + +describe("readAirnodeAttestation", () => { + it("reads the four headers", () => { + const headers = new Headers({ + "X-Airnode-Address": SIGNED.signer, + "X-Airnode-Request-Hash": SIGNED.requestHash, + "X-Airnode-Timestamp": SIGNED.timestamp, + "X-Airnode-Signature": SIGNED.signature + }); + expect(readAirnodeAttestation(headers)).toEqual(SIGNED); + }); + + it("returns undefined when any header is missing or malformed", () => { + expect( + readAirnodeAttestation(new Headers({ "X-Airnode-Address": SIGNED.signer })) + ).toBeUndefined(); + expect( + readAirnodeAttestation( + new Headers({ + "X-Airnode-Address": SIGNED.signer, + "X-Airnode-Request-Hash": SIGNED.requestHash, + "X-Airnode-Timestamp": "soon", + "X-Airnode-Signature": SIGNED.signature + }) + ) + ).toBeUndefined(); + }); +}); + +describe("reverifyAttestation", () => { + const resultHash = hashRpcResult("0x2105", "eth_chainId"); + const claimed = { ...SIGNED, signatureValid: true, signedBody: RESPONSE_BODY }; + + it("keeps a signature that covers the compared result", () => { + expect( + reverifyAttestation(claimed, "eth_chainId", resultHash, hashRpcResult).signatureValid + ).toBe(true); + }); + + it("drops a valid signature over some other answer", () => { + const otherHash = hashRpcResult("0x1", "eth_chainId"); + expect( + reverifyAttestation(claimed, "eth_chainId", otherHash, hashRpcResult).signatureValid + ).toBe(false); + }); + + it("drops a verdict the SDK claimed without a body to check", () => { + const withoutBody = { ...claimed, signedBody: undefined }; + expect( + reverifyAttestation(withoutBody, "eth_chainId", resultHash, hashRpcResult).signatureValid + ).toBe(false); + }); + + it("drops a forged verdict over a tampered body", () => { + const forged = { ...claimed, signedBody: RESPONSE_BODY.replace("0x2105", "0x2106") }; + expect( + reverifyAttestation(forged, "eth_chainId", resultHash, hashRpcResult).signatureValid + ).toBe(false); + }); +}); diff --git a/packages/shared/src/airnode.ts b/packages/shared/src/airnode.ts new file mode 100644 index 0000000..307c027 --- /dev/null +++ b/packages/shared/src/airnode.ts @@ -0,0 +1,194 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3"; +import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils"; + +/** + * Airnode attestations. + * + * An Airnode signs every response it serves: an EIP-191 signature over + * keccak256(requestHash ‖ uint256 timestamp ‖ body bytes). A verification observation that + * carries one is no longer just "a third provider said so" — anyone holding the signed body + * can check, offline and without trusting Heightproof, which address answered which request + * at which time. + * + * What a signature does not prove is that the answer is correct. It is attribution, not + * truth: an attested reference can be stale like any other endpoint, and detectors treat it + * as one more comparable peer, never as an oracle. + * + * Spec: https://airnodehub-docs.api3.org/airnode/attestation + */ + +export type AirnodeAttestation = { + /** The address the response claims to be signed by (X-Airnode-Address). */ + signer: string; + requestHash: string; + /** Unix seconds at which the response was signed. */ + timestamp: string; + signature: string; +}; + +export type AirnodeAttestationCheck = { + /** The signature recovers to `signer` over exactly this body, request and time. */ + signatureValid: boolean; + /** The request hash is the one this exact request derives, so the answer is ours. */ + requestBound: boolean; +}; + +const HEX_32 = /^0x[0-9a-fA-F]{64}$/; +const HEX_ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/; + +/** Reads the four attestation headers. Undefined unless all four are present and well formed. */ +export function readAirnodeAttestation( + headers: Pick +): AirnodeAttestation | undefined { + const signer = headers.get("x-airnode-address"); + const requestHash = headers.get("x-airnode-request-hash"); + const timestamp = headers.get("x-airnode-timestamp"); + const signature = headers.get("x-airnode-signature"); + + if (!signer || !HEX_ADDRESS.test(signer)) return undefined; + if (!requestHash || !HEX_32.test(requestHash)) return undefined; + if (!timestamp || !/^\d+$/.test(timestamp)) return undefined; + if (!signature || !HEX_SIGNATURE.test(signature)) return undefined; + + return { signer, requestHash, timestamp, signature }; +} + +/** + * The request hash an Airnode derives for a passthrough request. + * + * keccak256(utf8(JSON.stringify(["passthrough", METHOD, path, queryEntries, headerEntries, body]))) + * where `path` excludes the `/api` prefix and query entries keep their sent order with every + * `x-airnode-*` parameter removed. + */ +export function airnodePassthroughRequestHash(input: { + method: string; + /** Full request URL, including the `/api` prefix and the query. */ + url: string; + body: string; + headerEntries?: [string, string][]; +}): string | undefined { + let parsed: URL; + try { + parsed = new URL(input.url); + } catch { + return undefined; + } + + const apiIndex = parsed.pathname.indexOf("/api/"); + if (apiIndex === -1) return undefined; + + const path = parsed.pathname.slice(apiIndex + "/api".length); + const queryEntries = [...parsed.searchParams.entries()].filter( + ([name]) => !name.startsWith("x-airnode-") + ); + const headerEntries = [...(input.headerEntries ?? [])].sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0 + ); + + const serialized = JSON.stringify([ + "passthrough", + input.method.toUpperCase(), + path, + queryEntries, + headerEntries, + input.body + ]); + + return toHex(keccak_256(utf8ToBytes(serialized))); +} + +/** Recovers the address that signed `body` under this attestation, or undefined if malformed. */ +export function recoverAirnodeSigner( + attestation: AirnodeAttestation, + body: string +): string | undefined { + try { + const timestamp = BigInt(attestation.timestamp); + const digest = keccak_256( + concatBytes( + hexToBytes(attestation.requestHash.slice(2)), + hexToBytes(timestamp.toString(16).padStart(64, "0")), + utf8ToBytes(body) + ) + ); + const prefixed = keccak_256( + concatBytes(utf8ToBytes("\x19Ethereum Signed Message:\n32"), digest) + ); + + const signature = hexToBytes(attestation.signature.slice(2)); + const recovery = signature[64]! >= 27 ? signature[64]! - 27 : signature[64]!; + const publicKey = secp256k1.Signature.fromCompact(signature.slice(0, 64)) + .addRecoveryBit(recovery) + .recoverPublicKey(prefixed) + .toRawBytes(false); + + return toHex(keccak_256(publicKey.slice(1)).slice(-20)); + } catch { + return undefined; + } +} + +/** + * Checks an attestation against the body that arrived and, when known, the request that + * was sent. Never throws: a malformed attestation is simply not valid. + * + * `body` must be the response text exactly as received. Parsing and re-serializing it + * changes the bytes and every signature then fails. + */ +export function checkAirnodeAttestation( + attestation: AirnodeAttestation, + body: string, + expectedRequestHash?: string +): AirnodeAttestationCheck { + const recovered = recoverAirnodeSigner(attestation, body); + + return { + signatureValid: + recovered !== undefined && recovered.toLowerCase() === attestation.signer.toLowerCase(), + requestBound: + expectedRequestHash !== undefined && + expectedRequestHash.toLowerCase() === attestation.requestHash.toLowerCase() + }; +} + +/** + * Re-checks an attestation that arrived on an ingested event, without trusting the SDK. + * + * `signatureValid` survives only when the kept body still carries the signature *and* + * holds the very result whose hash was compared — otherwise a valid signature over some + * other answer could vouch for this one. Without a kept body there is nothing to check, so + * it is false. `requestBound` is the SDK's: re-deriving it needs the exact request bytes + * and endpoint URL, which are deliberately never transmitted. + */ +export function reverifyAttestation< + T extends AirnodeAttestation & { signatureValid: boolean; signedBody?: string } +>( + attestation: T, + method: string, + resultHash: string | undefined, + hashResult: (result: unknown, method: string) => string +): T { + if (!attestation.signedBody) return { ...attestation, signatureValid: false }; + + const { signatureValid } = checkAirnodeAttestation(attestation, attestation.signedBody); + if (!signatureValid) return { ...attestation, signatureValid: false }; + + let coversResult = resultHash === undefined; + if (resultHash !== undefined) { + try { + const parsed = JSON.parse(attestation.signedBody) as { result?: unknown }; + coversResult = + parsed.result !== undefined && hashResult(parsed.result, method) === resultHash; + } catch { + coversResult = false; + } + } + + return { ...attestation, signatureValid: coversResult }; +} + +function toHex(bytes: Uint8Array): string { + return `0x${bytesToHex(bytes)}`; +} diff --git a/packages/shared/src/detectors.test.ts b/packages/shared/src/detectors.test.ts index 58ea241..3731cf3 100644 --- a/packages/shared/src/detectors.test.ts +++ b/packages/shared/src/detectors.test.ts @@ -304,6 +304,33 @@ describe("D2 cross-provider disagreement", () => { expect(signals[0].reason).toContain("truncated"); expect(signals[0].weight).toBeLessThan(40); }); + + it("names the Airnode signer of a disagreeing answer without changing the weight", () => { + const signer = "0x511AcE8648D2f64260d50D036F8f8ce622d92137"; + const signals = detectDisagreement(rpcEvent(pinned), { + peerResults: [{ provider: "airnode-drpc", resultHash: "bbbb", attestedBy: signer }] + }); + + expect(signals[0].reason).toContain(`Airnode-signed evidence from ${signer}`); + expect(signals[0].weight).toBe(40); + }); + + it("credits only an attestation that is both valid and bound to the request", () => { + const attestation = { + scheme: "airnode" as const, + signer: "0x511AcE8648D2f64260d50D036F8f8ce622d92137", + requestHash: `0x${"1".repeat(64)}`, + timestamp: "1793476662", + signature: `0x${"2".repeat(130)}`, + signatureValid: true, + requestBound: false + }; + const signals = detectDisagreement(rpcEvent({ ...pinned, attestation }), { + peerResults: [{ provider: "infura", resultHash: "bbbb" }] + }); + + expect(signals[0].reason).not.toContain("Airnode"); + }); }); describe("D4 reorg lag", () => { diff --git a/packages/shared/src/detectors.ts b/packages/shared/src/detectors.ts index 6f65585..92ee329 100644 --- a/packages/shared/src/detectors.ts +++ b/packages/shared/src/detectors.ts @@ -242,6 +242,8 @@ export type PeerResult = { endpointHash?: string; provider?: string; resultHash: string; + /** Signer of a verified Airnode attestation over this peer's answer, if it carried one. */ + attestedBy?: string; }; export type DisagreementContext = { @@ -294,6 +296,7 @@ export function detectDisagreement( if (disagreeing.length === 0) return []; const labels = disagreeing.map((peer) => peer.provider ?? peer.endpointHash ?? "an unnamed endpoint"); + const evidence = attestedEvidence(event, disagreeing); if (DISAGREEMENT_LOG_METHODS.has(method)) { return [ @@ -302,7 +305,7 @@ export function detectDisagreement( weight: 20, reason: `${method} at block ${blockNumber} returned a different set of logs than ${labels.join( ", " - )} — most often a silently truncated range rather than a wrong answer` + )} — most often a silently truncated range rather than a wrong answer${evidence}` } ]; } @@ -313,11 +316,26 @@ export function detectDisagreement( weight: 40, reason: `${method} at block ${blockNumber} returned a different result than ${labels.join( ", " - )}; at equal height this is a provider bug or an unpropagated reorg` + )}; at equal height this is a provider bug or an unpropagated reorg${evidence}` } ]; } +/** + * Names the signed answers on either side of a disagreement. A signature changes no weight: + * it makes the finding independently checkable, it does not make one side right. + */ +function attestedEvidence(event: HeightproofEvent, disagreeing: PeerResult[]): string { + const signers = new Set(); + + const own = event.evmRpc?.attestation; + if (own?.signatureValid && own.requestBound) signers.add(own.signer); + for (const peer of disagreeing) if (peer.attestedBy) signers.add(peer.attestedBy); + + if (signers.size === 0) return ""; + return `. Airnode-signed evidence from ${[...signers].join(", ")} can be verified independently`; +} + /** * D4 — reorg lag. * diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 456c720..0ed4bf8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,15 @@ import { } from "./detectors.js"; import { z } from "zod"; +export { + airnodePassthroughRequestHash, + checkAirnodeAttestation, + readAirnodeAttestation, + recoverAirnodeSigner, + reverifyAttestation, + type AirnodeAttestation, + type AirnodeAttestationCheck +} from "./airnode.js"; export { buildDemoEvents } from "./demoEvents.js"; export { detectCostWaste, @@ -56,6 +65,9 @@ export { type ResultShape } from "./rpc.js"; +/** Largest signed response body kept as attestation evidence. */ +export const AIRNODE_SIGNED_BODY_MAX_CHARS = 65_536; + export const HttpMethodSchema = z.enum([ "GET", "POST", @@ -129,7 +141,24 @@ export const HeightproofEventSchema = z.object({ endpointHash: z.string().optional(), costUnits: z.number().nonnegative().optional(), // Links a verification call back to the primary call it shadows (detector D2). - shadowOfTraceId: z.string().optional() + shadowOfTraceId: z.string().optional(), + // A signed answer from an attesting endpoint (an Airnode). Attribution, not truth: + // detectors compare it like any other peer. The worker re-checks the signature + // against `signedBody` rather than trusting the SDK's verdict. + attestation: z + .object({ + scheme: z.literal("airnode"), + signer: z.string().regex(/^0x[0-9a-fA-F]{40}$/), + requestHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/), + timestamp: z.string().regex(/^\d+$/), + signature: z.string().regex(/^0x[0-9a-fA-F]{130}$/), + signatureValid: z.boolean(), + requestBound: z.boolean(), + // The response text exactly as signed, kept so the evidence can be re-verified + // later by anyone. Capped: an attestation over a larger body is kept without it. + signedBody: z.string().max(AIRNODE_SIGNED_BODY_MAX_CHARS).optional() + }) + .optional() }) .optional(), error: z diff --git a/packages/web3/package.json b/packages/web3/package.json index 457e728..1d1e489 100644 --- a/packages/web3/package.json +++ b/packages/web3/package.json @@ -56,5 +56,9 @@ "viem": { "optional": true } + }, + "devDependencies": { + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0" } } diff --git a/packages/web3/src/index.ts b/packages/web3/src/index.ts index 885cff9..64a474b 100644 --- a/packages/web3/src/index.ts +++ b/packages/web3/src/index.ts @@ -315,7 +315,8 @@ class RpcRecorder { costUnits: methodCostUnits(args.method, this.costTable), // Ties this observation back to the call that triggered verification, and to its // sibling observations at the same height. - shadowOfTraceId: traceId + shadowOfTraceId: traceId, + attestation: observation.attestation } }); } @@ -362,5 +363,10 @@ function collectStrings(value: unknown): string[] { return Object.values(value).flatMap((entry) => collectStrings(entry)); } -export { RpcVerifier } from "./verify.js"; -export type { PeerObservation, VerificationEndpoint, VerificationOptions } from "./verify.js"; +export { AIRNODE_DRPC_URL, airnodeDrpcEndpoint, RpcVerifier } from "./verify.js"; +export type { + ObservationAttestation, + PeerObservation, + VerificationEndpoint, + VerificationOptions +} from "./verify.js"; diff --git a/packages/web3/src/verify.test.ts b/packages/web3/src/verify.test.ts index ab2459b..7732de6 100644 --- a/packages/web3/src/verify.test.ts +++ b/packages/web3/src/verify.test.ts @@ -1,5 +1,9 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3"; +import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils"; +import { airnodePassthroughRequestHash } from "@heightproof/shared"; import { describe, expect, it, vi } from "vitest"; -import { RpcVerifier } from "./verify.js"; +import { airnodeDrpcEndpoint, RpcVerifier } from "./verify.js"; const endpoints = [ { rpcUrl: "https://a.example/key-a", provider: "alchemy" }, @@ -7,7 +11,7 @@ const endpoints = [ ]; function jsonResponse(body: unknown) { - return { ok: true, status: 200, json: async () => body } as unknown as Response; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); } describe("eligibility", () => { @@ -223,3 +227,112 @@ describe("observations", () => { expect(run!.observations[0].blockHash).toBe(hash); }); }); + +describe("airnode-attested endpoints", () => { + // A stand-in Airnode: signs whatever it answers exactly as the real one does. + const privateKey = secp256k1.utils.randomPrivateKey(); + const signer = `0x${bytesToHex(keccak_256(secp256k1.getPublicKey(privateKey, false).slice(1)).slice(-20))}`; + + function signedResponse(requestHash: string, text: string, timestamp = 1_793_476_662n) { + const digest = keccak_256( + concatBytes(hexToBytes(requestHash.slice(2)), hexToBytes(timestamp.toString(16).padStart(64, "0")), utf8ToBytes(text)) + ); + const signature = secp256k1.sign( + keccak_256(concatBytes(utf8ToBytes("\x19Ethereum Signed Message:\n32"), digest)), + privateKey + ); + const encoded = `0x${bytesToHex(signature.toCompactRawBytes())}${(signature.recovery + 27).toString(16)}`; + + return new Response(text, { + status: 200, + headers: { + "content-type": "application/json", + "x-airnode-address": signer, + "x-airnode-request-hash": requestHash, + "x-airnode-timestamp": timestamp.toString(), + "x-airnode-signature": encoded + } + }); + } + + function fakeAirnode(answer: (requestHash: string) => Response) { + return vi.fn(async (url: string, init: RequestInit) => + answer(airnodePassthroughRequestHash({ method: "POST", url, body: String(init.body) })!) + ); + } + + it("builds a passthrough endpoint for the dRPC listing", () => { + expect(airnodeDrpcEndpoint({ network: "base" })).toMatchObject({ + rpcUrl: "https://airnode-drpc.fly.dev/api/ogrpc?network=base", + provider: "airnode-drpc", + attestation: "airnode" + }); + }); + + it("keeps a valid, request-bound attestation with the signed body", async () => { + const text = '{"id":1,"jsonrpc":"2.0","result":"0x64"}'; + const airnodeFetch = fakeAirnode((hash) => signedResponse(hash, text)); + const verifier = new RpcVerifier({ + endpoints: [airnodeDrpcEndpoint({ network: "base", fetchImpl: airnodeFetch as unknown as typeof fetch })] + }); + + const run = await verifier.verify("eth_getBalance", ["0xabc", "latest"], "100"); + const attestation = run!.observations[0]!.attestation; + + expect(airnodeFetch).toHaveBeenCalledTimes(1); + expect(attestation).toMatchObject({ + scheme: "airnode", + signer, + signatureValid: true, + requestBound: true, + signedBody: text + }); + }); + + it("flags an answer signed for a different request", async () => { + const airnodeFetch = fakeAirnode(() => + signedResponse(`0x${"ab".repeat(32)}`, '{"id":1,"jsonrpc":"2.0","result":"0x64"}') + ); + const verifier = new RpcVerifier({ + endpoints: [airnodeDrpcEndpoint({ network: "base", fetchImpl: airnodeFetch as unknown as typeof fetch })] + }); + + const run = await verifier.verify("eth_getBalance", ["0xabc", "latest"], "100"); + + expect(run!.observations[0]!.attestation).toMatchObject({ signatureValid: true, requestBound: false }); + }); + + it("records an unpaid 402 as a failed observation, never as an answer", async () => { + const unpaid = vi.fn(async () => + new Response('{"error":"Payment required"}', { status: 402, headers: { "content-type": "application/json" } }) + ); + const verifier = new RpcVerifier({ + endpoints: [airnodeDrpcEndpoint({ network: "base", fetchImpl: unpaid as unknown as typeof fetch })] + }); + + const observation = (await verifier.verify("eth_getBalance", ["0xabc", "latest"], "100"))!.observations[0]!; + + expect(observation.resultHash).toBeUndefined(); + expect(observation.rpcErrorMessage).toContain("402"); + expect(observation.attestation).toBeUndefined(); + }); + + it("uses each endpoint's own fetch and the shared one for the rest", async () => { + const shared = vi.fn(async () => jsonResponse({ result: "0x64" })); + const paying = fakeAirnode((hash) => signedResponse(hash, '{"id":1,"jsonrpc":"2.0","result":"0x64"}')); + const verifier = new RpcVerifier({ + endpoints: [ + { rpcUrl: "https://a.example/key-a", provider: "alchemy" }, + airnodeDrpcEndpoint({ network: "base", fetchImpl: paying as unknown as typeof fetch }) + ], + fetchImpl: shared as unknown as typeof fetch + }); + + const run = await verifier.verify("eth_getBalance", ["0xabc", "latest"], "100"); + + expect(shared).toHaveBeenCalledTimes(1); + expect(paying).toHaveBeenCalledTimes(1); + expect(run!.observations[0]!.attestation).toBeUndefined(); + expect(run!.observations[1]!.resultHash).toBe(run!.observations[0]!.resultHash); + }); +}); diff --git a/packages/web3/src/verify.ts b/packages/web3/src/verify.ts index b906006..b19d432 100644 --- a/packages/web3/src/verify.ts +++ b/packages/web3/src/verify.ts @@ -1,4 +1,7 @@ import { + AIRNODE_SIGNED_BODY_MAX_CHARS, + airnodePassthroughRequestHash, + checkAirnodeAttestation, classifyResultShape, extractBlockHash, extractRpcError, @@ -6,6 +9,8 @@ import { hashRpcResult, isVerifiableMethod, pinBlockTag, + readAirnodeAttestation, + type HeightproofEvent, type ResultShape } from "@heightproof/shared"; @@ -27,8 +32,49 @@ import { export type VerificationEndpoint = { rpcUrl: string; provider?: string; + /** + * `"airnode"`: the endpoint signs its answers (an AirnodeHub passthrough URL). Each + * observation then carries the attestation, checked against the exact request sent. + */ + attestation?: "airnode"; + /** + * Fetch for this endpoint only, such as one wrapped to pay x402 charges. Falls back to + * the verifier's `fetchImpl`. + */ + fetchImpl?: typeof fetch; }; +/** AirnodeHub's dRPC listing, reached through its JSON-RPC passthrough. */ +export const AIRNODE_DRPC_URL = "https://airnode-drpc.fly.dev"; + +/** + * A verification endpoint that answers through AirnodeHub's dRPC listing, so every answer + * it gives is signed. + * + * Its reads are priced (x402, per call). Pass a `fetchImpl` that pays, such as one wrapped + * with `wrapFetchWithPaymentFromConfig` from `@x402/fetch`; an unpaid call is recorded as a + * failed observation and never compared. + */ +export function airnodeDrpcEndpoint(options: { + /** dRPC network slug, such as `ethereum` or `base` — not a chain ID. */ + network: string; + fetchImpl?: typeof fetch; + provider?: string; + baseUrl?: string; +}): VerificationEndpoint { + const url = new URL("/api/ogrpc", options.baseUrl ?? AIRNODE_DRPC_URL); + url.searchParams.set("network", options.network); + + return { + rpcUrl: url.toString(), + provider: options.provider ?? "airnode-drpc", + attestation: "airnode", + fetchImpl: options.fetchImpl + }; +} + +export type ObservationAttestation = NonNullable["attestation"]>; + export type VerificationOptions = { /** Endpoints to compare against. */ endpoints: VerificationEndpoint[]; @@ -59,6 +105,8 @@ export type PeerObservation = { missingBlock: boolean; rpcErrorCode?: number; rpcErrorMessage?: string; + /** Present when an attesting endpoint signed this answer, valid or not. */ + attestation?: ObservationAttestation; }; export type VerificationRun = { @@ -184,9 +232,11 @@ export class RpcVerifier { const endpointHash = hashEndpoint(endpoint.rpcUrl); try { - const body = await this.call(endpoint.rpcUrl, method, params); + const response = await this.send(endpoint, method, params); + const body = response.body; const rpcError = extractRpcError(body); const latencyMs = Date.now() - started; + const attestation = response.attestation; if (rpcError) { return { @@ -196,7 +246,8 @@ export class RpcVerifier { latencyMs, missingBlock: MISSING_BLOCK_PATTERN.test(rpcError.message ?? ""), rpcErrorCode: rpcError.code, - rpcErrorMessage: rpcError.message + rpcErrorMessage: rpcError.message, + attestation }; } @@ -213,7 +264,8 @@ export class RpcVerifier { latencyMs, // A null where the primary had an answer is far more likely to be an endpoint that // has not caught up than a claim that the answer is genuinely null. - missingBlock: shape === "null" + missingBlock: shape === "null", + attestation }; } catch (error) { return { @@ -228,20 +280,63 @@ export class RpcVerifier { } private async call(rpcUrl: string, method: string, params: unknown) { + return (await this.send({ rpcUrl }, method, params)).body; + } + + private async send(endpoint: VerificationEndpoint, method: string, params: unknown) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); + // Serialized once: an Airnode's request hash covers these exact bytes. + const requestBody = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }); + const fetchImpl = endpoint.fetchImpl ?? this.fetchImpl; try { - const response = await this.fetchImpl(rpcUrl, { + const response = await fetchImpl(endpoint.rpcUrl, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + body: requestBody, signal: controller.signal }); - return (await response.json()) as { result?: unknown; error?: unknown }; + // Read as text: a signature covers the bytes as received, which re-serializing changes. + const text = await response.text(); + const body = JSON.parse(text) as { jsonrpc?: unknown; result?: unknown; error?: unknown }; + + // A refusal that is not a JSON-RPC answer, such as an unpaid Airnode's 402, is a + // transport failure, not a provider saying something about the chain. + if (!response.ok && body.jsonrpc === undefined) { + throw new Error(`verification endpoint answered HTTP ${response.status}`); + } + + return { + body, + attestation: + endpoint.attestation === "airnode" + ? attestationFor(endpoint.rpcUrl, requestBody, response.headers, text) + : undefined + }; } finally { clearTimeout(timer); } } } + +function attestationFor( + rpcUrl: string, + requestBody: string, + headers: Headers, + text: string +): ObservationAttestation | undefined { + const attestation = readAirnodeAttestation(headers); + if (!attestation) return undefined; + + const expectedRequestHash = airnodePassthroughRequestHash({ method: "POST", url: rpcUrl, body: requestBody }); + const check = checkAirnodeAttestation(attestation, text, expectedRequestHash); + + return { + scheme: "airnode", + ...attestation, + ...check, + signedBody: text.length <= AIRNODE_SIGNED_BODY_MAX_CHARS ? text : undefined + }; +}