Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions book/docs/contracts/verifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ 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 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.
3. If the signer matches the provided Airnode address and this exact callback delivery has not succeeded before, the
data is forwarded to the callback contract.
4. If the callback reverts, the entire transaction reverts. Replay state is rolled back so the delivery can be retried.

## Function

Expand Down Expand Up @@ -59,8 +58,8 @@ The contract uses a separate `fulfilledDelivery` mapping to prevent duplicate de
care who pays gas.
- **No airnode registry.** The contract does not know which airnodes are legitimate. It only verifies the math: "did
this address sign this data?" The callback contract is responsible for checking whether it trusts the airnode address.
- **Callback failure is safe.** If the callback reverts, the fulfillment is still recorded and the event is emitted. The
submitter's transaction succeeds. This prevents a malicious callback from blocking fulfillment.
- **Callback failure is retryable.** If the callback reverts, the verifier transaction and replay-state writes revert
too. This prevents a premature or underfunded submission from consuming a valid delivery.

## Consumer contract example

Expand Down
34 changes: 18 additions & 16 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The fields:

- **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) included by Airnode when it signed the data.
- **data** — the signed payload: an ABI-encoded value, an FHE ciphertext, or `keccak256` of the raw JSON response. The
contract treats it as opaque `bytes` and forwards it to the callback unchanged (no size limit on-chain; the HTTP
request body is capped at 64 KB by the server).
Expand All @@ -61,18 +61,17 @@ pull path — a client gets signed data from the HTTP server and submits it to t

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.
4. If the callback reverts, the fulfillment is still recorded. This prevents griefing where a callback intentionally
reverts to block fulfillment.
3. If the signer matches the provided Airnode address and this exact callback delivery has not succeeded before, the
data is forwarded to the callback contract.
4. If the callback reverts, the whole transaction reverts. Replay state is rolled back so the delivery can be retried.

### Function

```solidity
function verifyAndFulfill(
address airnode, // expected signer
bytes32 endpointId, // specification-bound endpoint hash
uint256 timestamp, // data timestamp
uint256 timestamp, // timestamp included in the signature
bytes calldata data, // signed payload (opaque bytes — ABI value, FHE ciphertext, or JSON hash)
bytes calldata signature, // EIP-191 personal signature
address callbackAddress, // contract to forward data to
Expand All @@ -84,28 +83,31 @@ The callback receives:

```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: each unique
`(airnode, endpointId, timestamp, data)` combination can only be fulfilled once. The nested `fulfilled` mapping is
public, so callers can query `fulfilled(airnode, requestHash)`.
The `requestHash` is the `messageHash` from the signature. Replay protection is scoped to the signer, payload, callback
address, and callback selector. The public `fulfilled(airnode, requestHash)` mapping indicates whether the signer and
payload have been delivered successfully at least once.

### Trust model

- **Permissionless.** Anyone can submit signed data — client, relayer, or the airnode itself. The contract doesn't care
who pays gas.
- **No airnode registry.** The contract doesn't know which airnodes are legitimate. It only verifies the math: "did this
address sign this data?" The callback contract is responsible for checking whether it trusts the airnode address.
- **Callback failure is safe.** If the callback reverts, the fulfillment is still recorded and the event is emitted. The
submitter's transaction succeeds. This prevents a malicious callback from blocking fulfillment.
- **No Airnode registry.** The contract doesn't know which Airnodes are legitimate. It only verifies the math: "did this
address sign this data?" The callback contract is responsible for checking whether it trusts the Airnode address.
- **Signatures are chain-agnostic.** The signed message does not include a chain ID or verifier address, so the same
attestation can be delivered on multiple chains. Consumers that require domain-specific attestations must bind that
domain in their endpoint specification or signed data.
- **Callback failure is retryable.** A callback revert bubbles through the verifier and rolls back replay state and the
event. This prevents a premature or underfunded submission from consuming a valid delivery.

### Writing a consumer contract

