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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/components/RequestExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 19 additions & 5 deletions apps/worker/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
});
}

Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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
`,
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions apps/worker/src/stream.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>) => ({
...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<string, unknown>) {
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", () => {
Expand Down
22 changes: 20 additions & 2 deletions apps/worker/src/stream.ts
Original file line number Diff line number Diff line change
@@ -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[]];
Expand All @@ -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.
Expand Down
88 changes: 87 additions & 1 deletion apps/worker/src/worker.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`);
});
});
});
Loading
Loading