The coordination layer CCIP-Read was missing.
EIP-3668 defines how clients talk to CCIP-Read gateways. It says nothing about how gateways talk to each other. ccip-router fills that gap — peer sync, deduplication, signed records, and cryptographic attestation for any CCIP-Read gateway.
No ENS required. No agents required. Any CCIP-Read project can plug in a resolver and get a mesh-ready gateway in minutes.
→ Integration guide — install, quickstart, attestation setup, contracts, mesh join.
ccip-router is a general-purpose CCIP-Read gateway. The resolver function is yours — the mesh, signing, and attestation pipeline come wired up around it.
ENS name resolution
Point an ENS wildcard or offchainLookup resolver at your gateway. ccip-router handles the EIP-3668 /{sender}/{data}.json endpoint, signs every response, and replicates it to peer nodes — so your ENS names stay live even if one gateway goes down.
Native full web3 dapps Pin your app's pages as IPFS CIDs and set them as the ENS contenthash — the frontend has no server to take down. Serve dynamic data through ccip-router: any node in the mesh can answer a CCIP-Read request, so if one goes offline the others keep the app running. The ENS name ties both layers together on-chain. The result is a dapp with no single point of failure at either the frontend or the data layer.
Off-chain data for on-chain contracts
Any smart contract that uses OffchainLookup can delegate reads to ccip-router. Store token metadata, user profiles, game state, or permit trees off-chain and serve them through a verifiable gateway rather than a trusted API.
Audit trail for AI agents
Wrap an AI inference function with withWyriwe(). Every call gets a cryptographic receipt: what input the agent received, what model processed it, what it returned — EIP-712 signed and replicated across the mesh. Useful anywhere you need a tamper-evident log of AI output (legal, compliance, multi-agent workflows).
Model Context Protocol (MCP) gateway Run ccip-router as the transport layer for an MCP server. Tool calls arrive as CCIP-Read requests; attestations prove what the model saw and returned. Any peer can verify a past call without trusting your node.
Redundant resolver mesh
Run the same namespace across multiple nodes. Records sync every five minutes over GET /records. If one node goes offline its records are already on the others — no single point of failure, no custom failover logic.
Any verifiable off-chain lookup If your contract needs off-chain data and you want proof it wasn't tampered with, ccip-router gives you the signed record, the peer-replicated history, and an optional on-chain anchor — all from a single resolver function.
flowchart LR
Client(["CCIP-Read client\nbrowser / contract"])
subgraph Node["ccip-router node"]
Handler["GET /{sender}/{data}.json\nEIP-3668 handler"]
Resolver["Resolver fn\ncustom logic"]
Wyriwe["withWyriwe()\nEIP-712 attestation"]
DB[("SQLite\nWAL · dedup · cursor")]
Cron["sync cron\n*/5 * * * *"]
end
subgraph Peers["Peer mesh"]
NodeB["Node B"]
NodeC["Node C"]
end
subgraph Sepolia["Sepolia (chain 11155111)"]
AI["AttestationIndex\n0x107D…3698"]
NR["NodeRegistry\n0x6be4…42b7"]
end
Client -- "EIP-3668 request" --> Handler
Handler -- "{ data: 0x... }" --> Client
Handler --> Resolver --> Wyriwe --> DB
Cron -- "GET /records" --> NodeB & NodeC
NodeB & NodeC -- "signed records" --> DB
DB -. "publishAttestation()" .-> AI
DB -. "register(url, sig)" .-> NR
sequenceDiagram
participant Client as CCIP-Read client
participant GW as ccip-router
participant Res as Resolver fn
participant DB as SQLite
participant Chain as AttestationIndex
Client->>GW: GET /{sender}/{data}.json
GW->>Res: resolve(sender, calldata, namespace)
Res-->>GW: response bytes
Note over GW: withWyriwe() — attestation pipeline
GW->>GW: rawInputHash = keccak256(calldata)
GW->>GW: inputHash = rawInputHash (sentinel)<br/>or keccak256(abi.encode(raw, pipelineHash))
GW->>GW: outputHash = keccak256(response)
GW->>GW: commitmentHash = keccak256(agentId · modelHash · inputHash · outputHash · ts)
GW->>GW: EIP-712 sign WyriweAttestation
GW->>DB: INSERT OR IGNORE signed attestation record
GW-->>Client: { data: "0x..." }
Note over DB,Chain: async — admin-triggered batch publish
DB->>Chain: record(attestation, sig)
Chain-->>DB: signerOf[commitmentHash] anchored
graph TB
T["EIP-3668 · Transport\nCCIP-Read client-to-gateway"]
S["EIP-191 · Record signing\nkeccak256(inputHash · namespace · valueHash · ts)"]
W["WYRIWE · Input provenance\nsentinel path: inputHash = rawInputHash\nnon-sentinel: inputHash = keccak256(abi.encode(raw, pipelineHash))"]
I["ERC-8004 · Agent identity\nagentId · registryAddress declared on-chain"]
O["ERC-8281 (OCP) · Commitment shape\ncommitmentHash = keccak256(agentId · modelHash · inputHash · outputHash · ts)"]
P["ERC-8263 · On-chain anchor\ncommitmentHash → proofHash in TruthAnchorV1\nAnchorProof(agentIdScheme, agentId, proofHash, operator, aux)"]
A["EIP-712 · WyriweAttestation\nstructured signing · verifiable by any peer · synced by mesh"]
V["VNI · Node identity\nEIP-191 signed { nodeId · signerAddress · url · version · ts }"]
C["On-chain anchoring · Sepolia\nAttestationIndex — signerOf · commitmentOf\nNodeRegistry — register(url, sig)"]
T --> S --> W --> I --> O --> P --> A --> V --> C
All ccip-router contracts are permissionless — no owner, no admin. One deployment per chain serves all nodes.
| Contract | Sepolia address | Purpose |
|---|---|---|
AttestationIndex |
0x107D706112225aC57eCf6692FBbDC283fb6E3698 |
ccip-router's ERC-8281 (OCP)-compatible commitment store. Stores signerOf[commitmentHash] and commitmentOf[inputHash]. Valid ERC-8281 anchor — distinct from the ERC-8263 canonical contract. |
NodeRegistry |
0x6be4966596A9CBaa7260ab6EbbFFA69bBC9a42b7 |
Public directory of nodes. register(url, sig) proves key ownership via EIP-191 — the relayer (msg.sender) does not need to be the signing key. |
WyriweProofVerifier |
0x001eFFa0fD1D171b164808644678F3301d8EDC96 |
ERC-8274 IProofVerifier implementation. verify(inputHash, outputHash, abi.encode(agentId, registry), abi.encode(modelHash, rawInputHash, sanitizationPipelineHash, commitmentHash, timestamp, sig)) — recomputes ERC-8281 (OCP) commitment, recovers signer, returns bool. No external calls. |
WyriweAttestationVerifier (deprecated) |
0x9515D6e53D2D45C1CFE6181943ca11C150C2bf61 |
ERC-8183 IAttestationVerifier. Superseded by WyriweProofVerifier. |
ERC-8263 canonical reference contract (Vincent Wu, not ccip-router):
| Contract | Sepolia | Mainnet |
|---|---|---|
TruthAnchorV1 |
0x89EE9b68c3b2f50cbE9D0fC4Dc134939a0475c1C |
0xe95d6a15966984c209a62a2c188828555eb5ec3d |
TruthAnchorV1 emits the canonical AnchorProof(uint8 agentIdScheme, bytes32 agentId, bytes32 proofHash, address operator, bytes aux) event that ERC-8281 (OCP)'s ERC-8263 extraction rule is written against. AttestationIndex sits alongside it as the transport-layer commitment store — 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 of it, 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 the proofHash topic (= your commitmentHash) and compare the anchoring block's timestamp against your 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.
Deployed by 0xFf9a176577Fb42b6bc9c19fd05a241e8fCd0ca14 · Solc 0.8.24 · optimizer 200 runs.
| Contract | Mainnet address |
|---|---|
NodeRegistry |
0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D |
AttestationIndex |
0xc7BCCD785Fb994e570d0ca10D0F7899d87C82210 |
WyriweProofVerifier |
0xd8a09d830b27697e1b24e8c9800e562d20318a09 |
Nodes register their URL by signing keccak256("ccip-router:node:" + url) with their signing key. The relayer (msg.sender) can differ from the signing key — no ETH required in the hot key. Four nodes are currently registered: NAS (0x58766f90...), Railway primary (0x2048eADf...), ENS Boiler (0x85Fa1351...), and Damon's node (0x5e4F655f...).
Set NODE_REGISTRY=0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D on any mainnet node to enable on-chain registration via POST /admin/api/register.
ERC-8004 identity via NodeRegistry: Set AGENT_ID to your node's signer address padded to bytes32, REGISTRY_ADDRESS to the NodeRegistry address, and MODEL_HASH to keccak256("ccip-router:<name>:<nodeUrl>"). This gives each router node its own verifiable infrastructure identity — distinct from user-level ERC-8004 agent tokens.
# NAS node example
NODE_REGISTRY=0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D
AGENT_ID=0x00000000000000000000000058766f90ede2419feafd97c28bb0f0ddf951dc54
REGISTRY_ADDRESS=0x95a1e10D1508EF5CD11e3F4d296359c93f15e48D
MODEL_HASH=0x80d4afd92fa6918f6e3bf706d19b2680301e5015cf5c46ecf7f6b178cfd660fcEIP-3668 wildcard resolver with multi-signer support. Replaces the single signerAddress pattern with mapping(address => bool) authorizedSigners so any node in the mesh can sign a valid CCIP-Read response.
| Contract | Mainnet address |
|---|---|
OffchainResolver v2 |
0xB300e09e6C4f901409B809e7924CF68A2A429014 |
dinamic.eth is pointed at this contract. Four signers are authorized — one per active mesh node (router or gateway). If any node is down the ENS client falls back to the next URL automatically.
Source: contracts/OffchainResolver.sol
To use on Mainnet or Sepolia: open the admin panel → Deploy contracts → select the chain → "Use these addresses →". Canonical addresses are saved to config automatically, no deployment needed.
To deploy to another chain: open the admin panel → Deploy contracts → select the chain → connect wallet → three transactions (one per contract). No private key is stored — MetaMask signs everything in-browser.
Source: contracts/AttestationIndex.sol · contracts/NodeRegistry.sol · contracts/IProofVerifier.sol · contracts/WyriweProofVerifier.sol
import { CcipRouter } from 'ccip-router'
const ccip = new CcipRouter({
namespace: 'token-metadata',
db,
gatewayKey: process.env.GATEWAY_PRIVATE_KEY,
resolver: async (sender, calldata, namespace) => {
return encodeMyResponse(calldata)
},
})
app.route('/', ccip.hono())What you get:
- CCIP-Read handler (
/{sender}/{data}.json) - EIP-191 signed records written to SQLite on every call
- Mesh peer sync (
GET /records?since=&namespace=&limit=&cursor=) - Record deduplication — same
inputHashnever inserted twice - Admin dashboard at
/adminwith peer management + sync controls - Setup wizard at
/setupon first boot
withEns() decodes resolve(bytes name, bytes data) calldata (EIP-137 wildcard pattern), dispatches to a clean handler, and ABI-encodes the response. DNS wire-format, selector dispatch, and null-to-zero-value fallbacks are handled for you.
import { CcipRouter, withEns } from 'ccip-router'
import type { EnsResolverFn } from 'ccip-router'
const resolver: EnsResolverFn = 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 string or null
}
const ccip = new CcipRouter({
namespace: 'ens-offchain',
db,
gatewayKey: process.env.GATEWAY_PRIVATE_KEY,
resolver: withEns(resolver),
})Standalone mode: ENS records are managed from the admin panel ("ENS Records" panel — no code required). Any name pointing to this gateway via an on-chain CCIP-Read wildcard resolver is served automatically.
Compose with attestation:
resolver: withWyriwe(withEns(resolver), attestationOpts)Use isEnsCalldata(calldata) to safely gate withEns() in a multi-purpose resolver that also handles non-ENS calldata.
import { CcipRouter, withWyriwe } from 'ccip-router'
const ccip = new CcipRouter({
namespace: 'agent-attestations',
db,
gatewayKey: config.gatewayKey,
resolver: withWyriwe(myAgentResolver, {
gatewayKey: config.gatewayKey,
registryAddress: process.env.REGISTRY_ADDRESS as `0x${string}`,
agentId: process.env.AGENT_ID as `0x${string}`,
modelHash: process.env.MODEL_HASH as `0x${string}`,
chainId: 1,
// sanitizationCID: 'ipfs://Qm...', // omit for sentinel (identity) path
}),
})What withWyriwe() adds on top of basic:
- Triple-hash chain — two paths:
- Sentinel (default):
sanitizationPipelineHash = keccak256("IDENTITY_SENTINEL"),inputHash = rawInputHash - Non-sentinel (
sanitizationCIDset):sanitizationPipelineHash = keccak256(CID),inputHash = keccak256(abi.encode(rawInputHash, sanitizationPipelineHash))
- Sentinel (default):
- EIP-712
WyriweAttestationsigned with the gateway key on every resolver call - Attestation records persisted to
{namespace}:wyriwe— synced by the mesh automatically - Verifiable by any peer: recover signer from signature, match against known gateway address
ccip-router runs in two modes — any combination can form a mesh.
| Mode | Description | Typical host |
|---|---|---|
| Operator node | Admin dashboard, signing key, ENS records, attestation pipeline | Self-hosted (Docker / VPS / home server) |
| Public node | Mesh sync + CCIP-Read serving only. No admin surface, no key UI. Set DISABLE_ADMIN=true |
Any PaaS (Railway, Fly, Render) |
Both modes use the same Docker image and npm package. The difference is configuration only.
→ Full deployment guide — self-hosted operator setup, Railway one-click public node, reverse proxy config, Cloudflare Tunnel, multi-URL resolver, key generation, security notes.
The /health endpoint and admin peers panel report a role field for every node in the mesh:
| Role | Badge | Detection | Description |
|---|---|---|---|
| router | 🔵 blue | nodeVersion matches semver (\d+\.\d+) |
Full ccip-router node — peer sync, admin dashboard, attestation pipeline |
| gateway | 🟣 purple | nodeVersion is non-semver (e.g. "ens-boiler") |
Third-party gateway implementing GET /records — participates in mesh sync without running ccip-router |
| unknown | ⬛ grey | nodeVersion is null |
Version not yet discovered — role resolved on next sync tick |
Any CCIP-Read gateway that exposes GET /records in the mesh protocol format becomes a gateway peer. Router nodes pull its records exactly as they pull from other router nodes — fully heterogeneous mesh.
Four nodes serving dinamic.eth via OffchainResolver v2, forming a bidirectional heterogeneous mesh:
| Node | Role | URL | Signer | Tiers |
|---|---|---|---|---|
| ENS Boiler | 🟣 gateway node | https://gateway.ensub.org/lookup/{sender}/{data} |
0x85Fa1351… |
signed, erc8004, wyriwe, erc8281, vni, onChain |
| NAS node | 🔵 router node | https://gateway.gen-plasma.com/{sender}/{data} |
0x58766f90… |
signed, erc8004, wyriwe, erc8281, vni, onChain |
| Railway node | 🔵 router node | https://ccip-router-production.up.railway.app/{sender}/{data} |
0x2048eADf… |
signed, erc8004, wyriwe, erc8281, vni, onChain |
| Damon's node | 🔵 router node | https://ccip-router-production-3506.up.railway.app/{sender}/{data} |
0x5e4F655f… |
signed, erc8004, vni, onChain |
ENS Boiler runs ens-dynamic-kit — a separate gateway stack. It participates in the mesh as a gateway peer: it exposes GET /records in the ccip-router mesh protocol, so router nodes pull its attestation records on every sync tick. All nodes sync bidirectionally — records originating on any node propagate to all others within one sync interval.
All registered signers are authorized on the mainnet resolver. ENS clients try URLs in order — any live node produces a verifiable response.
All four nodes are registered in the mainnet NodeRegistry. Each node's ERC-8004 identity uses its signer address as agentId — infrastructure identity, not a user-level NFT agent.
Mesh resilience in practice: during a Railway SG3 storage outage (June 2026), the primary Railway node went offline entirely. The NAS node continued serving all 35 records with all 6 tiers green throughout the outage. The sync cron logged Railway as unreachable on every cycle and moved on — no manual intervention, no record loss, no downtime on the namespace. Railway came back and the mesh re-synced automatically. A single CCIP-Read gateway has no fallback; the mesh keeps serving as long as one node is up.
npm install
npm run dev
# → open http://localhost:3000/setup
# → step 1: generate or import your signing key
# → step 2: optional Bearer secret for CLI access
# → step 3: namespace, port, sync interval
# → step 4: confirm → config.json written, node restarts
# → /admin/login: connect any MetaMask wallet → first signer claims admin
# → /admin: dashboard readyOr configure via environment (no wizard needed):
cp .env.example .env
# set GATEWAY_PRIVATE_KEY, ADMIN_SECRET, PEERS, etc.
npm run devSecurity: never put
GATEWAY_PRIVATE_KEYor other secrets inline in a compose file. Useenv_file:pointing to achmod 600file, or your platform's secrets UI (Railway Variables, Fly secrets, etc.). See DEPLOYMENT.md for the recommended pattern.
| Variable | Required | Default | Description |
|---|---|---|---|
GATEWAY_PRIVATE_KEY |
Yes* | — | 32-byte hex signing key (0x...). Without it the node runs in dry-run mode (unsigned records). |
ADMIN_SECRET |
No | — | Protects /admin. Set for any non-local deployment. Without it the dashboard is open. |
ADMIN_ADDRESS |
No** | — | Ethereum address of the admin wallet. Locks admin permanently — survives restarts, overrides config.json. Required on stateless deployments (Railway, Fly, Render). |
PORT |
No | 3000 |
HTTP port |
DB_PATH |
No | ./data.db |
SQLite file path |
SYNC_NAMESPACE |
No | agent-attestations |
Record namespace — peers must match |
SYNC_INTERVAL |
No | */5 * * * * |
Cron expression for peer sync |
PEERS |
No | — | Comma-separated peer URLs |
AGENT_ID |
No | — | ERC-8004 agent identity (bytes32 hex). Enables /identity endpoint. |
REGISTRY_ADDRESS |
No | — | ERC-8004 on-chain registry address. Required alongside AGENT_ID. |
CHAIN_ID |
No | 1 |
Chain where the ERC-8004 registry is deployed. |
ATTESTATION_INDEX |
No | — | Deployed AttestationIndex contract address. Enables on-chain anchoring. |
NODE_REGISTRY |
No | — | Deployed NodeRegistry contract address. Enables on-chain node registration. |
RPC_URL |
No | — | JSON-RPC endpoint. Required alongside ATTESTATION_INDEX. |
MODEL_HASH |
No | — | keccak256 of model weights CID. Required to activate WYRIWE attestation. |
NODE_URL |
No | — | This node's public URL. Required for VNI (signed node identity). |
AUTO_DISCOVER |
No | true |
Pull peer lists from synced peers automatically. |
CDN_PROVIDER |
No | — | pinata or storacha. Enables IPFS upload from admin panel. |
CDN_API_KEY |
No | — | API key / JWT for the configured CDN provider. |
NETWORK_KEY |
No | — | Ethereum address. Messages signed by this key are marked as official network announcements. |
DISABLE_ADMIN |
No | false |
Set true to skip mounting /admin and /static entirely. Recommended for public PaaS nodes. |
RESOLVER_ADDRESS |
No | — | Deployed OffchainResolver contract (informational — shown in spec audit). |
* Can also come from config.json written by the setup wizard.
** Optional on persistent deployments; required on stateless ones — see Self-hosted vs stateless below.
The admin authentication model differs depending on whether your deployment has a persistent filesystem.
config.json survives restarts. Admin is claimed on first login via SIWE and the address is written to disk — it persists across reboots.
GATEWAY_PRIVATE_KEY=0x... # required
ADMIN_SECRET=... # recommended — Bearer fallback for CLI / scripts
# ADMIN_ADDRESS not needed — claimed via browser wallet on first loginFlow: boot node → visit /admin/login → connect MetaMask → first wallet to sign claims admin permanently. Transfer admin via the dashboard if you need to change it later.
The container filesystem is ephemeral — config.json is wiped on every redeploy. Without ADMIN_ADDRESS set, the node returns to unclaimed state after every restart and the first wallet to visit /admin/login claims admin.
Set ADMIN_ADDRESS in your platform's environment variables UI to lock admin permanently:
GATEWAY_PRIVATE_KEY=0x... # required — use platform secrets UI, not inline
ADMIN_SECRET=... # required — no filesystem to protect with file perms
ADMIN_ADDRESS=0x<your-wallet> # required — locks admin across all redeploysADMIN_ADDRESS takes precedence over config.json and is immutable at runtime — setAdminAddress and the SIWE reset endpoint are both no-ops when this env var is present. To change the admin wallet, update the env var in your platform dashboard and redeploy.
Recovery: if you get locked out (wrong wallet in MetaMask, or session lost after restart), the login page shows the expected wallet address with a copy button. On a session reset (restart), just reconnect with the correct wallet. If you've genuinely lost access, update ADMIN_ADDRESS in the platform dashboard — no SSH required.
On Railway: set
ADMIN_ADDRESS,GATEWAY_PRIVATE_KEY, andADMIN_SECRETin the Variables tab. Use Railway's secret injection — never put keys inline in a Dockerfile orrailway.json.
Visit /admin after setup. Features:
- Live stats: record count, peer count, last sync time
- Peer panel: add/remove peers, per-peer health + signer address + last sync
- Recent records panel: local vs peer-synced, timestamps
- ENS records panel — add/edit/delete addr, text, contenthash records without a restart
- Manual sync trigger
- Auto-refresh every 15 seconds
Peer discovery: The peers panel has a ⊕ Discover button that queries the on-chain NodeRegistry and lists all registered nodes not yet added as peers — with live health checks and one-click connect. Requires NODE_REGISTRY or REGISTRY_ADDRESS and RPC_URL to be configured.
Join Requests: New nodes that want to join the mesh can post a signed join request to POST /join-request on any known node. The admin panel shows pending requests in a Join Requests panel (polled every 30 s, red badge when pending). Approve → MetaMask → calls NodeRegistry.register(url, sig) on-chain. Decline drops the request. The on-chain registry remains permissionless — this is soft governance on top of it. See Joining the mesh below.
IPFS & browser resolution: The IPFS panel supports an optional Resolver address field. Leave blank to use the ENS Public Resolver; fill in a custom address to target any resolver that implements setContenthash(bytes32 node, bytes hash) — e.g. a CCIP-Read resolver like dinamic.eth.
Header role badge: A ROUTER / GATEWAY / UNKNOWN badge in the top-right header shows this node's role derived from its version string — same colour scheme as the peers panel badges.
Auth — claim on first login (EIP-4361 SIWE): On a fresh node the login page shows an amber "Unclaimed node" banner. Connect any browser wallet and sign once — that wallet address is saved to config.json as the permanent admin. Subsequent logins must match that address. Admin wallet is completely decoupled from the gateway signing key (GATEWAY_PRIVATE_KEY stays server-side).
On stateless deployments (Railway, Fly, etc.), set
ADMIN_ADDRESSas an env var instead —config.jsonis wiped on every restart. See Self-hosted vs stateless.
Transfer admin: While logged in, open the "Admin wallet" panel → Transfer. Switch MetaMask to the new wallet, sign a transfer message to prove ownership — adminAddress is updated live and a new session is issued, no restart required.
Wrong wallet / locked out: The login page shows the expected wallet address with a copy button and a "switch wallet" instruction. If you need to recover without the correct wallet, enter your ADMIN_SECRET in the recovery section — this calls POST /admin/siwe/reset to clear adminAddress back to unclaimed state. Disabled when ADMIN_ADDRESS is set via env var (update the env var in your platform dashboard instead).
Bearer fallback: Authorization: Bearer <ADMIN_SECRET> always works for CLI / scripts regardless of SIWE state.
Stack status row: A compact pill row below the header shows which tiers are active — Signing / ERC-8004 / WYRIWE / ERC-8281 / ERC-8263 / VNI / On-chain — derived from /admin/api/status. Green = active, grey = unconfigured.
Node logs panel: Live ring buffer of the last 200 log lines (info/warn/error), colour-coded. Auto-refreshes every 10 seconds.
Uses Hardhat dev keys — safe for local testing only, never use in production.
Terminal 1 — node A
PORT=3001 DB_PATH=./node-a.db \
GATEWAY_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
PEERS=http://localhost:3002 SYNC_INTERVAL="*/1 * * * *" \
npm run devTerminal 2 — node B
PORT=3002 DB_PATH=./node-b.db \
GATEWAY_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
PEERS=http://localhost:3001 SYNC_INTERVAL="*/1 * * * *" \
npm run devdocker compose up --build# trigger a CCIP call on node A — writes a signed record
curl http://localhost:3001/0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266/0xdeadbeef
# check node A recorded it
curl http://localhost:3001/health | jq .records # → 1
# wait ~1 minute for sync cron, then check node B
curl http://localhost:3002/health | jq .records # → 1 (synced from A)
# verify by inputHash on node B
curl http://localhost:3002/verify/<inputHash>GET /{sender}/{data}.json
→ { data: "0x..." } EIP-3668 response
GET /records?namespace=<str>&since=<unix>&limit=<n>&cursor=<str>
→ {
protocol: 1,
node_version: "0.2.0",
namespace: "agent-attestations",
records: [{ inputHash, namespace, key, value, timestamp, signature, sourcePeer }],
cursor: "<next>" | null
}
GET /ocp/:inputHash
→ {
inputHash,
found: true,
commitmentHash: "0x...",
observation: { agentId, modelHash, inputHash, outputHash, timestamp },
namespace, sourcePeer
}
→ { inputHash, found: false } (404 — no WYRIWE attestation for this inputHash)
GET /verify/:inputHash
→ {
inputHash,
found: true,
proofs: [
{
namespace: "agent-attestations",
signingType: "EIP-191",
verified: true,
signer: "0x...",
signature: "0x...",
timestamp: 1234567890,
sourcePeer: null | "https://..."
},
{
namespace: "agent-attestations:wyriwe",
signingType: "EIP-712 WyriweAttestation",
verified: true,
signer: "0x...",
signature: "0x...",
timestamp: 1234567890,
attestation: { agentId, registry, modelHash, rawInputHash,
sanitizationPipelineHash, inputHash, outputHash }
}
]
}
→ { inputHash, found: false } (404)
GET /identity
→ { declared: true, agentId, registryAddress, chainId, namespace, signerAddress }
→ { declared: false } (404 — AGENT_ID not configured)
GET /vni
→ { nodeId, signerAddress, url, version, timestamp, signature }
→ { declared: false } (404 — NODE_URL not configured)
GET /peers
→ { protocol: 1, node_version, signerAddress, peers: [{ url, signerAddress, healthy, lastSyncAt }] }
GET /contributions
→ { namespace, contributions: [{ source, records }] }
POST /join-request
body: { url: string, signature: string }
signature: personal_sign of keccak256("ccip-router:node:" + url) with the node's gateway key
→ { ok: true, id, signerAddress } (stored as pending, visible in admin panel)
→ { error } (invalid signature, unreachable URL, etc.)
GET /health
→ {
ok, version, role: "router",
namespace, signerAddress,
identity: { agentId, registryAddress, chainId } | null,
tiers: { signed, erc8004, wyriwe, ocp, vni, onChain },
peers: [{ url, healthy, nodeVersion, role, signerAddress, lastSyncAt }],
records, ensRecords
}
role is "router" for ccip-router nodes. Gateway peers report the role derived from their nodeVersion field: semver → "router", non-semver → "gateway", unknown → "unknown".
GET /admin/siwe/nonce → { nonce, domain, chainId, authorizedAddress, claimed }
POST /admin/siwe/verify { message, signature }
unclaimed node → first caller claims admin, saved to config.json
claimed node → must match stored adminAddress
→ { ok, address, claimed, redirect }
POST /admin/siwe/transfer { message, signature } — signed by NEW wallet
current session required; updates adminAddress live
→ { ok, address }
POST /admin/siwe/reset { secret } — no session required
clears adminAddress → unclaimed state (recovery without SSH)
requires ADMIN_SECRET; no-op if ADMIN_ADDRESS env var is set
→ { ok, message } | { error }
POST /admin/logout clear session cookie
GET /admin/api/status node info, peers, recent records, tiers, adminAddress
GET /admin/api/logs last 200 log lines [{ ts, level, msg }]
GET /admin/api/audit per-spec compliance report (EIP-3668/WYRIWE/ERC-8004/ERC-8281/ERC-8263/VNI)
POST /admin/api/sync trigger immediate peer sync
POST /admin/api/publish batch-publish recent WYRIWE records to AttestationIndex
body: { limit?: number } (default 50, max 200)
→ { published, skipped, errors }
POST /admin/api/peers { url } — add peer
DEL /admin/api/peers { url } — remove peer
GET /admin/api/ens-records ?name= — list ENS records
POST /admin/api/ens-records { name, type, coinType?, textKey?, value } — upsert
DEL /admin/api/ens-records { name, type, coinType?, textKey? } — delete
GET /admin/api/config safe config snapshot (never exposes private key)
POST /admin/api/config update config fields → writes config.json, restarts node
POST /admin/api/key { gatewayKey } — rotate signing key → restart
POST /admin/api/register register node on-chain via NodeRegistry
GET /admin/api/peers/discover query NodeRegistry for registered nodes not yet added as peers
→ { nodes: [{ url, signerAddress, healthy, role, version, alreadyPeer }] }
requires NODE_REGISTRY (or REGISTRY_ADDRESS) + RPC_URL
GET /admin/api/join-requests list join requests ?status=pending|approved|declined (default: pending)
→ { requests: [{ id, url, signerAddress, status, healthOk, healthData, createdAt }] }
GET /admin/api/join-requests/:id/calldata
encode NodeRegistry.register(url, sig) for MetaMask
→ { to, data, chainId }
POST /admin/api/join-requests/:id/approve mark as approved (call after MetaMask tx sent)
POST /admin/api/join-requests/:id/decline mark as declined, hide from pending list
Any node can request to join by posting a signed message to an existing node. No ETH required — the existing node's admin submits the on-chain registration tx.
# 1. Generate the signature (sign keccak256("ccip-router:node:" + url) with your gateway key)
node -e "
const { keccak256, toBytes } = require('viem')
const { privateKeyToAccount } = require('viem/accounts')
const url = 'https://my-node.example.com'
const acc = privateKeyToAccount('0x<GATEWAY_PRIVATE_KEY>')
const hash = keccak256(toBytes('ccip-router:node:' + url))
acc.signMessage({ message: { raw: hash } }).then(sig => console.log(JSON.stringify({ url, signature: sig })))
"
# 2. POST to any known mesh node
curl -X POST https://ccip-router-production.up.railway.app/join-request \
-H 'Content-Type: application/json' \
-d '{"url":"https://my-node.example.com","signature":"0x..."}'
# → { "ok": true, "id": 1, "signerAddress": "0x..." }The admin of any receiving node will see the request in their Join Requests panel. On approval, MetaMask submits NodeRegistry.register(url, sig) — the recovering the signer from the signature means any relayer can submit without ETH in your hot key.
Soft governance caveat: the on-chain NodeRegistry.register() is permissionless — any node can also call it directly, bypassing the request flow. Hard on-chain quorum enforcement (N-of-N approval gate) is planned for a future contract upgrade.
Any CCIP-Read gateway implementing /records is mesh-compatible — ccip-router nodes pull from it exactly as they pull from other router nodes:
GET /records?since=<unix>&namespace=<string>&limit=<n>&cursor=<string>
→ { protocol: 1, node_version, namespace, records: [...], cursor: string | null }
Each record must be signed: signature = signMessage({ raw: keccak256(encodePacked([bytes32, string, bytes32, uint64], [inputHash, namespace, keccak256(value), timestamp])) }). The receiving node recovers the signer and pins it — subsequent records from the same peer must match the same key.
Protocol version 1 is the current stable spec. Nodes on a different version are skipped during sync with a warning.
Namespaces are application-defined and scoped at the record level:
agent-attestations— base CCIP-Read response recordsagent-attestations:wyriwe— WYRIWE EIP-712 attestations (auto-produced bywithWyriwe())token-metadata— NFT gateways- anything — define your own
Economic attribution (/contributions): every record carries a sourcePeer field. GET /contributions returns per-peer record counts for a namespace — the on-mesh accounting layer for usage-based incentive models. This is the substrate for a transactional compensation layer between nodes: operators whose records are consumed by peers can be rewarded proportionally via an escrow smart contract keyed to contribution counts.
| Spec | Layer | Role | Status |
|---|---|---|---|
| EIP-3668 | Transport | CCIP-Read client-to-gateway | ✅ implemented |
| WYRIWE | L2 Input trust | Triple-hash commitment, EIP-712 attestation | ✅ implemented |
| ERC-8004 | L1 Identity | Agent identity agentId + registryAddress in attestation |
✅ implemented |
| ERC-8281 (OCP) | L3 Commitment shape | keccak envelope binding agent, model, input, output, timestamp. AttestationIndex = transport-layer store. /ocp/:inputHash endpoint. |
✅ implemented |
| ERC-8263 | L3 On-chain anchor | commitmentHash carried as proofHash in TruthAnchorV1 (Vincent Wu), emitting AnchorProof. The same opaque anchor layer serves OCP, WYRIWE, and zkML uniformly. |
✅ implemented |
| EIP-712 | L4 Attestation | Structured signing (via withWyriwe) |
✅ implemented |
| VNI | L5 Node Identity | Signed node identity, peer gossip | ✅ implemented |
| ERC-8275 | L6 Economics | Contribution attribution (/contributions — per-peer record counts, foundation for usage-based node compensation) |
✅ implemented |
-
CCIP-Read gateway (EIP-3668)
-
SQLite record store — WAL mode, composite PK
(inputHash, namespace), cursor pagination -
DB versioned migrations (
schema_versiontable, v1 applied on first boot) -
EIP-191 signed records (basic tier)
-
Mesh peer sync with protocol version check
-
Setup wizard (
/setup) — key generation, config.json persistence -
Admin dashboard (
/admin) — peers, records, sync -
Admin auth — cookie session + Bearer token (
ADMIN_SECRET) -
withWyriwe()— EIP-712 attestation, triple-hash chain, IDENTITY_SENTINEL path -
/verify— clean proof per namespace:{ verified, signer, signingType, signature, attestation } -
ERC-8004 identity —
AGENT_ID+REGISTRY_ADDRESS+CHAIN_ID,/identityendpoint,/healthfield -
ERC-8281 (OCP) —
commitmentHashinWyriweAttestation,/ocp/:inputHashendpoint -
ERC-8263 —
commitmentHashasproofHashinTruthAnchorV1,AnchorProofevent anchor layer -
Router SVG favicon, dinamic.eth design language
-
Peer signer pinning — reject records with unexpected signer after first sync
-
Peer health polling — fetch
/healthafter every sync, populatenodeVersion+signerAddress -
Graceful shutdown —
SIGTERM/SIGINT→server.close()→db.close()→process.exit(0) -
In-memory log ring buffer (200 lines, console-patched) →
/admin/api/logs+ colour-coded log panel -
Stack status pills in admin header bar — Signing / ERC-8004 / WYRIWE / ERC-8281 / ERC-8263
-
Library re-export (
src/lib.ts) —CcipRouter,withWyriwe,IdentityOpts,WyriweOpts,ResolverFn, DB types -
withWyriwe()non-sentinel path —sanitizationCIDoption;inputHash = keccak256(abi.encode(rawInputHash, sanitizationPipelineHash)) -
Setup wizard reconfigure flow — pre-fills current config, "Keep existing key",
/setup/current-configendpoint, inherited admin secret -
Spec audit accordion panel in admin — per-spec cards (EIP-3668 / WYRIWE / ERC-8004 / ERC-8281 / ERC-8263), inline summary pills, expandable detail grid with missing-config hints
-
contracts/AttestationIndex.sol— on-chain anchor for WyriweAttestations; verifies EIP-712 sig against ERC-8004 registry domain, storessignerOf[commitmentHash]+commitmentOf[inputHash] -
src/chain/— viem public + wallet clients,publishAttestation(),checkOnChain() -
/verifyon-chain fallback — ifinputHashnot in local DB andATTESTATION_INDEX+RPC_URLconfigured, queries contract and returns on-chain proof -
POST /admin/api/publish— batch-publish recent WYRIWE records toAttestationIndex; skips already-anchored; "Publish to chain" button in spec audit panel -
Open node network —
GET /peersgossip endpoint; auto-discovery pulls peer lists during sync (bounded at 10/cycle, disable withAUTO_DISCOVER=false) -
VNI (Verifiable Node Identity) —
GET /vnireturns EIP-191 signed{ nodeId, signerAddress, url, version, timestamp }; peers verify during sync for authoritative signer resolution -
contracts/NodeRegistry.sol— on-chain node directory;register(url, sig)proves key ownership;POST /admin/api/register+ "Register on-chain" button in VNI spec card -
ERC-8275 economics (MVP) — contribution attribution via
getContributions(namespace);GET /contributions; per-peer record counts surfaced in spec audit panel -
Config:
NODE_URL,NODE_REGISTRY,AUTO_DISCOVER;/healthexposestiers.vni+tiers.onChain
Stack status bar
- Add VNI + On-chain tier pills
- Click signer address pill to copy to clipboard (green flash feedback)
Node info & layout
- Move node info bar above the peers/records panels; add namespace field
- Toast-based error feedback in add-peer form (replaces
alert())
Node config panel (in-dashboard, no env editing required)
- Full config panel — Core / Signing / Network / Identity / Chain / Admin sections
-
GET /admin/api/config— safe config snapshot (signer address, never the key) -
POST /admin/api/config— writesconfig.json, preserves gateway key, restarts node - Auto-discover toggle, seed peers textarea, unsaved-changes indicator
Wallet & signing
- Signing key panel — generate or import, rotate with identity-change warning,
POST /admin/api/key - Dry-run banner — shown when no key configured, "Configure key →" scrolls to key panel
Setup wizard — node owner onboarding
- Admin secret as dedicated step 2 — prominent warning box, two-step skip confirmation
- Post-setup checklist — signing ✓, admin ✓/⚠, WYRIWE/ERC-8004/VNI ○ with next-step hints
- Spawn-based node restart (setup + config save) — works without a process manager
- Claim-on-first-login — first MetaMask wallet to sign becomes permanent admin, no pre-configuration required
- Admin transfer — logged-in admin proves new wallet ownership via SIWE,
adminAddressupdated live with no restart - ENS records panel — live table, add/delete addr / text / addr_coin / contenthash records, changes take effect immediately
- Admin wallet panel in dashboard — current address, two-step transfer UI
-
withEns()— ENS wildcard resolver wrapper; DNS wire-format decode, selector dispatch (addr / addr_coin / text / contenthash), null → zero-value fallbacks,isEnsCalldata()guard -
OffchainResolver.solv2 — multi-signer viamapping(address => bool) authorizedSigners;addSigner/removeSigner; deployed to mainnet (0xB300e09e6C4f901409B809e7924CF68A2A429014); dinamic.eth updated - Live 3-node mesh — ENS Boiler + NAS ccip-router + Railway ccip-router all authorized signers; any node can serve a verifiable CCIP-Read response
- ENS Boiler dual-write sync — every admin record upsert also pushes to ccip-router via
ccipRouterSync.ts -
RESOLVER_ADDRESSconfig field — shown in spec audit panel - WyriweProofVerifier deployed to Ethereum mainnet (
0xd8a09d830b27697e1b24e8c9800e562d20318a09) — ERC-8274IProofVerifierparity with Sepolia -
KNOWN_DEPLOYMENTSin admin panel includes Ethereum mainnet — one-click canonical contract setup for operators - All 3 mesh nodes registered in mainnet NodeRegistry with correct EIP-191 signing (relay pattern — no ETH required in hot key)
- ROUTER / GATEWAY / UNKNOWN role badges in admin peers panel — derived from
nodeVersion(semver → router, non-semver → gateway) -
/healthrolefield at node level and per-peer — exposes mesh topology to any client -
GET /recordson ENS Boiler (ens-dynamic-kit) — gateway nodes now participate bidirectionally; router nodes pull attestation records from gateway peers on every sync tick - Heterogeneous mesh — any stack implementing the
/recordsprotocol joins the mesh regardless of language or runtime - Peer discovery from NodeRegistry — "⊕ Discover" button in peers panel queries
NodeRegistryon-chain and lists registered nodes not yet added, with health check + one-click connect - Role badge in admin header — ROUTER / GATEWAY / UNKNOWN derived from node's own version, replaces namespace pill
- 4th mesh node registered on mainnet NodeRegistry — bidirectional sync verified
- Peer discovery fall-through —
REGISTRY_ADDRESSaccepted as fallback whenNODE_REGISTRYis unset - Join request flow —
POST /join-request(public) + admin Join Requests panel; approve → MetaMask →NodeRegistry.register(); soft governance layer over permissionless registry - DB migration v4 —
join_requeststable (unique onsigner_address, status lifecycle) - IPFS panel custom resolver field — optional; falls back to ENS Public Resolver; enables
setContenthashon any CCIP-Read resolver (e.g. dinamic.eth) - Version panel update guidance — Watchtower / Docker / Railway / npm paths documented inline
- Recent records panel spans full grid width; scrollbar transparent track
The test suite uses the Node.js built-in test runner (node:test) with tsx for ESM TypeScript — no extra test framework required.
npm testExpected output:
ℹ tests 61
ℹ suites 22
ℹ pass 61
ℹ fail 0
| File | Coverage |
|---|---|
src/__tests__/gateway.test.ts |
decodeRequest — address + calldata parsing, .json suffix stripping, CcipRequestError on bad inputs; encodeResponse envelope |
src/__tests__/crypto.test.ts |
signRecord / recoverRecordSigner round-trip; verifyRecord correct signer → true, wrong signer / tampered value → false |
src/__tests__/db.test.ts |
insertRecord, getRecord (with/without namespace), getRecordsByInputHash, INSERT OR IGNORE deduplication, cursor pagination, getContributions grouping, peer upsert + remove, ENS record upsert/delete/list |
src/__tests__/ocp.test.ts |
buildCommitmentHash determinism, 32-byte hex output, field-sensitivity (agentId / outputHash / timestamp) |
src/__tests__/wyriwe.test.ts |
Sentinel path (inputHash === rawInputHash), non-sentinel path (keccak256(abi.encode(rawInputHash, sanitizationPipelineHash))), paths produce distinct hashes for same calldata |
src/__tests__/vni.test.ts |
makeVni field shape + stable nodeId; verifyVni round-trip; tamper detection (url / signerAddress / nodeId → null) |
src/__tests__/ens.test.ts |
DNS wire-format encode/decode round-trip; withEns() dispatch for all 4 record types (addr, addr_coin, text, contenthash); null → zero-value fallbacks; unknown selector → 0x; wrong outer selector throws |
Tests use SQLiteDB(':memory:') directly (bypassing the runtime singleton) and Hardhat dev key 0 for any signing operations — both are safe to commit and require no external services.
- ens-boiler — opinionated ENS agent stack built on
ccip-router - WYRIWE — input provenance spec
- ERC-8281 (OCP) — observation commitment protocol
Author: Tiago Merlini — dinamic.eth Co-authors: Damon Zwicker (OCP / ERC-8281) · Vincent Wu (ERC-8263 / ERC-8004)