EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8309: Gateway Mesh Sync Protocol for CCIP-Read

A pull-based protocol for ERC-3668 CCIP-Read gateways to replicate each other's signed records, forming a redundant mesh.

Authors
Created2026-06-13
Discussion Linkhttps://ethereum-magicians.org/t/erc-8309-gateway-mesh-sync-protocol-for-ccip-read/28680
Requires

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#1826PR open

EIP-GPT summary

Contents
AbstractMotivationSpecificationRolesThe /records endpointRecord commitment and signingDeduplicationProtocol versioning β€” (LOCKED: declare-and-skip, no handshake)Namespaces β€” (LOCKED: no on-chain registry; out-of-band convention)Peer discoveryNode identity and key rotation β€” (two-tier: a cold identity key authorizes a rotatable signer key)validFrom monotonicity (normative)RationaleBackwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsCopyright

Abstract

ERC-3668 (CCIP-Read) defines how a client retrieves signed data from a single off-chain gateway. It says nothing about how gateways relate to one another, so a name or record served by one gateway disappears if that gateway goes down. This standard defines a minimal, pull-based mesh sync protocol: each gateway exposes a GET /records endpoint returning ERC-191-signed records, and peer gateways poll it on an interval and replicate the records they verify. Records are content-addressed and deduplicated, signer keys are verifiable and rotatable, and an optional on-chain NodeRegistry provides discovery. Any CCIP-Read gateway that implements /records becomes part of the mesh, regardless of language or runtime, so a record originating on any node survives the loss of any other.

Motivation

CCIP-Read makes off-chain data retrieval trustless but leaves a single gateway as a point of failure: if it is unreachable, resolution fails even though the data is fully reproducible. Operators work around this with bespoke replication or centralized failover. There is no interoperable way for independently operated gateways to share signed records, so meshes cannot form across operators or implementations.

This standard fills the gap ERC-3668 leaves between client↔gateway (specified) and gateway↔gateway (unspecified). It separates two concerns the existing stack conflates:

  • Trust β€” already solved: each record is signed; a consumer verifies the signature, independent of who served it.
  • Availability β€” addressed here: signed records replicate across peers so any node can answer.

Concretely it answers: how does a gateway expose its records for replication; how does a peer verify and deduplicate them; how do gateways discover one another; and how does node identity survive a key rotation.

Specification

The key words "MUST", "MUST NOT", "SHOULD", "MAY" are to be interpreted as in RFC 2119 and RFC 8174.

Roles

A node is any HTTP service that serves GET /records in the format below. A node MAY also serve ERC-3668 CCIP-Read lookups, but this standard governs only inter-node replication. Nodes are pull-based: each node polls its peers; there is no push and no central coordinator.

The /records endpoint

GET /records?since=<unix>&namespace=<string>&limit=<n>&cursor=<string>

Response:

{
  "protocol": 1,
  "supported_protocols": [1],
  "node_version": "<impl-defined string>",
  "namespace": "<string>",
  "records": [
    {
      "inputHash": "0x…",
      "namespace": "<string>",
      "value": "0x…",
      "timestamp": 1234567890,
      "signature": "0x…",
      "sourcePeer": null
    }
  ],
  "cursor": "<opaque string>" | null
}
  • since, namespace, limit, cursor are all OPTIONAL filters; a node MUST apply those provided and MUST support cursor pagination.
  • cursor is opaque to the caller and MUST be treated as a continuation token, not parsed.
  • sourcePeer is null for locally originated records; for a record learned via sync it MUST carry the URL of the originating node (the node that first signed the record). A node relaying a synced record MUST propagate that original sourcePeer unchanged and MUST NOT overwrite it with its own URL β€” so for a path Aβ†’Bβ†’C, C reports A, not B. sourcePeer is advisory provenance only; trust derives from the record signature, not from sourcePeer.
  • Sync cadence. Nodes SHOULD poll each peer on a regular interval. RECOMMENDED 30 s–5 min (the reference mesh polls every 60 s); intervals below ~10 s add redundant load with no survival benefit, and above ~10 min weaken the propagation guarantee. The interval is node-local and is not coordinated.

