Category: EIPs / ERC Discussion
Tags: eip-3668, ccip-read, gateways, attestation
Co-authors: Tiago Merlini (@TMerlini), Damon Zwicker (OCP) @Damonzwicker , Vincent Wu (ERC-8263 / Composition Note) @VincentWu , Jimmy Shi (ERC-8274) @JimmyShi22
The gap
EIP-3668 precisely specifies how a client talks to a CCIP-Read gateway: the request format, the response envelope, the revert convention. It says nothing about how gateways talk to each other.
This is fine for the simplest case — one gateway, one operator. But as CCIP-Read adoption grows, three problems appear that a single gateway can’t solve:
-
Redundancy. If the gateway goes down, the ENS name (or any CCIP-Read resolver) is broken. There’s no standard way for a second gateway to serve the same namespace. This matters beyond simple uptime: a CCIP-Read dapp whose pages are pinned as IPFS CIDs has no frontend server to take down — but it still has a single gateway that can fail. A mesh sync protocol removes that last centralised dependency: any node that has synced the namespace can answer. Combined with CID-pinned pages, the result is a dapp with no single point of failure at either layer.
-
Auditability. What did the gateway actually receive and return? Without a signed, replicable record of each call there’s no way to verify the gateway didn’t tamper with the response.
-
Attribution. In a multi-party system (multiple agents, multiple nodes) who contributed what to the shared record set?
None of these are addressed by EIP-3668 itself, and there’s been no follow-up coordination standard.
A proposed protocol
We’ve been running a reference implementation for a few months and want to propose a minimal gateway-to-gateway sync protocol for discussion.
The core primitive is a single endpoint any EIP-3668 gateway can expose:
GET /records?namespace=<str>&since=<unix>&limit=<n>&cursor=<str>
Response:
{
"protocol": 1,
"node_version": "0.3.0",
"namespace": "agent-attestations",
"records": [
{
"inputHash": "0x...",
"namespace": "agent-attestations",
"key": "0x...",
"value": "0x...",
"timestamp": 1234567890,
"signature": "0x...",
"sourcePeer": null
}
],
"cursor": "1234567890|0xabc..."
}
A few deliberate design choices:
Cursor pagination over offset. Using a timestamp|inputHash composite cursor means records are never skipped at timestamp boundaries, even under concurrent writes. Pure offset pagination loses records when new ones are inserted between pages.
INSERT OR IGNORE deduplication. The composite primary key is (inputHash, namespace). The same record arriving from two peers is stored once. Gossip is safe to run freely.
Protocol version field. Nodes on different protocol versions skip each other with a warning rather than silently accepting malformed data.
Signer pinning. On first sync from a peer, the recovering signer address is stored. Subsequent records from a different signer are rejected — a compromised peer can’t inject records on behalf of another node.
Namespace scoping. Each pull is scoped to a namespace string. A node serving token-metadata and a node serving agent-attestations are fully isolated at the record level even if they peer with each other.
Layering attestation on top
The sync protocol above is transport-level — it just moves signed records around. We’ve also been working on what sits above it: a way to prove what the gateway received and what it returned, not just that it wrote a record.
The approach is to wrap any resolver function with a pipeline that:
-
Hashes the raw calldata:
rawInputHash = keccak256(calldata) -
Optionally applies a sanitization pipeline hash:
inputHash = keccak256(abi.encode(rawInputHash, pipelineHash))— or useskeccak256("IDENTITY_SENTINEL")if no sanitization was applied -
Hashes the response:
outputHash = keccak256(response) -
Computes a commitment:
commitmentHash = keccak256(agentId · modelHash · inputHash · outputHash · timestamp) -
Signs an EIP-712
WyriweAttestationstruct containing all of the above
These attestations sync alongside regular records using the same /records protocol, in a separate {namespace}:wyriwe namespace. Any peer can verify the EIP-712 signature and reconstruct the commitment hash independently.
The commitment hash can then be anchored on-chain — a single 32-byte write that makes the attestation immutable and queryable without trusting any single gateway.
Contracts deployed on Sepolia (Etherscan verified):
| Contract | Role | Address |
|---|---|---|
AttestationIndex |
ccip-router OCP-compatible commitment store | 0x107D706112225aC57eCf6692FBbDC283fb6E3698 |
NodeRegistry |
Node registration | 0x6be4966596A9CBaa7260ab6EbbFFA69bBC9a42b7 |
WyriweProofVerifier |
ERC-8274 IProofVerifier |
0x001eFFa0fD1D171b164808644678F3301d8EDC96 |
TruthAnchorV1 (ERC-8263 canonical — Vincent Wu) |
ERC-8263 reference contract · Mainnet | 0xe95d6a15966984c209a62a2c188828555eb5ec3d |
AttestationIndex is ccip-router’s own commitment store — signerOf[commitmentHash] + commitmentOf[inputHash]. It satisfies the OCP commitment invariant and is a valid OCP-compatible anchor, distinct from the ERC-8263 canonical contract. TruthAnchorV1 emits the canonical AnchorProof(uint8 agentIdScheme, bytes32 agentId, bytes32 proofHash, address operator, bytes aux) event; AttestationIndex is the transport-layer anchor the gateway writes to after each attested execution. The two are separate primitives by design.
How ccip-router connects to ERC-8263: ccip-router anchors its commitmentHash as the proofHash in TruthAnchorV1. ERC-8263’s proofHash is deliberately opaque — the same anchor layer serves OCP, WYRIWE, and zkML uniformly. ccip-router’s commitmentHash = keccak256(abi.encode(agentId, modelHash, inputHash, outputHash, timestamp)) is one canonical instantiation, not the definition. Full chain: inference runs → gateway signs WyriweAttestation producing commitmentHash → anchor(commitmentHash) called on TruthAnchorV1 as the proofHash → AnchorProof event emitted. To verify L3 anchoring, filter AnchorProof by proofHash topic (= your commitmentHash) via eth_getLogs and compare the anchoring block’s timestamp against execution time. V1 is event-only by design — no per-anchor storage cost. A synchronous on-chain view (IAnchorReader) is proposed for ERC-8263 v0.3.
WyriweProofVerifier implements ERC-8274 IProofVerifier — an L4-only attestation check: “did the authorized gateway for this agentId attest that inputHash produced outputHash?” Parameters:
-
inputHash,outputHash— explicit inference I/O commitments -
metadata=abi.encode(agentId, registry)— authorized signer identity -
proof=abi.encode(modelHash, rawInputHash, sanitizationPipelineHash, commitmentHash, timestamp, sig)— L4 cryptographic material
verify() recomputes commitmentHash from (agentId, modelHash, inputHash, outputHash, timestamp), reconstructs the EIP-712 digest from the full struct, and recovers the signer. No external calls. The gateway’s prior signature on the struct guarantees the rawInputHash → sanitizationPipelineHash → inputHash provenance chain; the verifier trusts it without re-validating. This closes the loop: a commitment coordinated across the mesh is settleable on-chain by any ERC-8274-compatible contract.
ENS wildcard resolution
One practical application of this stack is an ENS offchain resolver gateway. The reference implementation ships a withEns() wrapper that decodes resolve(bytes name, bytes data) calldata (the EIP-137 wildcard resolution pattern) and dispatches to a clean handler:
import { CcipRouter, withEns } from 'ccip-router'
const ccip = new CcipRouter({
resolver: withEns(async (name, record) => {
// name → "vitalik.eth"
// record → { type: 'addr' } | { type: 'addr', coinType: 60n }
// { type: 'text', key: 'avatar' } | { type: 'contenthash' }
return db.lookup(name, record) // return value string or null
}),
})
withEns() handles DNS wire-format decoding, selector dispatch (addr, addr(uint256), text, contenthash), and ABI encoding of the response. null maps to the correct zero value per record type. Unknown selectors return 0x rather than throwing.
The standalone node ships with a DB-backed ENS resolver by default. Records are managed from the admin panel — no code required. Any name pointing to this gateway via an on-chain CCIP-Read resolver is served automatically.
Composing with attestation. Put withEns inside withWyriwe so every ENS resolution carries a full WyriweAttestation — the raw calldata hash, model hash, input/output commitment, and EIP-712 signature:
resolver: withWyriwe(withEns(myResolver), attestationOpts)
Operator identity via SIWE. The admin dashboard now authenticates via Sign-In With Ethereum (EIP-4361). The authorized signer is the node’s own gateway key — the same key that signs every record. Holding the private key proves you operate the node; no separate password.
Settlement layer
The commitmentHash OCP anchors on-chain is the primitive any settlement contract needs before releasing funds. ERC-8274 proposes a minimal IProofVerifier interface for this L4 step — one verify(inputHash, outputHash, metadata, proof) call that any consumer can make without knowing which proof system sits underneath. The interface is scoped narrowly: “did an authorized party attest this inference result?” It does not validate L3 anchoring (the gateway’s responsibility) or the input provenance chain (already guaranteed by the gateway’s EIP-712 signature on the struct). This maps naturally to the oracle/multisig pattern — the gateway is the authorized attester, metadata carries its identity, proof carries the cryptographic evidence.
WyriweProofVerifier is the concrete ERC-8274 implementation for this stack, deployed on Sepolia at 0x001eFFa0fD1D171b164808644678F3301d8EDC96. inputHash is the shared cryptographic anchor tying every layer together — from raw calldata through sanitization pipeline through commitment through on-chain anchor through settlement.
What we’re looking for feedback on
-
The
/recordsprotocol itself. Is the cursor design sound? Should protocol version negotiation be more formal (e.g. aGET /protocolendpoint)? Are there failure modes we haven’t considered? -
Namespace design. Right now namespaces are arbitrary strings — operators must coordinate out of band. Should there be a registry, or is that unnecessary complexity?
-
Signer pinning trade-offs. Pinning on first sync is simple but means a node that rotates its key looks like a different node to peers. Is there a better identity primitive here? (We’ve been experimenting with VNI — a signed
{ nodeId, signerAddress, url }document wherenodeId = keccak256(signerAddress)so it’s stable across key rotations if the operator re-signs.) -
Whether this warrants a follow-up EIP. The
/recordsprotocol is simple enough that it could be a short informational EIP as a companion to EIP-3668. Or it may be better left as a de-facto standard that implementations converge on. Opinions welcome. -
Per-execution vs per-configuration commitment. The
commitmentHashproduced here is strictly per-execution — it bindsinputHash,outputHash,agentId,modelHash, andtimestampfor a specific call. Whether a separate stable commitment describing the agent configuration (model, pipeline) should be anchored once and referenced across executions is an open question. Carrying it here since it sits at the transport layer where it would need to be surfaced.
Reference implementation
ccip-router v0.3.0 — npm package + standalone node with setup wizard, admin dashboard, and all of the above wired up:
-
GitHub + integration guide: https://github.com/Echo-Merlini/ccip-router
v0.3.0 includes: WYRIWE_PROOF_VERIFIER_ABI export for WyriweProofVerifier (ERC-8274 IProofVerifier), withEns() ENS wildcard resolver wrapper, DB-backed ENS records (managed from admin panel, no code required), SIWE admin auth (EIP-4361, gateway key = admin identity), isEnsCalldata() guard for multi-purpose resolvers.
This work connects to WYRIWE, OCP (Damon), ERC-8263 (Vincent), ERC-8004, ERC-8274, and ERC-8275 — earlier threads on those specs are the right place to discuss the attestation and settlement layers themselves. This thread is specifically about the gateway coordination layer.
Co-authored with Damon Zwicker (OCP), Vincent Wu (ERC-8263 / Composition Note), and Jimmy Shi (ERC-8274).