Expand Down Expand Up @@ -196,7 +198,7 @@ contracts/
examples/
AirnodePriceConsumer.sol Reference consumer (the four required checks)
ConfidentialPriceFeed.sol FHE-ciphertext consumer (ingests an encrypted value)
ITFHE.sol Minimal fhEVM interface used by the FHE example
ITFHE.sol Minimal FHE adapter used by the example
test/
AirnodeVerifier.{t,invariant.t,symbolic.t}.sol Unit / invariant / symbolic tests
AirnodePriceConsumer.t.sol Consumer tests (the four checks, out-of-order, fuzz)
Expand Down
46 changes: 31 additions & 15 deletions contracts/src/AirnodeVerifier.sol
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pragma solidity ^0.8.24;
/// and consumers can deliver the same payload without blocking each other.
/// - The callback receives (requestHash, airnode, endpointId, timestamp, data)
/// so it has all the context it needs to validate and process the data.
/// - If the callback reverts, that precise delivery tuple is still recorded.
/// - Callback failure reverts the entire delivery, including replay state and the
/// event, so the same signed payload can be retried safely.
contract AirnodeVerifier {
// ===========================================================================
// Events
Expand Down Expand Up @@ -60,7 +61,7 @@ contract AirnodeVerifier {
/// @param timestamp The timestamp included in the signature.
/// @param data The ABI-encoded response data.
/// @param signature The EIP-191 personal signature (65 bytes: r || s || v).
/// @param callbackAddress The contract to forward the data to. Must not be zero.
/// @param callbackAddress The deployed contract to forward the data to.
/// @param callbackSelector The function selector on the callback contract.
function verifyAndFulfill(
address airnode,
Expand All @@ -72,32 +73,47 @@ contract AirnodeVerifier {
bytes4 callbackSelector
) external {
require(callbackAddress != address(0), 'Callback address is zero');
require(callbackAddress.code.length != 0, 'Callback has no code');

// Derive the message hash: keccak256(encodePacked(endpointId, timestamp, data))
bytes32 messageHash = keccak256(abi.encodePacked(endpointId, timestamp, data));

// Apply EIP-191 prefix and recover the signer
bytes32 ethSignedHash = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', messageHash));
address recovered = _recover(ethSignedHash, signature);
require(recovered == airnode, 'Signature mismatch');
{
// Apply EIP-191 prefix and recover the signer.
bytes32 ethSignedHash = keccak256(abi.encodePacked('\x19Ethereum Signed Message:\n32', messageHash));
address recovered = _recover(ethSignedHash, signature);
require(recovered == airnode, 'Signature mismatch');
}

// Replay protection is scoped to the signer and callback target. Two
// independent consumers may legitimately deliver the same attestation, and
// a caller cannot burn it by supplying an unrelated callback or selector.
bytes32 deliveryHash = keccak256(abi.encode(airnode, messageHash, callbackAddress, callbackSelector));
require(!fulfilledDelivery[deliveryHash], 'Already fulfilled');
fulfilledDelivery[deliveryHash] = true;
{
bytes32 deliveryHash = keccak256(abi.encode(airnode, messageHash, callbackAddress, callbackSelector));
require(!fulfilledDelivery[deliveryHash], 'Already fulfilled');
fulfilledDelivery[deliveryHash] = true;
}
fulfilled[airnode][messageHash] = true;

// Emit before external interaction (checks-effects-interactions pattern)
emit Fulfilled(messageHash, airnode, endpointId, timestamp, callbackAddress);

// Forward to callback via the caller-specified selector. We use a low-level
// call because the selector is dynamic — there is no fixed interface. The
// return value is intentionally discarded: if the callback reverts, the
// fulfillment is still recorded to prevent griefing.
// slither-disable-next-line low-level-calls,unchecked-lowlevel
callbackAddress.call(abi.encodeWithSelector(callbackSelector, messageHash, airnode, endpointId, timestamp, data));
// Forward to callback via the caller-specified selector. Failure must revert
// this transaction so an underfunded or premature submission cannot consume a
// valid payload without updating the consumer. Reverting also rolls back both
// replay flags and the event, preserving a safe retry path.
// slither-disable-next-line low-level-calls
(bool success, bytes memory returndata) = callbackAddress.call(
abi.encodeWithSelector(callbackSelector, messageHash, airnode, endpointId, timestamp, data)
);
if (!success) {
// Bubble the callback's revert data. This standard assembly pattern preserves
// custom errors and revert strings for callers.
// slither-disable-next-line assembly
assembly ('memory-safe') {
revert(add(returndata, 0x20), mload(returndata))
}
}
}

// ===========================================================================
Expand Down
11 changes: 8 additions & 3 deletions contracts/src/examples/AirnodePriceConsumer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,13 @@ contract AirnodePriceConsumer {

/// @notice The latest accepted value.
int256 public latestPrice;
/// @notice The Airnode timestamp the latest value was produced at.
/// @notice The timestamp Airnode included when signing the latest value.
uint256 public latestTimestamp;

event PriceUpdated(int256 price, uint256 timestamp, bytes32 requestHash);

error ZeroAddress();
error VerifierHasNoCode();
error NotVerifier(address caller);
error UntrustedAirnode(address attested);
error WrongEndpoint(bytes32 attested);
Expand All @@ -62,6 +63,7 @@ contract AirnodePriceConsumer {

constructor(address airnodeVerifier, address trustedAirnode, bytes32 trustedEndpointId, uint256 maxAgeSeconds) {
if (airnodeVerifier == address(0) || trustedAirnode == address(0)) revert ZeroAddress();
if (airnodeVerifier.code.length == 0) revert VerifierHasNoCode();
verifier = airnodeVerifier;
airnode = trustedAirnode;
endpointId = trustedEndpointId;
Expand Down Expand Up @@ -89,9 +91,12 @@ contract AirnodePriceConsumer {
// irrelevant against a maxStaleness measured in minutes/hours.
// slither-disable-next-line timestamp
if (attestedAt > block.timestamp) revert TimestampInFuture(attestedAt);
uint256 deadline = attestedAt + maxStaleness;
// slither-disable-next-line timestamp
if (block.timestamp > deadline) revert DataTooStale(attestedAt, deadline);
uint256 age = block.timestamp - attestedAt;
if (age > maxStaleness) {
// This addition cannot overflow: maxStaleness < block.timestamp - attestedAt.
revert DataTooStale(attestedAt, attestedAt + maxStaleness);
}

// Optional, but typical for a feed: ignore out-of-order delivery. (No revert — the
// submitter shouldn't lose their tx just because a newer update already landed; and
Expand Down
47 changes: 25 additions & 22 deletions contracts/src/examples/ConfidentialPriceFeed.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,26 @@ import { ITFHE } from './ITFHE.sol';
/// configured with an `encrypt` block).
/// 2. Airnode encrypts the encoded response with the chain's FHE public key,
/// packing (handle, inputProof) into the data field.
/// 3. Airnode signs the ciphertext — the signature proves the encrypted data is
/// authentically from the API provider.
/// 3. Airnode signs the ciphertext. The signature identifies the Airnode key;
/// trusting that signer and its upstream data remains an application decision.
/// 4. Client submits the signed data to AirnodeVerifier.verifyAndFulfill(),
/// which verifies the signature and forwards to this contract's fulfill().
/// 5. This contract registers the FHE handle and manages decryption access.
///
/// Trust model:
/// - Only the AirnodeVerifier can call fulfill() (enforced by msg.sender check).
/// - Only airnodes in the trust set are accepted (operator-configured).
/// - Only the owner can grant/revoke decryption access. The submitter, the
/// relayer, and other users have no ability to authorize themselves.
/// - Only the owner can grant persistent decryption access. Current FHE ACL
/// grants are not revocable, so applications should grant them sparingly.
/// - The FHE coprocessor enforces that only this contract can call allow() on
/// handles it created — even if a malicious contract knows the handle ID,
/// it cannot grant itself access.
/// - This example trusts the configured ITFHE adapter to validate proofs and
/// enforce ciphertext binding. MockTFHE provides no cryptographic guarantee.
///
/// On fhEVM-compatible chains, replace ITFHE with the real `FHE` library
/// (zama-ai/fhevm-solidity) and use native encrypted types (euint256) instead
/// of uint256 handles. Note: an fhEVM encrypted input is bound to the contract
/// On FHEVM-compatible chains, replace ITFHE with the current FHEVM Solidity
/// package and use its native encrypted and external input types instead of
/// uint256 handles. Note: an fhEVM encrypted input is bound to the contract
/// that ingests it AND to that contract's msg.sender at ingestion time. Here
/// fulfill() is called by AirnodeVerifier, so the airnode must have encrypted
/// the input for (this contract, AirnodeVerifier) — i.e. the operator's
Expand All @@ -45,7 +47,7 @@ contract ConfidentialPriceFeed {
// ===========================================================================
event PriceUpdated(bytes32 indexed endpointId, uint256 handle, uint256 timestamp);
event AccessGranted(bytes32 indexed endpointId, address indexed account);
event AccessRevoked(bytes32 indexed endpointId, address indexed account);

event AirnodeTrusted(address indexed airnode);
event AirnodeRemoved(address indexed airnode);

Expand All @@ -55,6 +57,7 @@ contract ConfidentialPriceFeed {
address public immutable owner;
address public immutable verifier;
ITFHE public immutable tfhe;
uint256 public immutable maxStaleness;

/// @notice Trusted airnode addresses. Only data signed by these airnodes is accepted.
mapping(address => bool) public trustedAirnodes;
Expand All @@ -81,12 +84,16 @@ contract ConfidentialPriceFeed {

/// @param _verifier The AirnodeVerifier contract address.
/// @param _tfhe The TFHE interface implementation (mock for testing, precompile on fhEVM).
constructor(address _verifier, address _tfhe) {
/// @param _maxStaleness Maximum accepted age of an Airnode timestamp, in seconds.
constructor(address _verifier, address _tfhe, uint256 _maxStaleness) {
require(_verifier != address(0), 'Verifier is zero');
require(_tfhe != address(0), 'TFHE is zero');
require(_verifier.code.length != 0, 'Verifier has no code');
require(_tfhe.code.length != 0, 'FHE adapter has no code');
owner = msg.sender;
verifier = _verifier;
tfhe = ITFHE(_tfhe);
maxStaleness = _maxStaleness;
}

// ===========================================================================
Expand All @@ -95,6 +102,7 @@ contract ConfidentialPriceFeed {

function trustAirnode(address airnode) external {
require(msg.sender == owner, 'Only owner');
require(airnode != address(0), 'Airnode is zero');
trustedAirnodes[airnode] = true;
emit AirnodeTrusted(airnode);
}
Expand All @@ -114,7 +122,7 @@ contract ConfidentialPriceFeed {
/// packed by Airnode's FHE encryption.
/// @param airnode The airnode that signed the data.
/// @param endpointId The endpoint the data came from.
/// @param timestamp When the data was fetched.
/// @param timestamp The timestamp Airnode included when signing the data.
/// @param data The FHE-encrypted payload (handleRef + inputProof).
function fulfill(
bytes32 /* requestHash */,
Expand All @@ -125,6 +133,11 @@ contract ConfidentialPriceFeed {
) external nonReentrant {
require(msg.sender == verifier, 'Only verifier');
require(trustedAirnodes[airnode], 'Untrusted airnode');
// Airnode timestamps are trusted only within this application-defined window.
// slither-disable-next-line timestamp
require(timestamp <= block.timestamp, 'Future timestamp');
// slither-disable-next-line timestamp
require(block.timestamp - timestamp <= maxStaleness, 'Data too stale');
require(timestamp > timestamps[endpointId], 'Stale data');

// Effects before interactions (CEI): bumping the timestamp first blocks any
Expand All @@ -136,7 +149,7 @@ contract ConfidentialPriceFeed {

// Register the FHE handle — validates the ZK proof and creates a ciphertext
// reference in the coprocessor. Only this contract gets initial access.
uint256 handle = tfhe.asEuint256(handleRef, inputProof);
uint256 handle = tfhe.fromExternal(handleRef, inputProof);
tfhe.allow(handle, address(this));

// The handle is only known after the external call, so this write is unavoidably
Expand All @@ -148,7 +161,7 @@ contract ConfidentialPriceFeed {
}

// ===========================================================================
// Decryption access control — only the owner decides who can read prices
// Decryption access control — only the owner can grant persistent access
// ===========================================================================

/// @notice Grant an address permission to decrypt a price.
Expand All @@ -162,14 +175,4 @@ contract ConfidentialPriceFeed {
tfhe.allow(prices[endpointId], account);
emit AccessGranted(endpointId, account);
}

/// @notice Revoke an address's permission to decrypt a price.
/// @param endpointId The endpoint whose price to revoke access from.
/// @param account The address to revoke.
function revokeAccess(bytes32 endpointId, address account) external nonReentrant {
require(msg.sender == owner, 'Only owner');
require(prices[endpointId] != 0, 'No price for endpoint');
tfhe.deny(prices[endpointId], account);
emit AccessRevoked(endpointId, account);
}
}
Loading