Record commitment and signing

Each record is signed by the originating node's current signer key. The signed message is the ERC-191 personal_sign of:

commitment = keccak256(abi.encodePacked(
    bytes32  inputHash,
    string   namespace,
    bytes32  keccak256(value),
    uint64   timestamp
))

The single variable-length field (namespace) is bracketed by fixed-size fields β€” inputHash before it, the 32-byte value hash and 8-byte timestamp after. Because the total preimage length fixes the namespace length and every field's position is anchored from one end or the other, no two distinct tuples can produce the same preimage; the packing is unambiguous. (Packing two adjacent variable-length fields is ambiguous and MUST NOT be used β€” this is the single-dynamic-field case, which is safe.) A verifier recovers the signer from signature over commitment and MUST reject any record whose recovered signer is not authorized by the peer's pinned identity (see Node identity).

Deduplication

Deduplication MUST collapse only observation-identical records β€” those with the same (inputHash, namespace, value). The signature, timestamp, and source peer are attestation metadata outside record identity, so two validly-signed records with the same (inputHash, namespace, value) from different signers are observation-identical and collapse to one (the first valid one wins). This keeps replication idempotent and convergent: pulling the same observation from multiple peers is a no-op.

Two validly-signed records with the same (inputHash, namespace) but a differing value are a divergence, not a duplicate: both MUST be retained. The record-store state for a given (inputHash, namespace) MUST be expressible as single, divergent (the committed set of observations), or absent. A divergence MUST NOT be silently discarded and MUST NOT be represented as agreement. This specification names an extension point at which a declared resolution policy MAY attach; it prescribes none.

Collapsing observation-identical records discards per-value attestation multiplicity. A policy that counts independent vantages per value (for example, a quorum rule) requires attestation-set retention β€” a per-value record of which signers attested each value β€” which this specification does not mandate; such a policy MUST declare that retention as its own precondition.

Protocol versioning β€” (LOCKED: declare-and-skip, no handshake)

  • protocol is a single monotonically increasing integer present in every /records response. Version 1 is this specification.
  • Versioning is declared, not negotiated. A node MUST verify a peer's protocol is one it implements before ingesting records, and MUST skip (not error) a peer whose version it does not implement, logging the mismatch and continuing the sync cycle.
  • There is no in-band negotiation round-trip β€” nodes are stateless and pull-based, and a handshake would add coordination and state the model deliberately avoids.
  • A node MAY serve multiple versions by honoring an optional ?protocol=<n> request parameter, defaulting to its highest supported version when the parameter is absent.
  • A node SHOULD advertise the versions it can serve as a supported_protocols array (in its /records response and in /peers), so a peer selects a compatible version via ?protocol=<n> instead of discovering incompatibility by trial.

Namespaces β€” (LOCKED: no on-chain registry; out-of-band convention)

  • A namespace is a free-form string scoped at the record level. There is no on-chain or standardized namespace registry; coordination is out-of-band, by convention.
  • Implementations SHOULD avoid collisions using descriptive, owned, or hierarchical prefixes (e.g. agent-attestations, agent-attestations:wyriwe, token-metadata). A colon (:) SHOULD delimit a sub-namespace of a parent.
  • The agent-attestations namespace is the reference mesh's primary load today: signed attestations about agent actions, whose subjects are identified by ERC-8004 agent ids, with :wyriwe sub-scoping those that carry input provenance for an inference. This protocol does not interpret them β€” a record is opaque bytes with a signature and a content hash β€” but it is the transport those artifacts travel over, and naming the case makes the mesh's purpose legible to a reader who arrives at this document first.
  • A registry is intentionally out of scope: namespaces are an off-chain coordination concern, and an on-chain registry would add gas, governance, and a permissioning chokepoint to a permissionless protocol. If global namespace discovery is later required, it can be layered via NodeRegistry metadata without changing this protocol.
  • Self-description. A node SHOULD advertise the namespaces it serves β€” via a GET /namespaces list or a namespaces field in /peers β€” so the mesh stays discoverable without a registry: a joining node learns what each peer carries directly from the peer, not from a gatekeeper.

