diff --git a/book/docs/cli.md b/book/docs/cli.md index d605119..1a11d43 100644 --- a/book/docs/cli.md +++ b/book/docs/cli.md @@ -67,14 +67,14 @@ No options. Outputs the mnemonic and address. ## `airnode address` -Derive and display the airnode address from the `AIRNODE_PRIVATE_KEY` environment variable. Useful for verifying which -address your node will use without starting it. +Derive and display the Airnode address from `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY`. This lets you check the address +without starting the server. ```bash airnode address ``` -No options. Requires `AIRNODE_PRIVATE_KEY` in the environment. +No options. At least one signing credential is required. If both are set, the mnemonic takes precedence. ## `airnode identity show` @@ -89,7 +89,7 @@ airnode identity show --domain [options] | `--domain ` | `-d` | -- | Domain name to associate | | `--chain-id ` | | `1` | Chain ID for the TXT record | -Requires `AIRNODE_PRIVATE_KEY` in the environment. +Requires `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY`. If both are set, the mnemonic takes precedence. ```bash airnode identity show --domain api.coingecko.com diff --git a/book/docs/concepts/architecture.md b/book/docs/concepts/architecture.md index 177a004..37e7e1b 100644 --- a/book/docs/concepts/architecture.md +++ b/book/docs/concepts/architecture.md @@ -48,7 +48,7 @@ giving plugins the ability to observe, filter, or modify data at each stage. 11. **Plugin: onBeforeSign** -- plugins can modify the encoded (or, for `encrypt` endpoints, encrypted) data before signing. 12. **Sign** -- EIP-191 personal sign over `keccak256(encodePacked(endpointId, timestamp, data))`. The signature proves - the data came from this airnode at this time for this endpoint. + the Airnode key signed this payload and timestamp for this endpoint. 13. **TLS proof** -- if proof is enabled in settings and the endpoint has `responseMatches`, request a TLS proof from the proof gateway. Proof failures are non-fatal -- the response is returned without a proof. See [TLS Proofs](/docs/concepts/proofs). @@ -96,8 +96,8 @@ raw-response hashing rules. When Airnode starts: -1. Load and validate `config.yaml` against the Zod schema. -2. Interpolate environment variables (`${VAR}` references in the config). +1. Load `config.yaml` and interpolate environment variables (`${VAR}` references). +2. Validate the resolved config against the Zod schema. 3. Derive the airnode address from `AIRNODE_PRIVATE_KEY` or `AIRNODE_MNEMONIC`. 4. Build the endpoint map: compute each endpoint ID and register it. 5. Load plugins from their `source` paths. diff --git a/book/docs/concepts/endpoint-ids.md b/book/docs/concepts/endpoint-ids.md index 29926fe..48c64ff 100644 --- a/book/docs/concepts/endpoint-ids.md +++ b/book/docs/concepts/endpoint-ids.md @@ -5,23 +5,23 @@ sidebar_position: 3 # Endpoint IDs -Endpoint IDs are deterministic hashes of the full API specification. They are not names, not UUIDs, not -auto-incrementing counters. The ID is derived from what the endpoint does -- its URL, path, method, parameters, and -encoding -- so the airnode's signature carries a commitment to exactly what was called and how the response was -interpreted. +An endpoint ID identifies a configured API operation. Airnode derives it from the parts of the configuration that affect +the upstream request or the meaning of the signed data. + +Changing one of those fields creates a new ID. Human-readable names, client authentication, caching, and other +operational settings do not affect it. ## Derivation -The endpoint ID is the keccak256 hash of a pipe-delimited canonical string. Parameters are represented as canonical JSON -objects so their location and semantics cannot collapse into the same ID: +Airnode hashes this canonical string with Keccak-256: -``` -endpointId = keccak256(url | path | method | sorted parameter JSON | encoding spec | encrypt spec) +```text +api URL | path | method | sorted parameter rules | encoding | encryption ``` -(The `encoding spec` and `encrypt spec` segments are only present when the endpoint configures them.) +The encoding and encryption segments are omitted when they are not configured. -Concretely, for an endpoint configured as: +For example: ```yaml apis: @@ -32,216 +32,122 @@ apis: path: /simple/price method: GET parameters: - - name: vs_currencies - in: query - fixed: usd - name: ids in: query required: true + - name: vs_currencies + in: query + fixed: usd encoding: type: int256 path: $.ethereum.usd times: '1e18' ``` -The canonical string is: +produces a canonical value like: -``` +```text https://api.coingecko.com/api/v3|/simple/price|GET|[{"name":"ids","in":"query","required":true,"secret":false},{"name":"vs_currencies","in":"query","required":false,"secret":false,"fixed":"usd"}]|type=int256,path=$.ethereum.usd,times=1e18 ``` -And the endpoint ID is `keccak256` of that string encoded as hex bytes. - -### Fixed vs. client-controlled encoding - -One upstream API usually serves many different consumers. A CoinGecko price endpoint can be projected as `int256 × 1e18` -for a lending protocol, as `uint128 × 1e8` for a DEX, or read for its `last_updated_at` timestamp by a staleness check. -These are legitimate, simultaneous uses of the same HTTP call. - -Airnode supports this by letting the operator decide **per field** whether to pin a concrete value or open it to the -client via the literal wildcard `'*'`. Wildcard fields are filled at request time via reserved parameters `_type`, -`_path`, and `_times` in the request body. The endpoint ID commits to the exact split — whatever the operator wrote -flows through to the canonical string verbatim, so a `'*'` in config means `*` in the ID. - -`type` and `path` are required whenever an `encoding` block is present. `times` is optional and only valid for numeric -types (`int256` / `uint256`). - -Three valid configurations: - -| Operator config | Encoding spec in ID | Who controls what | -| ------------------------------------------------------------------------------ | --------------------------------------------- | ----------------------------------------------------- | -| `encoding: { type: int256, path: $.price, times: '1e18' }` — fully pinned | `type=int256,path=$.price,times=1e18` | Fully operator-fixed | -| `encoding: { type: int256, path: '*', times: '1e18' }` — pin type & multiplier | `type=int256,path=*,times=1e18` | Operator fixes type & multiplier; client chooses path | -| `encoding: { type: '*', path: '*', times: '*' }` — all wildcards (fully open) | `type=*,path=*,times=*` | Client fully controls encoding | -| No `encoding` block at all | (encoding spec omitted from canonical string) | Endpoint returns raw-JSON-hash responses only | - -Client-supplied fields are **silently ignored** for any field the operator pinned. If the operator sets `type: int256`, -the request's `_type` parameter has no effect on encoding (it's still consumed by the pipeline and never sent to the -upstream API). Wildcard fields require the matching reserved parameter: omitting `_path` on an endpoint with `path: '*'` -returns 400. - -### FHE-encrypted endpoints - -An endpoint with an [`encrypt`](/docs/concepts/fhe-encryption) block appends an encryption spec to the canonical string: - -``` -fhe=euint256,contract=0x5fbdb2315678afecb367f032d93f642f64180aa3 -``` - -So the endpoint ID commits to the ciphertext type and the consumer contract the encrypted input is bound to. The -`encrypt.contract` value is always operator-fixed — there is no requester-controlled variant — and the relayer/verifier -settings (`settings.fhe`) are operational config, so they are _not_ part of the ID. - -### Why this design - -The two obvious alternatives both fail. - -**"Force operators to fully fix every projection."** This sounds safer, but it turns the operator into a gatekeeper for -every consumer-side design change. Each new downstream use case (new type, new JSON path, new multiplier) would require -an operator config push, a new endpoint ID, and coordination across teams that have no business reason to coordinate. In -practice, operators would either (a) refuse to add endpoints, killing adoption, or (b) add every imaginable projection -upfront, which is neither maintainable nor knowable in advance. - -**"Leave encoding fully unbound and stop including it in the ID."** This is what v1 effectively did. The endpoint ID -becomes a loose identifier of "which upstream was called," and the signature over `(endpointId, timestamp, data)` -carries no guarantee about what `data` means. On-chain consumers then need out-of-band schema agreements to interpret -the bytes safely — which reintroduces the registry and coordination problems that specification-bound IDs were -introduced to solve. - -**The middle ground.** The endpoint ID commits to the _contract_ between operator and consumer: which fields the -operator stands behind, and which fields the submitter is trusted to choose. A consumer contract hard-coding a specific -endpoint ID implicitly accepts exactly that trust split: +Parameter rules are sorted by name and location before hashing. -- `keccak256(...|type=int256,path=$.price,times=1e18)` — the consumer is trusting only the operator. The submitter - cannot influence what the bytes mean. -- `keccak256(...|type=int256,path=*,times=1e18)` — the consumer is trusting the operator for type & multiplier, and - trusting the submitter to pick a meaningful JSON path. This is a weaker guarantee and should be used deliberately. -- `keccak256(...|type=*,path=*,times=*)` — the consumer is trusting the submitter for everything about the projection. - Only reasonable in contexts where the submitter is the consumer itself (they sign the transaction that submits, so - they're only lying to themselves). +## Included fields -If the operator later widens or narrows an endpoint (e.g. removes the fixed `type`), the endpoint ID changes and any -consumer hard-coding the old ID stops matching new signatures — exactly the behavior you want. The operator cannot -silently alter the trust split of an existing endpoint. +The ID includes: -**Security properties this gives you:** +- API URL +- endpoint path and method +- each parameter's name, location, required flag, and secret marker +- non-secret fixed and default parameter values +- encoding type, JSON path, and multiplier +- FHE ciphertext type and consumer contract, when encryption is configured -1. **Clients cannot widen an endpoint.** `_type`/`_path`/`_times` only fill fields the operator left open; they cannot - override fixed values. A malicious submitter cannot turn a fully-fixed `type=int256,path=$.price` endpoint into - something that projects volume or timestamp instead. -2. **Consumers explicitly opt into any flexibility.** By hard-coding a specific ID, a consumer is accepting the exact - encoding contract baked into that ID. A consumer who wants no submitter-side flexibility simply refuses to recognize - any ID whose encoding spec contains `*`. -3. **Operators cannot silently rewire an endpoint.** Any change to the fixed-vs-wildcard split changes the ID. Existing - consumers hard-coding the old ID stop accepting signatures the moment the operator widens or narrows the endpoint. +This means changing the URL, moving a parameter from a query string to a header, or changing the response encoding +creates a new ID. -### Endpoints with no `encoding` block +## Excluded fields -An endpoint with no `encoding` block in config does not include an encoding spec in the canonical string. Its signature -covers `keccak256(json_hash)` of the raw upstream response. Reserved request parameters cannot synthesize an encoding -out of nothing — `_type` / `_path` / `_times` are ignored in raw mode, so the only way to ABI-encode a response is for -the operator to declare an `encoding` block (pinned or wildcarded). +The ID does not include: -If a consumer contract wants the endpoint ID to bind some encoding shape, the operator should declare an `encoding` -block with the appropriate pin/wildcard split. An endpoint without any `encoding` block should be treated as -raw-JSON-only from a consumer perspective. +- API or endpoint names +- upstream headers +- secret parameter values +- client authentication +- timeouts and caching +- response mode +- FHE network and relayer settings -## What Is Included +These fields are operational or secret. For example, rotating an upstream API key should not change the endpoint ID. -These fields are part of the hash: +## Secret parameters -| Field | Example | Why | -| ------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `api.url` | `https://api.coingecko.com/api/v3` | Different APIs produce different endpoint IDs | -| `endpoint.path` | `/simple/price` | Different paths on the same API are different endpoints | -| `endpoint.method` | `GET` | A GET and POST to the same path are different operations | -| Parameter semantics | name, location, required/default/fixed and secret marker | Parameters define the generated request | -| `encoding.type` | `int256` or `*` | Different encodings of the same data produce different outputs | -| `encoding.path` | `$.ethereum.usd` or `*` | Extracting different fields produces different data | -| `encoding.times` | `1e18` or `*` | Different multipliers produce different values | -| `encrypt.type` | `euint256` (if `encrypt` is set) | The FHE ciphertext type changes the response shape | -| `encrypt.contract` | `0x5fbdb2…` (if `encrypt` is set) | The encrypted input is bound to this consumer contract | +A parameter marked `secret: true` remains represented in the parameter list, but its value is omitted. Airnode also +treats a fixed `${ENV_VAR}` value as secret. -### Parameter rules +Adding or removing a secret parameter changes the ID. Changing only its secret value does not. -Parameters are sorted by name and location. Each entry commits to `name`, `in`, `required`, whether it is secret, and -any non-secret `default` or `fixed` value. Secret parameter values are omitted, but the parameter itself remains -represented. Rotating a secret therefore preserves the ID, while adding or removing a secret header changes it. +## Fixed and client-selected encoding -## What Is Excluded +An encoding field can be fixed by the operator or set to `'*'` so the client supplies it at request time. -These fields do not affect the endpoint ID: - -| Field | Why excluded | -| ------------------------------ | ------------------------------------------------------------------ | -| `endpoint.name` | Names are for human readability, not identity | -| `api.headers` | Headers often contain secrets (API keys, auth tokens) | -| `api.auth` | Client-facing auth is an operator choice, not a data specification | -| `api.timeout` | Operational config, not data specification | -| `api.cache` / `endpoint.cache` | Caching is an optimization, not a data property | -| `endpoint.mode` | `sync` / `async` / `stream` is a delivery choice, not a data spec | -| `endpoint.auth` | Endpoint-level client auth override, like `api.auth` | -| Secret parameter values | Credentials rotate without changing the public endpoint contract | +```yaml +# Fully fixed +encoding: + type: int256 + path: $.price + times: '1e18' + +# Client chooses only the path +encoding: + type: int256 + path: '*' + times: '1e18' +``` -## Why This Design +The endpoint ID includes either the fixed value or the literal `*`. A consumer that accepts an ID containing a wildcard +is also accepting that the requester controls that part of the encoding. -### Commitment to the API specification +Clients fill wildcard fields with reserved request parameters: -Airnode is built for the first-party oracle model: the API provider runs the airnode that serves their own API. The -endpoint ID turns that arrangement into a cryptographic commitment. A consumer contract hard-coding an endpoint ID binds -itself to the specific URL, path, method, parameters, and encoding rules the provider declared in config. +| Config value | Request parameter | +| ------------ | ----------------- | +| `type: '*'` | `_type` | +| `path: '*'` | `_path` | +| `times: '*'` | `_times` | -If the provider later changes any part of the spec — redirects to a different upstream, renames a parameter, tweaks the -encoding — the endpoint ID changes and existing signatures no longer match what the consumer expected. The consumer -immediately stops accepting data under the old ID. There is no silent re-pointing. +Client values cannot override fixed encoding fields. If the endpoint has no `encoding` block, these reserved parameters +do not enable encoding; Airnode returns signed raw JSON instead. -The same property holds in reverse: if you recompute the endpoint ID from a published config and it matches the ID you -had already integrated against, you know the airnode is serving exactly the spec you committed to. +## FHE encryption -### Aggregation across providers +An encrypted endpoint adds this shape to the ID: -Different API providers each run their own airnode for their own API. A consumer can aggregate signed data from several -first-party airnodes — for instance, combining BTC/USD prices from multiple exchanges — by collecting signatures across -those distinct endpoint IDs. Each airnode's signature is independently verifiable, and the aggregation happens at the -consumer's side with no coordination layer or shared registry. +```text +fhe=euint256,contract=0x... +``` -### TLS proof verification +The consumer contract and ciphertext type therefore affect the ID. Relayer and network settings do not. -The canonical string used to derive the endpoint ID matches the information that would be present in a TLS proof of the -HTTP request. A future verifier can check that: +## What the ID proves -1. The API URL and path in the TLS proof match the endpoint specification. -2. The query parameters in the TLS proof match the non-secret parameters. -3. The endpoint ID hash is consistent with the observed request. +The ID lets a consumer recompute the hash from a published configuration and detect changes to that specification. -This is why secret parameters are excluded -- they would appear in the TLS transcript but should not be part of the -public identity. +It does not prove that: -### No registry +- the published configuration is the one currently running +- the operator called the configured API for a particular response +- the upstream API returned the signed value -Endpoint IDs do not require registration, coordination, or a central authority. An operator derives the ID locally from -the config, publishes it alongside their endpoint, and consumers integrate against it directly. +The operator's signature still carries those trust assumptions. A separate [TLS proof](/docs/concepts/proofs) can add +evidence about a gateway's HTTPS request and response matching, subject to the limits described on that page. -## Computing an Endpoint ID +## Print endpoint IDs -The CLI prints endpoint IDs for every endpoint when you validate a config: +Validate a config to print every derived endpoint ID: ```bash airnode config validate -c config.yaml ``` -You can also derive the ID programmatically: - -```typescript -import { keccak256, toHex } from 'viem'; - -const canonical = [ - 'https://api.coingecko.com/api/v3', - '/simple/price', - 'GET', - 'ids,vs_currencies=usd', - 'type=int256,path=$.ethereum.usd,times=1e18', -].join('|'); - -const endpointId = keccak256(toHex(canonical)); -``` +The server also prints registered IDs during startup. diff --git a/book/docs/concepts/fhe-encryption.md b/book/docs/concepts/fhe-encryption.md index e472ec5..17d56c6 100644 --- a/book/docs/concepts/fhe-encryption.md +++ b/book/docs/concepts/fhe-encryption.md @@ -37,8 +37,8 @@ signing: [@zama-fhe/relayer-sdk](https://docs.zama.org/protocol/relayer-sdk-guides/fhevm-relayer) SDK, producing an encrypted-input handle and a zero-knowledge proof. 3. It replaces the `data` field with `abi.encode(bytes32 handle, bytes inputProof)`. -4. Airnode signs the ciphertext — the EIP-191 signature proves the encrypted data is authentically from the API - provider. (`onBeforeSign` plugins also see the ciphertext, not the plaintext.) +4. Airnode signs the ciphertext. The EIP-191 signature identifies the signing key; provider identity must be established + separately. (`onBeforeSign` plugins also see the ciphertext, not the plaintext.) 5. The client submits the signed response to `AirnodeVerifier` on-chain (the existing contract — no changes needed). 6. `AirnodeVerifier` verifies the signature and forwards the bytes to the consumer's callback, which unpacks the data, registers the FHE handle via `FHE.fromExternal(handle, inputProof)`, and manages its own decryption access control. @@ -165,7 +165,7 @@ euint256 price = FHE.fromExternal(handle, inputProof); ## Example consumer contract An example `ConfidentialPriceFeed` contract is provided in -[`contracts/src/examples/`](https://github.com/api3dao/airnode-v2/tree/main/contracts/src/examples). It demonstrates: +[`contracts/src/examples/`](https://github.com/andreogle/airnode-v2/tree/main/contracts/src/examples). It demonstrates: - Receiving encrypted price data via the `AirnodeVerifier` callback pattern - Registering FHE handles with the coprocessor diff --git a/book/docs/concepts/proofs.md b/book/docs/concepts/proofs.md index c79ee9e..c083840 100644 --- a/book/docs/concepts/proofs.md +++ b/book/docs/concepts/proofs.md @@ -161,5 +161,6 @@ https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd ## Example config -See [`examples/configs/reclaim-proof/`](https://github.com/api3dao/airnode-v2/tree/main/examples/configs/reclaim-proof) +See +[`examples/configs/reclaim-proof/`](https://github.com/andreogle/airnode-v2/tree/main/examples/configs/reclaim-proof) for a minimal working configuration with TLS proofs enabled. diff --git a/book/docs/concepts/request-response.md b/book/docs/concepts/request-response.md index 7e3a5c6..7b53a18 100644 --- a/book/docs/concepts/request-response.md +++ b/book/docs/concepts/request-response.md @@ -36,8 +36,8 @@ The `data` field is the ABI-encoded value extracted from the API response at the `times` if specified, and encoded as the configured `type` (e.g., `int256`). The `proof` field is present only when [TLS proofs](/docs/concepts/proofs) are enabled and the endpoint has -`responseMatches` configured. It contains the attestor's cryptographic attestation that the data came from the claimed -API over TLS. +`responseMatches` configured. It attests that the gateway's separate HTTPS response matched configured patterns. It does +not prove that Airnode signed the same response. ## Response (raw) @@ -61,7 +61,7 @@ covers the keccak256 hash of the JSON: ``` Raw mode is useful when the consumer needs the full JSON structure or when multiple values from the same response are -needed. The signature still proves the data came from this airnode, but the data itself is not ABI-encoded for on-chain +needed. The signature identifies the Airnode key that signed the response, but the data is not ABI-encoded for on-chain use. The `proof` field appears when [TLS proofs](/docs/concepts/proofs) are enabled for the endpoint. ## Response (empty) diff --git a/book/docs/concepts/signing.md b/book/docs/concepts/signing.md index 8198a10..874389d 100644 --- a/book/docs/concepts/signing.md +++ b/book/docs/concepts/signing.md @@ -5,9 +5,9 @@ sidebar_position: 4 # Signing and Verification -Every response from Airnode is signed with the operator's private key using EIP-191. The signature proves that a -specific airnode produced specific data for a specific endpoint at a specific time. Consumers can verify the signature -off-chain or submit it to an on-chain contract for verification. +Every response from Airnode is signed with the operator's private key using EIP-191. The signature proves that the key +holder signed specific data for a specific endpoint and timestamp. It does not prove who controls the key or where the +data came from. Consumers can verify the signature off-chain or submit it to an on-chain contract. ## Signature Format @@ -29,8 +29,8 @@ The three fields are ABI-packed with their types: The `endpointId`, `timestamp`, and `data` are packed as separate fields -- not nested inside another hash. This is a deliberate design choice: -- **On-chain contracts** can decode the packed data and inspect each field independently. A freshness check can reject - data with a stale timestamp. An endpoint filter can reject data for an unexpected endpoint. +- **On-chain contracts** receive each field separately and can inspect it before using the data. A consumer can reject a + stale timestamp or an unexpected endpoint ID. - **TLS proof verification** can match the endpoint ID against the observed HTTP request without needing to reconstruct a nested hash structure. - **Simplicity** -- the signed message is a single `keccak256(encodePacked(...))`, which maps directly to how Solidity diff --git a/book/docs/config/apis.md b/book/docs/config/apis.md index a23a5b7..fb445a6 100644 --- a/book/docs/config/apis.md +++ b/book/docs/config/apis.md @@ -111,9 +111,8 @@ uniqueness key — it can be redeemed exactly once. `expiresAt` must be a future unix-seconds timestamp no further ahead than 10 minutes; longer-lived proofs are rejected. -Submitted proofs are additionally rate-limited **per client IP** before the airnode touches the chain RPC — currently 30 -attempts per minute per IP. The unpaid 402 challenge path is unaffected. This is independent of `server.rateLimit` and -keeps an unauthenticated flooder from draining the operator's RPC quota by spamming bogus proofs. +Submitted proofs use the separate `server.rateLimit.x402` per-IP limit before Airnode calls the chain RPC. The unpaid +402 challenge path does not use this bucket. ### Multiple auth methods @@ -155,10 +154,9 @@ caller (and every on-chain submission) gets the identical signed payload. :::warning On-chain race within the cache window -Because every caller in a TTL window receives the **byte-identical** signed response, only the first on-chain submission -through `AirnodeVerifier` succeeds. The verifier's `fulfilled[]` mapping treats subsequent submissions of the same -`(endpointId, timestamp, data)` as replays and reverts with `"Already fulfilled"` — the second and third callers pay gas -for a failed transaction. +Because every caller in a TTL window receives the **byte-identical** signed response, only the first delivery to a given +callback and selector succeeds. Repeating the same delivery reverts with `"Already fulfilled"`, so later callers may pay +gas for a failed transaction. For endpoints whose consumers race to submit on-chain (price feeds, single-fulfillment auctions), set a short `maxAge` (e.g. 1000ms) or omit `cache` entirely so each caller gets a fresh signature. Caching is most useful for off-chain @@ -265,15 +263,15 @@ In short: only `body` parameters may be nested; everything else should be a prim ### Parameter fields -| Field | Type | Required | Default | Description | -| ------------- | ----------------------------- | -------- | ------- | -------------------------------------------------------------- | -| `name` | `string` | Yes | -- | Parameter name as the upstream API expects it. | -| `in` | `string` | No | `query` | Where to send: `query`, `header`, `path`, `cookie`, or `body`. | -| `required` | `boolean` | No | `false` | If `true`, the client must provide this parameter. | -| `fixed` | `string \| number \| boolean` | No | -- | Hardcoded value. Always overrides the client's value. | -| `default` | `string \| number \| boolean` | No | -- | Fallback value when the client does not provide one. | -| `secret` | `boolean` | No | `false` | If `true`, excluded from endpoint ID derivation. | -| `description` | `string` | No | -- | Human-readable description. | +| Field | Type | Required | Default | Description | +| ------------- | ----------------------------- | -------- | ------- | ----------------------------------------------------------------------- | +| `name` | `string` | Yes | -- | Parameter name as the upstream API expects it. | +| `in` | `string` | No | `query` | Where to send: `query`, `header`, `path`, `cookie`, or `body`. | +| `required` | `boolean` | No | `false` | If `true`, the client must provide this parameter. | +| `fixed` | `string \| number \| boolean` | No | -- | Hardcoded value. Always overrides the client's value. | +| `default` | `string \| number \| boolean` | No | -- | Fallback value when the client does not provide one. | +| `secret` | `boolean` | No | `false` | Omits the parameter value, but not the parameter, from the endpoint ID. | +| `description` | `string` | No | -- | Human-readable description. | ### Fixed vs default @@ -295,8 +293,8 @@ optional — there is always a fallback value. The schema validator rejects this ### Secret parameters -Parameters marked `secret: true` are excluded from endpoint ID derivation. This means changing a secret parameter does -not change the endpoint ID. +Parameters marked `secret: true` remain part of the endpoint specification, but their values are excluded. Changing the +secret value does not change the endpoint ID. Adding, removing, or renaming the parameter does. Parameters with `fixed` values that use `${VAR}` interpolation are also treated as secret automatically. diff --git a/book/docs/config/index.md b/book/docs/config/index.md index c8d2fa1..bb07811 100644 --- a/book/docs/config/index.md +++ b/book/docs/config/index.md @@ -12,7 +12,7 @@ Airnode is configured with a single YAML file. JSON is also accepted. The file h | ----------------------------------- | ------------------------------------------------------- | | `version` | Must be `'1.0'`. Used for schema validation. | | [`server`](/docs/config/server) | HTTP server port, host, CORS, and rate limiting. | -| [`settings`](/docs/config/settings) | Timeout, upstream concurrency, proof, FHE, and plugins. | +| [`settings`](/docs/config/settings) | Upstream concurrency, proof, FHE, and plugins. | | [`apis`](/docs/config/apis) | Upstream API definitions with endpoints and parameters. | ## Minimal config @@ -22,7 +22,9 @@ version: '1.0' server: port: 3000 + host: '0.0.0.0' rateLimit: + trustForwardedFor: false window: 60000 max: 100 x402: @@ -56,6 +58,7 @@ server: origins: - '*' rateLimit: + trustForwardedFor: false window: 60000 max: 100 x402: @@ -63,7 +66,6 @@ server: max: 30 settings: - timeout: 15000 maxConcurrentApiCalls: 50 proof: none # or: { type: reclaim, gatewayUrl: 'http://localhost:5177/v1/prove' } plugins: diff --git a/book/docs/config/plugins.md b/book/docs/config/plugins.md index d5f2b02..802a250 100644 --- a/book/docs/config/plugins.md +++ b/book/docs/config/plugins.md @@ -92,7 +92,7 @@ hook from `onBeforeApiCall` onward: - `onBeforeApiCall`, `onAfterApiCall`, `onBeforeSign` — **only fire on the first request in each cache window**. Cached hits within the TTL never invoke them. -- `onHttpRequest`, `onResponseSent`, `onError` — fire on every request, cached or not. +- `onHttpRequest` and `onResponseSent` fire for cached and uncached requests. `onError` fires only when a request fails. If your plugin tracks per-request signal (heartbeats, counters, alerting), put that work in `onResponseSent` or `onHttpRequest`. Don't rely on the mutation hooks to run once per HTTP request — they run once per upstream API call, diff --git a/book/docs/consumers/getting-started.md b/book/docs/consumers/getting-started.md index bf01330..1da3944 100644 --- a/book/docs/consumers/getting-started.md +++ b/book/docs/consumers/getting-started.md @@ -143,14 +143,13 @@ const ethPrice = rawData.ethereum.usd; // 3842.17 At this point you have: -- A verified `airnode` address (proves who signed it). +- An Airnode address from a trusted provider-controlled source. - A decoded `value` (the price, the temperature, whatever the endpoint serves). - A `timestamp` (so you can decide if the value is fresh enough for your use case). -That's the full off-chain consumer loop. Use the value in your app, run your own staleness check -(`Date.now() / 1000 - timestamp < maxAgeSeconds`), and store or forward the signed payload if you want to prove -provenance to a downstream system later — `(airnode, endpointId, timestamp, data, signature)` is a self-contained -attestation that anyone can re-verify. +That's the full off-chain consumer loop. Use the value in your app and apply your own staleness check +(`Date.now() / 1000 - timestamp < maxAgeSeconds`). You can store the signed payload for later verification, but anyone +checking it still needs an independent reason to trust the signer and data source. ## Step 7: Submit on-chain (optional) @@ -194,7 +193,7 @@ out of nothing. | `400` | ``Endpoint requires `_type` request parameter`` | The operator marked `type: '*'`. Supply `_type` in `parameters`. | | `400` | ``Endpoint requires `_path` request parameter`` | The operator marked `path: '*'`. Supply `_path` in `parameters`. | | `400` | ``Endpoint requires `_times` request parameter`` | The operator marked `times: '*'`. Supply `_times` in `parameters`. | -| `401` | `Missing X-Api-Key header` | The endpoint requires authentication. Add `X-Api-Key: your-key` to the request. | +| `401` | `Missing X-Api-Key header` | The endpoint requires authentication. Add the `X-Api-Key` header. | | `401` | `Invalid API key` | The key value is wrong. Check with the airnode operator. | | `404` | `Endpoint not found` | The endpoint ID is incorrect. Verify the ID with the operator. | | `413` | `Request body too large` | The request body exceeds 64KB. Reduce the payload size. | diff --git a/book/docs/consumers/on-chain.md b/book/docs/consumers/on-chain.md index 8c8aa90..6687188 100644 --- a/book/docs/consumers/on-chain.md +++ b/book/docs/consumers/on-chain.md @@ -25,7 +25,7 @@ AirnodeVerifier contract, which verifies the signature and forwards the data to Your contract receives the callback. `verifyAndFulfill` is **permissionless** (anyone can submit any valid Airnode-signed payload and point the callback anywhere) and signed payloads are **public**, so your `fulfill` must run four checks before trusting the data. A documented reference is -[`AirnodePriceConsumer.sol`](https://github.com/api3dao/airnode-v2/blob/main/contracts/src/examples/AirnodePriceConsumer.sol); +[`AirnodePriceConsumer.sol`](https://github.com/andreogle/airnode-v2/blob/main/contracts/src/examples/AirnodePriceConsumer.sol); the essentials: ```solidity diff --git a/book/docs/contracts/overview.md b/book/docs/contracts/overview.md index f9d9c48..40679f1 100644 --- a/book/docs/contracts/overview.md +++ b/book/docs/contracts/overview.md @@ -38,7 +38,7 @@ The fields it commits to: - **endpointId** -- a specification-bound hash committing to the API URL, path, method, parameters, and encoding rules. Two independent airnodes serving the same API with the same config produce the same endpoint ID. -- **timestamp** -- unix timestamp (seconds) of when the data was produced. +- **timestamp** -- Unix timestamp (seconds) of when Airnode signed the data. - **data** -- the signed payload: ABI-encoded value, an FHE ciphertext, or `keccak256` of the raw JSON. The contract treats it as opaque `bytes` and forwards it to the callback unchanged. (Airnode caps the HTTP _request_ body at 64 KB, but the contract imposes no size limit on `data`.) @@ -57,5 +57,5 @@ is implemented inline for auditability and to minimize the attack surface. ### Flat architecture -A standalone contract with no shared state, no inheritance chain, and no external dependencies. It can be deployed and -used independently. +A standalone contract with no admin or configuration state, no inheritance chain, and no external dependencies. It keeps +only replay-delivery state and can be deployed independently. diff --git a/book/docs/contracts/verifier.md b/book/docs/contracts/verifier.md index 4b66f3c..2bf9b8e 100644 --- a/book/docs/contracts/verifier.md +++ b/book/docs/contracts/verifier.md @@ -12,8 +12,8 @@ pull path -- a client gets signed data from the HTTP server and submits it to tr 1. Anyone calls `verifyAndFulfill()` with signed data and a callback target. 2. The contract recovers the signer from the signature. -3. If the signer matches the provided airnode address, and the data hasn't been submitted before (replay protection), - the data is forwarded to the callback contract. +3. If the signer matches the provided Airnode address and this exact callback delivery has not run before, the data is + forwarded to the callback contract. 4. If the callback reverts, the fulfillment is still recorded. This prevents griefing where a callback intentionally reverts to block fulfillment. @@ -37,19 +37,21 @@ The callback receives five arguments: ```solidity function fulfill( - bytes32 requestHash, // keccak256(endpointId, timestamp, data) -- unique per submission + bytes32 requestHash, // keccak256(endpointId, timestamp, data) -- identifies the signed payload address airnode, // the signer's address bytes32 endpointId, // which API endpoint produced this data - uint256 timestamp, // when the data was produced + uint256 timestamp, // when Airnode signed the data bytes calldata data // the ABI-encoded response ) external; ``` ## Replay protection -The `requestHash` is the `messageHash` from the signature. Replay protection is scoped by signer, so each unique -`(airnode, endpointId, timestamp, data)` combination can only be fulfilled once while independent airnodes can attest -the same payload. The nested `fulfilled(airnode, requestHash)` mapping is public. +The `requestHash` is the message hash from the signature. Replay protection is scoped to the signer, payload, callback +address, and callback selector. The same signed payload can be delivered once to each distinct callback target. + +The public `fulfilled(airnode, requestHash)` mapping records whether a signer and payload have been delivered anywhere. +The contract uses a separate `fulfilledDelivery` mapping to prevent duplicate delivery to the same callback. ## Trust model @@ -62,26 +64,37 @@ the same payload. The nested `fulfilled(airnode, requestHash)` mapping is public ## Consumer contract example -Your contract receives the callback and decides what to do with the data. At minimum, check that you trust the airnode: +Your callback is public, so checking only the Airnode address is unsafe. At minimum, verify the caller, signer, +endpoint, and timestamp before decoding data: ```solidity contract MyConsumer { - address public trustedAirnode; + address public immutable verifier; + address public immutable trustedAirnode; + bytes32 public immutable trustedEndpointId; int256 public lastPrice; - constructor(address _airnode) { + constructor(address _verifier, address _airnode, bytes32 _endpointId) { + verifier = _verifier; trustedAirnode = _airnode; + trustedEndpointId = _endpointId; } function fulfill( bytes32, // requestHash (unused here) address airnode, - bytes32, // endpointId (unused here) - uint256, // timestamp (unused here) + bytes32 endpointId, + uint256 timestamp, bytes calldata data ) external { + require(msg.sender == verifier, 'Untrusted verifier'); require(airnode == trustedAirnode, 'Untrusted airnode'); + require(endpointId == trustedEndpointId, 'Unexpected endpoint'); + require(timestamp <= block.timestamp && block.timestamp - timestamp <= 5 minutes, 'Stale timestamp'); lastPrice = abi.decode(data, (int256)); } } ``` + +Production consumers should also decide how to handle repeated or out-of-order updates. See +[On-chain integration](/docs/consumers/on-chain) for a complete example. diff --git a/book/docs/guides/system-overview.md b/book/docs/guides/system-overview.md index 0564e0f..7040861 100644 --- a/book/docs/guides/system-overview.md +++ b/book/docs/guides/system-overview.md @@ -52,7 +52,8 @@ This is enough when: - One API provider operates one airnode. - Clients connect directly to the airnode (no aggregation needed). -No external infrastructure required. The airnode is the entire backend. +No coordinator or chain-watching service is required. Airnode still depends on its upstream API and normal hosting, DNS, +and key-management infrastructure. ## Multi-operator setup diff --git a/book/docs/introduction.md b/book/docs/introduction.md index 3b7e390..77ca9a1 100644 --- a/book/docs/introduction.md +++ b/book/docs/introduction.md @@ -5,121 +5,102 @@ sidebar_position: 1 # Airnode v2 -Airnode is an HTTP server that receives requests from clients, calls upstream APIs, signs the responses with the API -provider's private key (EIP-191), and returns the signed data. Clients can verify the signature off-chain or submit the -signed response on-chain. There is no chain scanning, no coordinator cycle, no database -- Airnode is a stateless HTTP -service that API providers run alongside their existing APIs. - -## Why Run an Airnode - -If you operate an API, running an Airnode lets you serve your data to smart contracts and crypto-native clients without -changing your existing infrastructure. Your API stays exactly as it is — Airnode sits in front of it as a signing proxy. - -- **Monetize your API on-chain.** Smart contracts can't call HTTP APIs directly. Airnode bridges this gap. Developers - pay per-request via API keys or x402 HTTP-native payments — you choose the model. -- **No blockchain expertise required.** You don't run a node, manage wallets, or write smart contracts. Airnode is a - stateless HTTP server that signs responses. The on-chain verification contract is already deployed. -- **Your data, your key.** Every response is signed with your private key, creating a verifiable attestation: "this API - provider, at this time, received this data from this API." Your reputation is tied to your signature. -- **Zero infrastructure change.** Airnode calls your existing API endpoints. You don't need to modify your API, add new - routes, or change your data format. Point Airnode at your API URL and configure which endpoints to serve. - -## Why Request Data from an Airnode - -If you're building a smart contract, dApp, or AI agent that needs real-world data, Airnode provides it with -cryptographic guarantees. - -- **First-party data.** The API provider runs the Airnode and signs the data directly. No intermediary chain of oracles - repackaging data — the signature traces back to the source. Verify that the airnode is operated by the API provider - using [DNS identity verification](/docs/security/identity-verification) (ERC-7529) before trusting its data. -- **Verifiable off-chain and on-chain.** Every response includes an EIP-191 signature. Verify it locally in your - application or submit it to an on-chain verifier contract. The same signature works in both contexts. -- **Standard HTTP interface.** No proprietary SDKs or oracle-specific protocols. Send a `POST` request, get signed JSON - back. Any HTTP client works — `curl`, `fetch`, Axios, or your smart contract's off-chain component. -- **Flexible encoding.** Get raw JSON for off-chain use, or ABI-encoded data ready for on-chain submission. You can even - choose the encoding at request time with `_type`, `_path`, and `_times` parameters. -- **Multiple access models.** Free endpoints for public data, API keys for authenticated access, or pay-per-request via - x402. Use whatever fits your use case. -- **Aggregation across providers.** Each API provider runs their own airnode for their own API. Consumers can aggregate - signed data from several first-party airnodes — for example, combining BTC/USD from multiple exchanges into an - on-chain average or median — without any coordination layer in between. - -## Core Flow - -Every request follows the same path: +Airnode is an HTTP server for API providers. It receives a request, calls an upstream API, and signs the response with +an EIP-191 key. A client can verify the signature off-chain or submit it to a verifier contract. -``` -Client ──POST──▶ Airnode ──HTTP──▶ Upstream API - │ │ - │◀───JSON response───┘ - │ - ├─ Encode (ABI or raw JSON) - ├─ Encrypt (FHE — optional) - ├─ Sign (EIP-191) - ├─ TLS proof (Reclaim — optional) - │ - ▼ - Signed response -``` +Airnode does not scan blockchains or submit fulfillment transactions. The operator runs it next to an existing API and +remains responsible for the API, the Airnode service, and the signing key. -1. A client sends a `POST /endpoints/{endpointId}` request with parameters. -2. Airnode resolves the endpoint, authenticates the client, and validates parameters. -3. Airnode calls the upstream API and receives a JSON response. -4. If the endpoint has encoding configured, Airnode ABI-encodes the response. Otherwise, the raw JSON is returned. -5. If the endpoint has `encrypt` configured, the encoded value is replaced with an FHE ciphertext. -6. Airnode signs the result with the operator's private key. -7. If TLS proofs are enabled, Airnode requests an attestation of the upstream call (non-fatal — a failure just omits the - proof). -8. The signed response is returned to the client. +## Who it is for -Endpoints can also run in `async` mode (returns `202` with a `pollUrl`; the result is fetched later from -`GET /requests/{requestId}`) or `stream` mode (the signed result is wrapped in a single Server-Sent Events frame). See -[Request and Response](/docs/concepts/request-response). +### API providers -## Endpoint IDs +Airnode lets an API provider offer signed responses without changing the upstream API. The provider chooses which routes +to expose and how clients access them: -Endpoint IDs are deterministic hashes of the API specification -- the URL, path, method, non-secret parameters, encoding -configuration, and encryption configuration. The ID binds the airnode's signature to the exact API spec the operator -committed to, so a consumer hard-coding an endpoint ID locks in the upstream URL, parameters, and encoding rules. Any -change to the spec produces a different ID, which on-chain consumers can detect immediately. +- `free` allows public access. +- `apiKey` restricts access to configured client keys. +- `x402` charges for each request using the payment flow described in the + [configuration reference](/docs/config/apis#x402-http-native-payment). -``` -endpointId = keccak256(url | path | method | sorted parameters | encoding spec | encrypt spec) -``` +### Consumers -See [Endpoint IDs](/docs/concepts/endpoint-ids) for the full derivation. +A consumer receives the data together with an Airnode address, endpoint ID, timestamp, and signature. The signature +proves that the holder of the Airnode key signed that response. It does not, by itself, prove who operates the Airnode +or where the data came from. -## Quick Start +Before trusting a response, consumers should: -### 1. Install +1. Confirm the Airnode address through [DNS identity verification](/docs/security/identity-verification). +2. Understand what the [endpoint ID](/docs/concepts/endpoint-ids) commits to. +3. Verify the signature and apply a freshness check. +4. Decide whether the operator and upstream API are suitable for the use case. -Download the `airnode` binary for your platform from the -[latest release](https://github.com/api3dao/airnode-v2/releases/latest) and place it on your `PATH`. +See the [trust model](/docs/security/trust-model) for the full boundary. -### 2. Generate a key +## How a request works -```bash -airnode generate-mnemonic +```text +Client ──POST──> Airnode ──HTTP──> Upstream API + | | + |<── JSON response ──┘ + | + ├─ Encode, if configured + ├─ Encrypt, if configured + ├─ Sign + └─ Request a separate TLS proof, if configured + | + v + Signed response ``` -This prints a new BIP-39 mnemonic and its corresponding airnode address. Save the mnemonic to a `.env` file: +1. A client sends `POST /endpoints/{endpointId}` with request parameters. +2. Airnode resolves the endpoint, authenticates the client, and validates the parameters. +3. Airnode calls the upstream API. +4. It returns raw JSON or ABI-encodes a configured value. +5. It optionally encrypts the encoded value. +6. It signs the result. +7. It may request a separate TLS proof. Proof failure does not fail the Airnode response. + +Endpoints can also use `async` mode or return the result in a single Server-Sent Events frame. See +[Requests and responses](/docs/concepts/request-response). + +## What endpoint IDs mean + +An endpoint ID is a hash of the configured API URL, path, method, parameter rules, encoding, and encryption settings. If +one of those fields changes, the ID changes. + +The ID commits to the published configuration. It does not prove that the running process used that configuration for a +particular request. See [Endpoint IDs](/docs/concepts/endpoint-ids). + +## Run from source + +Airnode v2 is currently an alpha and has no published release binaries. To run it from this repository, install +[Bun](https://bun.sh/) and then run: ```bash -echo 'AIRNODE_MNEMONIC=your twelve word mnemonic ...' > .env +git clone https://github.com/andreogle/airnode-v2.git +cd airnode-v2 +bun install +bun run airnode generate-mnemonic ``` -(`AIRNODE_PRIVATE_KEY=0x...` also works; the mnemonic takes precedence if both are set.) +Save either the mnemonic or a private key in `.env`: -### 3. Create a config +```bash +AIRNODE_MNEMONIC=your twelve word mnemonic ... +``` -Create `config.yaml` in the project root: +Create `config.yaml`: ```yaml version: '1.0' server: port: 3000 + host: '0.0.0.0' rateLimit: + trustForwardedFor: false window: 60000 max: 100 x402: @@ -151,15 +132,19 @@ apis: times: '1e18' ``` -### 4. Start the server +Validate the config and copy the printed endpoint ID: ```bash -airnode start -c config.yaml +bun run airnode config validate -c config.yaml ``` -Airnode logs the server address and all registered endpoint IDs on startup. +Start the server: + +```bash +bun run airnode start -c config.yaml +``` -### 5. Make a request +Make a request: ```bash curl -X POST http://localhost:3000/endpoints/{endpointId} \ @@ -167,35 +152,22 @@ curl -X POST http://localhost:3000/endpoints/{endpointId} \ -d '{"parameters":{"ids":"ethereum","vs_currencies":"usd"}}' ``` -Replace `{endpointId}` with the endpoint ID printed at startup. The response contains the signed data: - -```json -{ - "airnode": "0x...", - "endpointId": "0x...", - "timestamp": 1234567890, - "data": "0x...", - "signature": "0x..." -} -``` - -### 6. Check health +Check the service: ```bash curl http://localhost:3000/health ``` -```json -{ - "status": "ok", - "airnode": "0x..." -} -``` - ## Routes -| Method | Path | Description | -| ------ | ------------------------- | --------------------------------------- | -| `POST` | `/endpoints/{endpointId}` | Call an endpoint with parameters | -| `GET` | `/requests/{requestId}` | Poll an async request for its result | -| `GET` | `/health` | Health check (status + airnode address) | +| Method | Path | Purpose | +| ------ | ------------------------- | ----------------------------------------- | +| `POST` | `/endpoints/{endpointId}` | Call an endpoint | +| `GET` | `/requests/{requestId}` | Poll an async request | +| `GET` | `/health` | Check status and read the Airnode address | + +## Next steps + +- [Run an Airnode](/docs/operators) +- [Call an Airnode](/docs/consumers/getting-started) +- [Understand the trust model](/docs/security/trust-model) diff --git a/book/docs/operators/deployment.md b/book/docs/operators/deployment.md index f7edd35..0967ec0 100644 --- a/book/docs/operators/deployment.md +++ b/book/docs/operators/deployment.md @@ -71,7 +71,7 @@ For JSON log aggregation, set `LOG_FORMAT=json` in the `.env` file. ```dockerfile FROM oven/bun:1 WORKDIR /app -COPY package.json bun.lockb ./ +COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . EXPOSE 3000 @@ -119,13 +119,13 @@ docker compose up -d | `LOG_FORMAT` | No | `text` (default) or `json`. | | `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, or `error`. | -\* Exactly one of `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY` is required. Any `${VAR}` referenced in your config must -also be set in the environment. +\* At least one of `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY` is required. If both are set, the mnemonic takes +precedence. Any `${VAR}` referenced in your config must also be set in the environment. ## Graceful shutdown -Airnode handles `SIGINT` and `SIGTERM` for clean shutdown. The server stops accepting new requests, in-flight requests -complete, the cache is cleared, and the process exits. +Airnode handles `SIGINT` and `SIGTERM` for clean shutdown. The server stops accepting new requests, waits for in-flight +requests, stops background timers, and exits. ```bash # Manual stop diff --git a/book/docs/operators/index.md b/book/docs/operators/index.md index d97fb46..c6410f1 100644 --- a/book/docs/operators/index.md +++ b/book/docs/operators/index.md @@ -34,7 +34,9 @@ version: '1.0' server: port: 3000 + host: '0.0.0.0' rateLimit: + trustForwardedFor: false window: 60000 max: 100 x402: @@ -112,7 +114,8 @@ Expected response: | `LOG_FORMAT` | No | `text` (default) or `json`. | | `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, or `error`. | -\* Exactly one of `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY` is required. +\* At least one of `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY` is required. If both are set, the mnemonic takes +precedence. Bun automatically loads `.env` files from the working directory. diff --git a/book/docs/operators/publishing-endpoints.md b/book/docs/operators/publishing-endpoints.md index 357f614..7bbd40d 100644 --- a/book/docs/operators/publishing-endpoints.md +++ b/book/docs/operators/publishing-endpoints.md @@ -38,8 +38,8 @@ curl http://localhost:3000/health } ``` -This address is what consumers pin as a trusted signer. Never let it drift — rotating the key means re-deriving every -endpoint a consumer trusts. +This address is what consumers pin as a trusted signer. Key rotation changes the address, so consumers must update the +trusted signer. Endpoint IDs do not change because they are derived from API configuration, not the signing key. ## Listing endpoint IDs diff --git a/book/docs/plugins.md b/book/docs/plugins.md index 3fe2a59..d8e0090 100644 --- a/book/docs/plugins.md +++ b/book/docs/plugins.md @@ -208,7 +208,7 @@ The output must be an ES module whose default export is an `AirnodePlugin` (or a ## Example plugins The repository includes example plugins in -[`examples/plugins/`](https://github.com/api3dao/airnode-v2/tree/main/examples/plugins): +[`examples/plugins/`](https://github.com/andreogle/airnode-v2/tree/main/examples/plugins): | Plugin | Description | | ---------------------- | ----------------------------------------------------------------------------- | diff --git a/book/docs/security/identity-verification.md b/book/docs/security/identity-verification.md index cfec33a..419d548 100644 --- a/book/docs/security/identity-verification.md +++ b/book/docs/security/identity-verification.md @@ -5,8 +5,8 @@ sidebar_position: 2 # Identity Verification (ERC-7529) -Identity verification proves that an airnode address is controlled by the entity that owns a specific DNS domain. It -follows [ERC-7529](https://eips.ethereum.org/EIPS/eip-7529), a standard for associating EVM addresses with DNS domains. +Identity verification checks that a domain's DNS records associate it with an Airnode address. It follows +[ERC-7529](https://eips.ethereum.org/EIPS/eip-7529), a standard for associating EVM addresses with DNS domains. ## How it works @@ -39,8 +39,7 @@ Value: 0xAbC123..., 0xDef456... airnode identity show --domain api.coingecko.com ``` -This reads `AIRNODE_PRIVATE_KEY` from your environment, derives the address, and displays the exact TXT record host and -value to set: +This reads `AIRNODE_MNEMONIC` or `AIRNODE_PRIVATE_KEY`, derives the address, and displays the TXT record to set: ``` ────────────────────────────────────────────────────────────────────── @@ -109,10 +108,11 @@ Check that the response contains the expected airnode address. ## Trust model -Identity verification proves **who** operates the airnode -- it does not prove what data the airnode serves. An operator -who sets the TXT record is claiming: "I control this domain and I operate this airnode address." This is useful because: +Identity verification shows that the domain controller published an association with an Airnode address. It does not +prove who runs the process or whether its responses are correct. The record claims: "this domain recognizes this +address." This is useful because: -- **Requesters can verify operator identity** before trusting an airnode with their funds. +- **Requesters can verify the published domain association** before trusting an Airnode. - **DNS records are controlled by domain owners**, not by the airnode software. Only someone with access to `api.coingecko.com`'s DNS can set records under that domain. - **It composes with existing trust**: if you already trust CoinGecko's API, verifying that their airnode address @@ -121,8 +121,8 @@ who sets the TXT record is claiming: "I control this domain and I operate this a ### First-party verification DNS identity verification is most meaningful for **first-party airnodes** — where the API provider operates the node. -When CoinGecko sets a DNS TXT record associating their airnode address with `api.coingecko.com`, consumers can verify -that the data signer is the same entity that controls the data source. This is the strongest trust configuration. +When an API provider sets a DNS TXT record for an Airnode address under its API domain, consumers can attribute that +address to the domain controller. This avoids adding an unidentified relay, but does not prove response provenance. A third-party operator can only verify their own domain. If `oracle-service.example.com` claims to serve CoinGecko data, DNS verification proves the operator controls `oracle-service.example.com` — not that CoinGecko authorized them or that diff --git a/book/docs/security/trust-model.md b/book/docs/security/trust-model.md index 76e6271..bb93aab 100644 --- a/book/docs/security/trust-model.md +++ b/book/docs/security/trust-model.md @@ -3,139 +3,112 @@ slug: /security/trust-model sidebar_position: 1 --- -# Trust Model +# Trust model -Understanding what you are trusting when you consume data from an airnode. +An Airnode response has two separate questions: -## First-party oracle model +1. Who signed it? +2. Why should you trust the signed value? -Airnode is designed around the **first-party oracle** principle: the entity that operates the API also operates the -airnode. When CoinGecko runs an airnode serving CoinGecko's API, the signature on every response traces directly back to -the data source. There is no intermediary oracle network, no third-party relaying the data, no trust gap between the -signer and the source. +The signature answers only the first question. Consumers must decide the second. -This is important because it means the trust relationship is the same on-chain as it is off-chain. If you already trust -CoinGecko's API for off-chain use, an airnode operated by CoinGecko extends that exact same trust on-chain. The -signature is the API provider's signature. +## The intended setup -### Why first-party matters +Airnode is designed for an API provider to operate the Airnode that serves its own API. For example, if an exchange runs +an Airnode for its own market data, the exchange controls both the API and the signing key. -With a third-party oracle (someone other than the API provider running the node), consumers must trust that the -operator: +This removes an extra relay from the trust path, but it does not make the data objectively true. You still trust the +provider, its API, its Airnode process, and its key management. -- Is actually calling the API they claim and not fabricating or caching responses -- Has legitimate access to the API and is not violating terms of service -- Is not modifying, delaying, or selectively omitting data -- Will keep the node running and the API credentials current +Before accepting data, confirm that the Airnode address belongs to the provider through +[DNS identity verification](/docs/security/identity-verification). DNS proves control of a domain and its association +with an address. It does not inspect the running code or the upstream request. -None of these properties can be verified on-chain today. The endpoint ID commits to _what_ API should be called, but it -does not prove the operator is actually calling it. DNS identity verification proves _who_ controls a domain, but a -third-party operator would only prove their own domain -- not the API provider's. +## What a signature proves -**First-party operation eliminates this entire class of trust assumptions.** The API provider has no incentive to -fabricate responses to their own API, already has legitimate access, and controls the infrastructure end-to-end. +An EIP-191 signature proves that the holder of the Airnode private key signed: -Consumers should prefer airnodes operated by the API provider and verify this via -[DNS identity verification](/docs/security/identity-verification). If an airnode's identity cannot be traced to the API -provider's domain, treat it with the same skepticism you would apply to any unverified data source. +```text +endpointId + timestamp + data +``` -## What you are trusting +It does not prove that: -### 1. The airnode operator is calling the API they claim +- the signer is the API provider +- the operator used the published configuration +- the operator called the configured API +- the upstream API returned that value +- the value is accurate or suitable for your application -The endpoint ID is a specification-bound hash that commits to the API URL, path, method, parameters, and encoding rules. -You can recompute the endpoint ID from the operator's published config and confirm it matches what you are integrating -against — this is a verifiable commitment to the configuration, but not on its own proof that the airnode is actually -running that config. With a first-party airnode, this is not a concern: the API provider has no reason to misrepresent -calls to their own API. TLS proofs close the remaining gap by attesting that each response really did come from the -declared HTTPS endpoint. +The endpoint ID commits to a configuration. It is not runtime evidence. See [Endpoint IDs](/docs/concepts/endpoint-ids). -### 2. The airnode's private key is secure +## What consumers trust -The signature proves the airnode endorsed this data. If the private key is compromised, an attacker can sign arbitrary -data. Operators should use dedicated keys (not general-purpose wallets), store them securely (HSM, encrypted at rest), -and rotate them if exposure is suspected. +### The operator identity -### 3. The data is genuine +Use the provider's published domain and DNS record to verify the expected Airnode address. Reading an address from +`/health` is not enough, because an untrusted server can return any address. -The first-party trust model means the API provider is already trusted. If you use CoinGecko's price API off-chain, you -trust CoinGecko. Airnode extends that trust on-chain -- the data is signed by the API provider's key, not by a -third-party oracle network. +### The signing key -For higher assurance, use a quorum of multiple independent first-party airnodes -- each operated by a different API -provider serving comparable data. An attacker would need to compromise a majority of providers to manipulate the result. +Anyone with the private key can produce valid signatures. Operators should use a dedicated key, restrict access to it, +and publish a recovery or rotation plan. -## How trust is established +Key rotation changes the trusted signer address. It does not change endpoint IDs, because endpoint IDs are derived from +API configuration rather than the signing key. -### DNS identity verification (ERC-7529) +### The provider and upstream data -Operators prove their identity by setting a DNS TXT record that associates their domain with their airnode address. This -proves **who** operates the airnode -- the entity controlling `api.coingecko.com` has explicitly claimed this airnode -address. See [Identity Verification](/docs/security/identity-verification) for details. +A first-party Airnode carries the same basic provider trust as the provider's normal API. If the provider is wrong, +compromised, or dishonest, its signed response can also be wrong. -### Endpoint ID as verifiable commitment +For higher resilience, a consumer can compare signed responses from independent providers. The consumer must define the +aggregation rule, freshness policy, and minimum number of acceptable providers. -The endpoint ID is `keccak256(apiUrl, path, method, parameters, encoding)`. Given a config file, anyone can recompute -the endpoint ID and confirm it matches what the airnode is serving. This does not prove the operator is running that -config, but it creates an auditable commitment. +## Third-party operators -### Quorum across providers +If someone other than the API provider operates the Airnode, the consumer also trusts that operator to call the claimed +API and relay the result correctly. DNS verification of the operator's domain does not prove a relationship with the API +provider. -Different API providers each run their own airnode for their own API. A consumer can collect signed data from several -first-party airnodes — for example, a BTC/USD quorum composed of exchanges that each publish their own price feed — and -aggregate the results off-chain or submit them to an on-chain quorum verifier. The trust gain comes from independence at -the source: each airnode commits to its own specific endpoint, and an attacker would need to compromise multiple -unrelated providers to manipulate the aggregate. +Do not describe a third-party Airnode as first-party data unless the API provider has explicitly authorized and +identified it. -### TLS proofs and third-party trust +## TLS proofs -zkTLS / TLS Notary produces cryptographic proof that the data came from a specific HTTPS endpoint, eliminating the need -to trust the operator's honesty -- the proof shows the data was not fabricated. Airnode integrates this today via the -Reclaim protocol: when `settings.proof` is configured and an endpoint declares `responseMatches`, each response carries -an attestation of the upstream call. See [TLS Proofs](/docs/concepts/proofs). (Proofs are non-fatal — a gateway outage -just omits the `proof` field rather than failing the request — and the consumer verifies the attestor signature -on-chain, not Airnode.) +Airnode can request a Reclaim TLS proof. The gateway and attestor make a separate HTTPS request and attest that the +response matched configured patterns. -TLS proofs are particularly significant for third-party operators. A third-party operator cannot, by signature alone, -prove it is actually calling the API it claims. With a TLS proof, the cryptographic proof itself demonstrates the data -came from the API provider's HTTPS endpoint -- regardless of who operates the airnode. This makes third-party operation -viable for use cases where the API provider does not want to run infrastructure, while still preserving verifiable data -provenance. Without a proof, first-party operation remains the only trust model where the data source is guaranteed. +This adds evidence about the attestor's request. It does not prove that Airnode's separately fetched and signed payload +is byte-for-byte the same response. Consumers also trust the proof system, gateway behavior, attestor set, matching +rules, and verification code. -### Future: TEE attestation +Proof generation is optional and non-fatal. A response may be signed without a proof when the gateway fails. Consumers +that require a proof must reject responses that omit it. -Running the airnode in a Trusted Execution Environment (AWS Nitro Enclaves, Intel SGX, AMD SEV-SNP) produces attestation -proofs that the running code matches a specific binary hash. Combined with DNS identity verification, this creates a -verifiable chain: the domain proves who operates the airnode, the attestation proves what code it runs. +See [TLS Proofs](/docs/concepts/proofs) for the exact boundary. -## What is NOT trusted +## Plugins -### Who submits data on-chain +Plugins run in the Airnode process without a sandbox. Mutation hooks can change parameters or data before signing. Trust +in an Airnode therefore includes every loaded plugin. -AirnodeVerifier is permissionless. Anyone can submit valid signed data -- the client, a relayer, the airnode itself, or -any third party. The contract verifies the signature, not the submitter. +Operators should review plugin code and keep the plugin list small. -### The transport layer - -Responses are signed. A man-in-the-middle can observe the data but cannot modify it without invalidating the signature. -The signature is verified on-chain by the contracts and can be verified off-chain by any client. - -## Off-chain trust: plugins - -The airnode node supports a [plugin system](/docs/plugins) that can intercept and modify data at every stage of the -request lifecycle. Plugins run in the airnode process with no sandboxing. A malicious plugin can alter data before the -airnode signs it. +## On-chain verification -The trust placed in an airnode implicitly extends to all plugins it runs. Operators should audit plugin code and load -only plugins from trusted sources. +`AirnodeVerifier` checks the EIP-191 signature, replay state, and callback flow. It does not verify the upstream API +call or decide whether the signer is trustworthy. -## On-chain verification +Anyone can submit a valid signed response. The contract verifies the signature, not the submitter. -### Signature verification +## Consumer checklist -`AirnodeVerifier` recovers the signer from an EIP-191 signature over -`keccak256(encodePacked(endpointId, timestamp, data))` and asserts it matches the airnode address the caller claimed. -See [Signing and Verification](/docs/concepts/signing) for the full format. +Before using an Airnode response: -`endpointId` is a top-level field (not buried inside another hash) so future on-chain verifiers — including TLS proof -verifiers — can inspect it directly without reconstructing a nested hash structure. +- Verify the expected signer through a provider-controlled channel. +- Pin the signer and endpoint ID you intend to trust. +- Check the timestamp against your freshness limit. +- Decode the data using the expected ABI type. +- Decide whether a TLS proof is required, and reject missing proofs if it is. +- Define how key rotation, provider failure, and conflicting providers are handled. diff --git a/book/docs/troubleshooting.md b/book/docs/troubleshooting.md index 30c13e0..6ac7011 100644 --- a/book/docs/troubleshooting.md +++ b/book/docs/troubleshooting.md @@ -14,8 +14,8 @@ Common errors, what causes them, and how to fix them. **Cause:** Your `config.yaml` uses `${VAR_NAME}` interpolation, but the variable is not defined in the environment or `.env` file. -**Fix:** Create a `.env` file in the same directory as `config.yaml` and define the variable. Bun loads `.env` -automatically -- no dotenv needed. +**Fix:** By default, Airnode loads `.env` from the current working directory. Define the variable there or pass another +file with `airnode start --env /path/to/.env`. ```bash # .env @@ -23,11 +23,12 @@ COINGECKO_URL=https://api.coingecko.com/api/v3 API_KEY_1=your-key-here ``` -### `AIRNODE_PRIVATE_KEY environment variable is required` +### `AIRNODE_PRIVATE_KEY or AIRNODE_MNEMONIC is required` -**Cause:** The server cannot start without a private key to derive the airnode address and sign responses. +**Cause:** The server cannot start without a credential to derive the Airnode address and sign responses. -**Fix:** Add `AIRNODE_PRIVATE_KEY` to your `.env` file. It must be a valid 32-byte hex string with the `0x` prefix. +**Fix:** Add a BIP-39 `AIRNODE_MNEMONIC` or a 32-byte hexadecimal `AIRNODE_PRIVATE_KEY`. If both are set, the mnemonic +takes precedence. ```bash # .env diff --git a/book/docs/v1-comparison.md b/book/docs/v1-comparison.md index 01b247d..4b2c2db 100644 --- a/book/docs/v1-comparison.md +++ b/book/docs/v1-comparison.md @@ -72,11 +72,10 @@ v2 endpoint IDs are hashes of the full API specification — the URL, path, meth rules. The signature over `(endpointId, timestamp, data)` therefore commits to exactly what the airnode was configured to do. -**Why:** The first-party model — the API provider runs the airnode that serves their own API — means the signature and -the data source are the same party. The endpoint ID turns that configuration into a verifiable commitment: a consumer -contract hard-coding an ID binds itself to the specific URL, parameters, and encoding rules the operator declared. The -operator cannot silently point an endpoint at a different upstream without changing the ID. TLS proofs extend this -further: the endpoint ID can be cross-checked against the proven HTTP request that backs the response. +**Why:** In the intended first-party model, the API provider also controls the signing key. The endpoint ID commits to +the URL, parameter rules, and encoding declared in config. It does not prove which config ran or bind requester-supplied +values for one call. TLS proofs add evidence about a gateway's separate HTTPS request, not the exact payload Airnode +signed. ### Fixed and client-controlled encoding, both committed to by the ID @@ -121,8 +120,7 @@ v1 signed `keccak256(requestId, timestamp, airnodeAddress, data)` where the requ (sponsor, requester, chain ID, nonce). v2 signs `keccak256(encodePacked(endpointId, timestamp, data))` where the endpoint ID is derived from the API spec. The -endpoint ID, timestamp, and data are separate top-level fields — not nested inside another hash — so on-chain contracts -and TLS proof verifiers can inspect each field independently. +fields are transported separately, so a verifier can validate them before recomputing the signed hash. ## TLS proofs for data provenance @@ -131,9 +129,8 @@ _who_ signed — not _where the data came from_. A compromised or dishonest oper them. v2 integrates [TLS proofs](/docs/concepts/proofs) via [Reclaim Protocol](https://reclaimprotocol.org/). When enabled, an -independent attestor participates in the upstream TLS session over MPC-TLS and signs a claim that the response actually -came from the declared HTTPS endpoint and matched the configured `responseMatches` patterns. Airnode attaches the proof -to the response alongside the signature. +independent attestor makes a separate HTTPS request over MPC-TLS and signs a claim that its response came from the +declared endpoint and matched the configured `responseMatches` patterns. Airnode attaches the proof to its own response. ```yaml settings: @@ -155,9 +152,9 @@ apis: Proof generation is **non-fatal** — if the gateway is unavailable, Airnode still returns the signed response without the `proof` field and logs a warning. Consumers that require provenance simply reject responses that lack a `proof`. -**Why:** Signatures answer "who endorsed this data." TLS proofs answer "did this data really come from the API." Pairing -them turns an airnode from a trusted relay into a verifiable relay — the operator can no longer forge upstream responses -undetected. +**Why:** Signatures identify the Airnode key. A TLS proof adds evidence that an attestor's separate HTTPS response +matched configured patterns. It does not prove that Airnode signed the same response bytes, so consumers must apply the +limits described on the TLS proofs page. ## A real plugin system diff --git a/book/sidebars.ts b/book/sidebars.ts index ff8a839..4014a00 100644 --- a/book/sidebars.ts +++ b/book/sidebars.ts @@ -1,44 +1,44 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; -// Audience-first navigation: a reader landing on the docs picks "I'm an -// operator" or "I'm a consumer" first, then drills into concepts / -// reference / security as needed. Concepts come after the audience tracks -// because they make more sense once you know what you're building. const sidebars: SidebarsConfig = { docs: [ 'introduction', { type: 'category', - label: 'Operators', + label: 'Run an Airnode', items: ['operators/index', 'operators/deployment', 'operators/publishing-endpoints'], }, { type: 'category', - label: 'Consumers', + label: 'Use an Airnode', items: ['consumers/getting-started', 'consumers/http-client', 'consumers/on-chain'], }, { type: 'category', - label: 'Concepts', - items: [ - 'concepts/architecture', - 'concepts/request-response', - 'concepts/endpoint-ids', - 'concepts/signing', - 'concepts/proofs', - 'concepts/fhe-encryption', - ], + label: 'How it works', + items: ['concepts/architecture', 'concepts/request-response', 'concepts/endpoint-ids', 'concepts/signing'], }, { type: 'category', - label: 'Config Reference', - items: ['config/index', 'config/server', 'config/settings', 'config/apis', 'config/plugins'], + label: 'Optional features', + items: ['concepts/proofs', 'concepts/fhe-encryption', 'plugins'], }, - 'plugins', { type: 'category', - label: 'Contracts', - items: ['contracts/overview', 'contracts/verifier'], + label: 'Reference', + items: [ + { + type: 'category', + label: 'Configuration', + items: ['config/index', 'config/server', 'config/settings', 'config/apis', 'config/plugins'], + }, + 'cli', + { + type: 'category', + label: 'Contracts', + items: ['contracts/overview', 'contracts/verifier'], + }, + ], }, { type: 'category', @@ -46,9 +46,11 @@ const sidebars: SidebarsConfig = { items: ['security/trust-model', 'security/identity-verification'], }, 'troubleshooting', - 'cli', - 'v1-comparison', - 'roadmap', + { + type: 'category', + label: 'Project', + items: ['v1-comparison', 'roadmap'], + }, ], };