Skip to content

Commit 048fb69

Browse files
authored
Reclaim proof integration (#1)
* Add Reclaim TLS proof integration to pipeline When proof mode is enabled (proof: { type: reclaim, gatewayUrl: ... }), Airnode requests a TLS proof from the ChainAPI proof gateway after each API call. The proof is attached to the response alongside the existing EIP-191 signature. Proof failures are non-fatal — the response is still returned without the proof field. - Add reclaim proof schema to settings (none | { type: reclaim, gatewayUrl }) - Add src/proof.ts — lightweight HTTP client for the proof gateway - Thread settings through PipelineDependencies for proof access - Attach proof to SignedResponseBody and RawResponseBody when available * Add responseMatches to endpoint config and pass full URL to proof gateway The Reclaim attestor requires at least one responseMatch to validate and extract data from the API response. Added responseMatches as an optional endpoint-level config field and wired it through to the prove request. Also fixed the prove URL to include resolved query and path parameters. * Update docs images and remove stale push target env vars * Update docs for Reclaim TLS proof integration and fix inaccuracies - Add TLS proofs concept page (book/docs/concepts/proofs.md) - Add reclaim-proof example config (examples/configs/reclaim-proof/) - Fix raw response hash docs: stableStringify, not JSON.stringify - Fix stale NFT auth references, wrong verifyAndFulfill case, OIS terminology - Document responseMatches field in endpoint config - Update settings.md with reclaim proof configuration - Add proof field to all response format examples - Update pipeline from 13 to 14 steps (TLS proof step) - Update roadmap: TLS proofs (Reclaim) now delivered in Phase 4 - Use full gateway URL in proof.ts (no hardcoded path) - Remove chainapi.com references from test fixtures
1 parent bac5169 commit 048fb69

30 files changed

Lines changed: 663 additions & 68 deletions

CLAUDE.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ src/
2121
abi-encode.ts ABI encoding (0x01 + string[] name-value pairs)
2222
server.ts Bun.serve HTTP server (routes, CORS, rate limiting)
2323
pipeline.ts Request processing pipeline (auth → validate → cache → plugins → API call → encode → sign)
24-
auth.ts Client-facing request authentication (free / apiKey)
24+
proof.ts TLS proof gateway client (Reclaim)
25+
async.ts Async request store (mode: async)
26+
auth.ts Client-facing request authentication (free / apiKey / x402)
2527
cache.ts In-memory TTL response cache with periodic sweep
2628
sign.ts EIP-191 response signing and request ID derivation
2729
identity.ts DNS identity verification (ERC-7529) — public API
@@ -82,18 +84,20 @@ The pipeline runs per-request in `src/pipeline.ts`:
8284
8. **Plugin: onAfterApiCall** → plugins can modify the response
8385
9. **Encode** → if endpoint has `encoding`, ABI-encode using type/path/times via `src/api/process.ts`
8486
10. **Plugin: onBeforeSign** → plugins can modify encoded data before signing
85-
11. **Sign** → EIP-191 sign `keccak256(requestId || keccak256(data))` via `src/sign.ts`
86-
12. **Cache** → store response if cache config is present
87-
13. **Plugin: onResponseSent** → observation hook for logging/monitoring
87+
11. **Sign** → EIP-191 sign `keccak256(encodePacked(endpointId, timestamp, data))` via `src/sign.ts`
88+
12. **TLS proof** → if proof is enabled and endpoint has `responseMatches`, request a proof from the gateway (non-fatal)
89+
13. **Cache** → store response if cache config is present
90+
14. **Plugin: onResponseSent** → observation hook for logging/monitoring
8891

8992
### Config format
9093

9194
Version `'1.0'`. Top-level sections: `version`, `server`, `settings`, `apis`.
9295

9396
- `server` contains `port`, `host` (default `'0.0.0.0'`), `cors` (optional), `rateLimit` (optional).
94-
- `settings` contains `timeout` (default 10s), `proof` (`'none'` for Phase 1), `plugins`.
97+
- `settings` contains `timeout` (default 10s), `proof` (`'none'` or `{ type: 'reclaim', gatewayUrl }` for TLS proofs),
98+
`plugins`.
9599
- `apis[].url` is the upstream API base URL. Upstream credentials go in `apis[].headers`.
96-
- `apis[].auth` is client-facing: `{ type: 'free' }` or `{ type: 'apiKey', keys: [...] }`.
100+
- `apis[].auth` is client-facing: `{ type: 'free' }`, `{ type: 'apiKey', keys: [...] }`, or `{ type: 'x402', ... }`.
97101
- Endpoints use `encoding: { type, path, times? }` instead of `reservedParameters`. Encoding is optional — endpoints
98102
without it return raw JSON with a signature over the JSON hash.
99103
- Auth and cache config inherit from API level; endpoint-level overrides take precedence.

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Client ──POST──▶ Airnode ──HTTP──▶ Upstream API
2626

2727
```bash
2828
bun install
29-
bun airnode generate-mnemonic # prints private key + address
29+
bun airnode generate-mnemonic # prints mnemonic phrase + address
3030
cp examples/configs/minimal/config.yaml config.yaml
3131
cp examples/configs/minimal/.env.example .env
3232
# paste your private key into .env
@@ -103,7 +103,8 @@ apis:
103103
```
104104
105105
See [`examples/configs/complete/config.yaml`](examples/configs/complete/config.yaml) for auth methods, caching,
106-
multi-value encoding, and all available fields.
106+
multi-value encoding, and all available fields. See [`examples/configs/reclaim-proof/`](examples/configs/reclaim-proof/)
107+
for TLS proof configuration.
107108

108109
## Contracts
109110

@@ -156,14 +157,14 @@ src/
156157
config/ Zod schema, YAML parser, env interpolation
157158
api/ Upstream API calls and response processing
158159
server.ts Bun.serve HTTP server
159-
pipeline.ts Request pipeline (auth → validate → cache → API → encode → sign)
160+
pipeline.ts Request pipeline (auth → validate → cache → API → encode → sign → proof)
160161
auth.ts Authentication (free, apiKey, x402)
161162
sign.ts EIP-191 signing
162163
endpoint.ts Specification-bound endpoint ID derivation
163164
plugins.ts Plugin hooks and budget tracking
164165
contracts/ Solidity contracts and Foundry tests
165166
examples/
166-
configs/ Reference configs (complete, minimal)
167+
configs/ Reference configs (complete, minimal, reclaim-proof)
167168
plugins/ Example plugins (heartbeat, logger, slack-alerts)
168169
book/ Documentation site (Docusaurus)
169170
```

book/docs/concepts/architecture.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ CORS preflight (`OPTIONS`) is handled automatically. Rate limiting uses a token
2222

2323
## Request Processing Pipeline
2424

25-
Every `POST /endpoints/{endpointId}` request runs through a 13-step pipeline. Plugin hooks fire at defined points,
25+
Every `POST /endpoints/{endpointId}` request runs through a 14-step pipeline. Plugin hooks fire at defined points,
2626
giving plugins the ability to observe, filter, or modify data at each stage.
2727

2828
1. **Resolve endpoint** -- look up the endpoint by ID in the endpoint map. Returns 404 if not found.
@@ -42,8 +42,11 @@ giving plugins the ability to observe, filter, or modify data at each stage.
4242
10. **Plugin: onBeforeSign** -- plugins can modify the encoded data before signing.
4343
11. **Sign** -- EIP-191 personal sign over `keccak256(encodePacked(endpointId, timestamp, data))`. The signature proves
4444
the data came from this airnode at this time for this endpoint.
45-
12. **Cache** -- store the response if cache config is present. The `maxAge` field controls TTL.
46-
13. **Plugin: onResponseSent** -- observation hook for logging, monitoring, or heartbeats. Cannot modify the response.
45+
12. **TLS proof** -- if proof is enabled in settings and the endpoint has `responseMatches`, request a TLS proof from
46+
the proof gateway. Proof failures are non-fatal -- the response is returned without a proof. See
47+
[TLS Proofs](/docs/concepts/proofs).
48+
13. **Cache** -- store the response if cache config is present. The `maxAge` field controls TTL.
49+
14. **Plugin: onResponseSent** -- observation hook for logging, monitoring, or heartbeats. Cannot modify the response.
4750

4851
Error hooks (`onError`) fire when any stage fails, providing plugins with error context for alerting.
4952

@@ -64,6 +67,7 @@ Client Airnode Upstream API
6467
│ │ │
6568
│ ├── Encode (ABI or raw) │
6669
│ ├── Sign (EIP-191) │
70+
│ ├── TLS proof (optional) │
6771
│ │ │
6872
│◀────── signed response ─│ │
6973
│ │ │
@@ -84,8 +88,8 @@ signature = EIP-191 personal sign over hash
8488
The three fields (`endpointId`, `timestamp`, `data`) are packed separately -- not nested in another hash. This enables
8589
on-chain contracts to inspect each field independently for freshness checks and TLS proof verification.
8690

87-
For raw (unencoded) responses, `data` is the keccak256 hash of the JSON-serialized response. The full JSON is returned
88-
in the `rawData` field alongside the signature over its hash.
91+
For raw (unencoded) responses, `data` is the keccak256 hash of the stable-stringified (sorted-key) JSON response. The
92+
full JSON is returned in the `rawData` field alongside the signature over its hash.
8993

9094
## Startup
9195

book/docs/concepts/proofs.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
slug: /concepts/proofs
3+
sidebar_position: 5
4+
---
5+
6+
# TLS Proofs
7+
8+
TLS proofs provide cryptographic evidence that an API response came from a specific HTTPS endpoint. When enabled,
9+
Airnode attaches a proof to each response alongside the EIP-191 signature. The signature proves the airnode endorsed the
10+
data; the proof proves the data actually came from the upstream API over TLS.
11+
12+
## Why TLS proofs
13+
14+
Without a proof, you trust the airnode operator to faithfully relay the API response. The EIP-191 signature proves who
15+
signed the data, but not where the data came from. A malicious operator could fabricate responses.
16+
17+
TLS proofs close this gap. An independent attestor participates in the TLS session (via MPC-TLS) and produces a
18+
cryptographic attestation that the response came from the claimed HTTPS endpoint. The attestor never sees the full
19+
plaintext -- it only verifies the TLS transcript and checks that the response matches specified patterns.
20+
21+
## How it works
22+
23+
1. Airnode calls the upstream API and receives the JSON response (the normal pipeline).
24+
2. Airnode sends a proof request to the proof gateway with the full URL, HTTP method, headers, and `responseMatches`
25+
patterns.
26+
3. The proof gateway coordinates with an attestor to re-fetch the URL via MPC-TLS.
27+
4. The attestor verifies the TLS session and checks that the response matches the `responseMatches` regex patterns.
28+
5. The attestor signs a claim attesting to the match, and the proof is returned to Airnode.
29+
6. Airnode attaches the proof to the response.
30+
31+
## Configuration
32+
33+
Enable proofs globally in `settings` and configure `responseMatches` on each endpoint that should have proofs:
34+
35+
```yaml
36+
settings:
37+
proof:
38+
type: reclaim
39+
gatewayUrl: http://localhost:5177/v1/prove
40+
41+
apis:
42+
- name: CoinGecko
43+
url: https://api.coingecko.com/api/v3
44+
endpoints:
45+
- name: coinPrice
46+
path: /simple/price
47+
parameters:
48+
- name: ids
49+
in: query
50+
required: true
51+
- name: vs_currencies
52+
in: query
53+
default: usd
54+
encoding:
55+
type: int256
56+
path: $.ethereum.usd
57+
times: '1e18'
58+
responseMatches:
59+
- type: regex
60+
value: '"usd":\s*(?<price>[\d.]+)'
61+
```
62+
63+
The `gatewayUrl` must be the **full URL** to the proof endpoint. Airnode sends the request directly to this URL without
64+
appending any path.
65+
66+
## `responseMatches`
67+
68+
Each entry defines a regex pattern the attestor checks against the API response. The attestor only signs a claim if all
69+
patterns match. This ensures the proof covers specific data in the response, not just that some response was received.
70+
71+
```yaml
72+
responseMatches:
73+
- type: regex
74+
value: '"usd":\s*(?<price>[\d.]+)'
75+
```
76+
77+
| Field | Type | Required | Description |
78+
| ------- | -------- | -------- | --------------------------------------- |
79+
| `type` | `string` | Yes | Must be `'regex'`. |
80+
| `value` | `string` | Yes | Regex pattern to match in the response. |
81+
82+
Endpoints without `responseMatches` skip proof generation entirely, even when proof is enabled globally.
83+
84+
## Response format
85+
86+
When a proof is successfully generated, the response includes a `proof` field:
87+
88+
```json
89+
{
90+
"airnode": "0x...",
91+
"endpointId": "0x...",
92+
"timestamp": 1711234567,
93+
"data": "0x...",
94+
"signature": "0x...",
95+
"proof": {
96+
"claim": {
97+
"provider": "http",
98+
"parameters": "{...}",
99+
"context": "{...}",
100+
"owner": "0x...",
101+
"timestampS": 1711234567,
102+
"epoch": 1,
103+
"identifier": "0x..."
104+
},
105+
"signatures": {
106+
"attestorAddress": "0x7ff8f768be3c32132d395e888e44a6299e532604",
107+
"claimSignature": "0x..."
108+
}
109+
}
110+
}
111+
```
112+
113+
The `proof.signatures.attestorAddress` identifies the attestor that generated the proof. The `claimSignature` is a
114+
signature over the claim data that can be verified independently.
115+
116+
## Non-fatal behavior
117+
118+
Proof generation is **non-fatal**. If the proof gateway is unavailable, times out, or returns an error:
119+
120+
- The response is still returned without the `proof` field.
121+
- A `WARN` log is emitted with the failure reason.
122+
- The EIP-191 signature is unaffected.
123+
124+
This ensures that proof infrastructure issues do not block data delivery. Consumers that require proofs should check for
125+
the presence of the `proof` field and reject responses without it.
126+
127+
## URL construction
128+
129+
Airnode builds the full URL for the proof request by resolving all endpoint parameters (path, query, defaults, fixed
130+
values) against the API base URL. This ensures the attestor fetches the exact same URL that Airnode called. For example,
131+
an endpoint at `/simple/price` with query parameters `ids=ethereum&vs_currencies=usd` produces:
132+
133+
```
134+
https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd
135+
```
136+
137+
## Example config
138+
139+
See [`examples/configs/reclaim-proof/`](https://github.com/api3dao/airnode-v2/tree/main/examples/configs/reclaim-proof)
140+
for a minimal working configuration with TLS proofs enabled.

book/docs/concepts/request-response.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,18 @@ When the endpoint has `encoding` configured, the response contains ABI-encoded d
2626
"endpointId": "0xa1b2...endpoint-id-hash",
2727
"timestamp": 1711234567,
2828
"data": "0x00000000000000000000000000000000000000000000006c6b935b8bbd400000",
29-
"signature": "0x1234...65-byte-ecdsa-signature"
29+
"signature": "0x1234...65-byte-ecdsa-signature",
30+
"proof": { "...": "present when TLS proofs are enabled" }
3031
}
3132
```
3233

3334
The `data` field is the ABI-encoded value extracted from the API response at the configured `path`, multiplied by
3435
`times` if specified, and encoded as the configured `type` (e.g., `int256`).
3536

37+
The `proof` field is present only when [TLS proofs](/docs/concepts/proofs) are enabled and the endpoint has
38+
`responseMatches` configured. It contains the attestor's cryptographic attestation that the data came from the claimed
39+
API over TLS.
40+
3641
## Response (raw)
3742

3843
Endpoints without `encoding` return the full JSON response. The `data` field is replaced by `rawData`, and the signature
@@ -49,13 +54,14 @@ covers the keccak256 hash of the JSON:
4954
"usd_24h_vol": 12345678901.23
5055
}
5156
},
52-
"signature": "0x1234...65-byte-ecdsa-signature"
57+
"signature": "0x1234...65-byte-ecdsa-signature",
58+
"proof": { "...": "present when TLS proofs are enabled" }
5359
}
5460
```
5561

5662
Raw mode is useful when the consumer needs the full JSON structure or when multiple values from the same response are
5763
needed. The signature still proves the data came from this airnode, but the data itself is not ABI-encoded for on-chain
58-
use.
64+
use. The `proof` field appears when [TLS proofs](/docs/concepts/proofs) are enabled for the endpoint.
5965

6066
## Response (empty)
6167

book/docs/concepts/signing.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,16 @@ For endpoints without encoding, Airnode returns the full JSON in a `rawData` fie
4242
hash of the JSON-serialized response:
4343

4444
```
45-
dataHash = keccak256(toHex(JSON.stringify(rawData)))
45+
dataHash = keccak256(toHex(stableStringify(rawData)))
4646
hash = keccak256(encodePacked(endpointId, timestamp, dataHash))
4747
signature = EIP-191 personal sign over hash
4848
```
4949

50+
**Important:** `stableStringify` is not `JSON.stringify`. It produces deterministic output by sorting object keys
51+
alphabetically at every level. This ensures that `{ "b": 1, "a": 2 }` and `{ "a": 2, "b": 1 }` produce the same hash. If
52+
you verify a raw response signature, you must sort keys the same way. See the
53+
[verification example](#off-chain-verification) below.
54+
5055
The consumer receives both `rawData` (the full JSON) and `signature` (over the hash of that JSON). To verify, hash the
5156
raw data yourself and check it against the signature.
5257

@@ -80,12 +85,24 @@ const valid = await verifyMessage({
8085
console.log(valid); // true
8186
```
8287

83-
For raw responses, compute `dataHash` first:
88+
For raw responses, compute `dataHash` first. You must use stable (sorted-key) JSON serialization to match what the
89+
airnode produces:
8490

8591
```typescript
8692
import { keccak256, toHex } from 'viem';
8793

88-
const dataHash = keccak256(toHex(JSON.stringify(response.rawData)));
94+
// Stable stringify: sorts object keys alphabetically at every level
95+
function stableStringify(value: unknown): string {
96+
if (value === undefined) return 'null';
97+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
98+
if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(',')}]`;
99+
const sorted = Object.entries(value as Record<string, unknown>)
100+
.sort(([a], [b]) => a.localeCompare(b))
101+
.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
102+
return `{${sorted.join(',')}}`;
103+
}
104+
105+
const dataHash = keccak256(toHex(stableStringify(response.rawData)));
89106
const messageHash = keccak256(
90107
encodePacked(['bytes32', 'uint256', 'bytes'], [response.endpointId, BigInt(response.timestamp), dataHash])
91108
);

0 commit comments

Comments
 (0)