Peer discovery

  • A node SHOULD expose GET /peers returning { protocol, node_version, signerAddress, peers: [{ url, signerAddress, healthy, lastSyncAt }] } for gossip-based discovery. A node MAY pull peer lists from peers it already syncs with, bounded per cycle to limit fan-out.
  • An OPTIONAL on-chain NodeRegistry provides permissionless discovery and the authoritative current signer for a node:
interface INodeRegistry {
    /// @notice Register or update the signer authorized for `url`.
    /// @dev `sig` is an ERC-191 signature over the registration message by the
    ///      authorizing key (see Node identity). The relayer (`msg.sender`) need
    ///      not be the signing key.
    function register(string calldata url, bytes calldata sig) external;

    /// @notice The currently authorized signer for `url`, or address(0).
    function signerOf(string calldata url) external view returns (address);
}

Node identity and key rotation β€” (two-tier: a cold identity key authorizes a rotatable signer key)

A node has two keys whose roles are deliberately separated:

  • an identity key β€” long-lived, MAY be kept cold; it never signs records. The node's identity is nodeId = keccak256(identityKeyAddress).
  • a signer key β€” signs records and the VNI document; authorized by the identity key via a delegation.

A node MAY collapse both tiers into a single key (identity key == signer key, no delegation). This is the conformant single-tier case, and the reference mesh runs this way today β€” each node's existing key is its identity key. Rotation requires a distinct signer key delegated by the identity key; a single-key node simply cannot rotate until it adopts one.

  • Delegation. The identity key signs (ERC-191) a delegation authorizing the current signer:
delegation = keccak256(abi.encode(nodeId, signerAddress, validFrom))

A node SHOULD expose GET /vni (the Verifiable Node Identity document). A node that supports signer rotation MUST expose at least one delegation-resolution path β€” GET /vni as specified here, or a NodeRegistry entry resolvable via signerOf (see Peer discovery) β€” otherwise a rotated signer is unverifiable and the node cannot rotate. When offered, /vni is normative and MUST return:

{
  "nodeId":        "0x…",       // keccak256(identityKeyAddress)
  "identityKey":   "0x…",       // identity key address
  "signerAddress": "0x…",       // currently delegated signer
  "delegation":    "0x…",       // ERC-191 sig by identityKey over keccak256(abi.encode(nodeId, signerAddress, validFrom))
  "validFrom":     1234567890,  // strictly increasing per nodeId
  "url":           "<string>",
  "version":       1,
  "timestamp":     1234567890,
  "signature":     "0x…"        // ERC-191 sig by signerAddress over the document
}

delegation is signed by the identity key; the document itself is signed by the signer key. A peer resolving a delegation MUST verify both: that delegation recovers to the pinned identityKey, and that signature recovers to signerAddress.

  • Pinning. Peers pin the nodeId (the identity key), not the signer. A node MUST accept records from whatever signer the pinned identity key currently delegates, and MUST reject any signer lacking a valid delegation from that identity key.
  • Rotation. To rotate the signer, the identity key issues a new delegation naming the new signer. Because identity is the cold identity key and not the hot signer, rotation changes no nodeId and invalidates no peer's pin.
  • Replay / rollback guard (validFrom). Every delegation carries a validFrom, and a node MUST reject any delegation whose validFrom is not strictly greater than the last it accepted for that nodeId. This monotonicity forecloses re-introducing a retired signer β€” including during the propagation window β€” and is the property that makes a rotation independently auditable.
  • Rotation propagation (transition window). Because peers pin the identity key and not the signer, a record from an unrecognized signer for a known nodeId MUST trigger a delegation re-resolution β€” re-fetch the node's /vni, or read NodeRegistry.signerOf β€” and MUST NOT be treated as a hard rejection. The delegation is self-certifying (signed by the pinned identity key), so a peer validates the new signer on first contact. If the delegation is not yet retrievable (the node is mid-rotation or briefly unreachable), the peer MUST defer the record and retry on the next sync cycle rather than drop it, so no records are lost while the delegation propagates. The prior signer need not remain valid; an operator MAY overlap signing with the prior key until propagation is confirmed, but the protocol does not require it.
  • On-chain anchor (optional). NodeRegistry maps nodeId / url to the identity key; signerOf MAY return the currently delegated signer. Because only the identity key authorizes registry updates, the on-chain record cannot be hijacked without it.

validFrom monotonicity (normative)

Strict per-nodeId monotonicity of validFrom is a hard floor, not a guideline. A node MUST reject any delegation whose validFrom is not strictly greater than the highest validFrom it has accepted for that nodeId, and this check MUST apply uniformly β€” including to delegations resolved on the rotation-propagation re-resolve path. Without a strict floor per nodeId, the rotation record is ambiguous under a race (two delegations carrying equal or out-of-order validFrom), and the re-resolve path loses its guarantee that the pinned identity key's current signer is the latest one it authorized. This is the same invariant the OCP closed registry enforces on its commit sequence: strict monotonicity is what makes the audit trail trustworthy.

Rationale

  • Pull, not push. CCIP-Read gateways are stateless request/response services. A pull model needs no inbound connections, no delivery guarantees, and no central broker; convergence comes from idempotent dedup, not coordination.
  • Declare-and-skip versioning keeps the protocol stateless. A negotiation handshake would force a round-trip and per-peer session state for no benefit at this layer; an integer plus optional ?protocol= selection covers forward compatibility.
  • No namespace registry. Namespaces behave like MIME types: collision-avoided by convention, not by a gatekeeper. Putting them on-chain would tax a permissionless protocol with gas and governance for an off-chain concern.
  • Two-tier keys. A naive nodeId = keccak256(signer) makes every rotation an identity change that breaks every peer's pin β€” the reference implementation does exactly this today and therefore cannot rotate. A cold identity key that delegates a hot signer key decouples the two: the rarely-used identity key has a low compromise surface, and rotation is a fresh delegation, not a new identity. A node MAY collapse both tiers into one key until it needs to rotate, so existing single-key deployments are conformant and migrate additively.
  • Packed encoding, deliberately. The record commitment uses abi.encodePacked with a single variable-length field bracketed by fixed-size fields β€” unambiguous, unlike packing two adjacent dynamic fields. This matches the deployed reference implementation byte-for-byte, so the live mesh is conformant with no signing change; a defensive abi.encode would gain nothing here and would needlessly break compatibility.

Backwards Compatibility

Additive. A node that implements only ERC-3668 CCIP-Read is unaffected and simply does not participate in the mesh. Adding /records to such a gateway makes it a mesh node without changing its CCIP-Read behavior.

Test Cases

A live record from the reference mesh (GET https://gateway.ensub.org/records?namespace=agent-attestations), shown with its commitment and recovered signer:

inputHash  = 0xa50f50918f5886e410ced5cc5d44ad0d35dc324fcf70d3e6d833580e4d3a3da7
namespace  = "agent-attestations"
value      = 0x7b226167656e744964223a2235...   (UTF-8 JSON attestation; full bytes served at the endpoint)
valueHash  = keccak256(value)
           = 0x86ef7df7c641ff5ac24a6011f2240b8f3590ddb7ec65d4cbc31482bf7b31db5a
timestamp  = 1779221791

commitment = keccak256(abi.encodePacked(
               bytes32 inputHash,
               string  namespace,
               bytes32 valueHash,
               uint64  timestamp))
           = 0x7f4b08ce408d831ba13b40ad02aa107676711f3c57e99dec9d6e28f7a280824b

signature  = 0xd428964bb569473721c5bd794c36f497a7145d03718436658f3143fd8648856448a314b46dfacb8202431c62cfe05d0274577e0eb93709161893f8c68b68bd771b
recovered  = recoverAddress( ERC-191 personal_sign over commitment, signature )
           = 0x85Fa13511D170FBe173761b63D7f8DD4A6f6Bf1A

The recovered signer is registered in the on-chain NodeRegistry under gateway.ensub.org. To verify a record: recompute valueHash and commitment from its fields, recover the signer from signature (ERC-191), and confirm that signer is authorized by the node's pinned identity (via /vni or NodeRegistry.signerOf).

Reference Implementation

ccip-router β€” live, multi-operator mesh on Ethereum mainnet. Source: Echo-Merlini/ccip-router; published to npm as ccip-router.

Live mesh (heterogeneous, bidirectional): four nodes serving dinamic.eth, three independent operators β€”

  • NAS router Β· gateway.gen-plasma.com
  • Railway router Β· ccip-router-production.up.railway.app
  • ENS Boiler gateway Β· gateway.ensub.org (a different stack implementing /records)
  • Damon's router node

Nodes also run different ccip-router package versions (e.g. 0.5.24 and 0.6.4) and interoperate at protocol version 1 β€” a live demonstration that the declare-and-skip versioning carries a mixed-version mesh with no flag-day.

On-chain (mainnet):

ContractAddress
NodeRegistry0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D
AttestationIndex0xc7BCCD785Fb994e570d0ca10D0F7899d87C82210

NodeRegistry provides the optional discovery described above. AttestationIndex is not part of this protocol β€” it is a consumer of it, indexing the commitment and signer of agent-attestations records so a relying party can locate an attestation on-chain and then fetch its bytes from any node in the mesh. It is listed here because it is the deployment that exercises the mesh in production, and because it answers the question this specification otherwise leaves open: what the replicated records are actually for.

A record originating on any node propagates to all peers within one sync interval; during a real Railway outage the NAS node served the full record set with no loss, and the mesh re-synced automatically on recovery.

The deployed nodes currently operate single-key (nodeId = keccak256(signerAddress)), which is the conformant single-tier case of Node identity and key rotation. Adopting the two-tier identity/signer split is additive: each node's existing key becomes its identity key, so every nodeId and on-chain NodeRegistry entry is preserved; nodes generate a hot signer key and a delegation only when they wish to rotate. No flag-day and no break to the live mesh.

Security Considerations

  • Signer pinning vs. rotation. Peers pin the identity key (nodeId), not the signer (see Node identity and key rotation), so a signer cannot be silently swapped. A rotation is a fresh delegation and MUST be signed by the node's identity key; a node MUST reject any delegation not signed by the pinned identity key, and MUST reject any record whose signer lacks a valid delegation from it.
  • Stale rotation / replay. Each delegation carries a validFrom; a node MUST reject any delegation whose validFrom is not strictly greater than the highest it has accepted for that nodeId (see validFrom monotonicity). This strict per-nodeId floor prevents rollback to a retired signer, including on the rotation-propagation re-resolve path.
  • Namespace collision. Without a registry, two operators may pick the same namespace. Because records are also bound to their signer, collision causes no trust violation β€” only potential confusion; consumers SHOULD scope trust to known signers per namespace.
  • Record flooding / DoS. A node serves /records publicly; implementations SHOULD rate-limit, bound limit, and cap per-peer ingestion per cycle to resist a peer flooding the mesh with junk records (which remain unverifiable and thus non-replicating beyond the first hop, since each must carry a valid signature).
  • Hostile peer URL. sourcePeer and /peers gossip are self-reported; a node MUST verify a peer's signer via /vni or NodeRegistry before pinning, and MUST NOT grant a peer trust on the basis of a self-reported URL alone.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth