From f332bfab76f35b6b8bc68328f682dbde1c947667 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 14:57:01 +0200 Subject: [PATCH 01/16] docs: W2 implementation plan (CCIP-Read ENS gateway) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 tasks across 7 milestones (M1 OffchainResolver contract → M2 gateway lib → M3 HTTP route → M4 frontend + /ens-debug → M5 deploy + agentlab.eth resolver flip → M6 e2e → M7 PR + walkthrough). W2-α trusted gateway flavor; resolver designed for swap-in to W2-β storage proofs later. Depends on W1 merging — cross-links to AgentINFT.memoryReencrypted and oracle Redis for inft-tradeable + memory-rotations text records. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-29-w2-ens-gateway.md | 1364 +++++++++++++++++ 1 file changed, 1364 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-w2-ens-gateway.md diff --git a/docs/superpowers/plans/2026-04-29-w2-ens-gateway.md b/docs/superpowers/plans/2026-04-29-w2-ens-gateway.md new file mode 100644 index 0000000..806b530 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-w2-ens-gateway.md @@ -0,0 +1,1364 @@ +# W2 — Dynamic CCIP-Read ENS gateway implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the static `setText` cron-driven ENS records on `*.agentlab.eth` with an EIP-3668 offchain resolver served by a signed Vercel gateway. Live `last-seen-at`, `reputation-summary`, `outstanding-bids`, `inft-tradeable`, and `memory-rotations` for every agent — zero gas per read. + +**Architecture:** Deploy a single `OffchainResolver` contract on Sepolia that reverts `OffchainLookup` for any `*.agentlab.eth` query. The L1 ENS registry's resolver slot for `agentlab.eth` is set to this contract. wagmi/viem clients auto-handle the revert per CCIP-Read, hitting our Vercel gateway endpoint. The gateway computes the requested record value from Redis + on-chain reads + Edge Config, signs the response with `INFT_GATEWAY_PK` (W2-α trusted gateway flavor), and the resolver's `resolveWithProof` callback verifies the signature with `ecrecover`. ENSIP-10 wildcard means every new agent gets ENS records for free. + +**Tech Stack:** Solidity 0.8.28 (Foundry), TypeScript (Next.js 16 App Router on Vercel), viem, EIP-3668, EIP-712, ENSIP-10, ENSIP-25. + +**Spec:** [docs/superpowers/specs/2026-04-28-agent-identity-package-design.md](../specs/2026-04-28-agent-identity-package-design.md), W2 sections. + +**Branch:** `feat/w2-ens-gateway` (already created). + +**Depends on:** W1 (merged) — `inft-tradeable` and `memory-rotations` records cross-link into the AgentINFT contract address and the oracle's Redis. The `agentInftAddress` and `inftTokenId` are already in Edge Config from M7. The W1 INFT contract's `memoryReencrypted(tokenId)` and the oracle's `inft:meta::rotations` are the read sources. + +--- + +## File map + +### New contracts +- Create: `contracts/src/OffchainResolver.sol` — implements `IExtendedResolver.resolve()` reverting `OffchainLookup`; `resolveWithProof()` verifies gateway signature. +- Create: `contracts/test/OffchainResolver.t.sol` — unit tests for revert format, sig verification, replay/expiry handling. +- Create: `contracts/script/DeployOffchainResolver.s.sol` — deploy + write deployment artifact. + +### New TypeScript +- Create: `lib/ens-gateway.ts` — record value computation (label → agent → live data), signing, ABI encoding per resolve selector. +- Create: `lib/ens-records.ts` — typed helper for frontend (replaces direct `useEnsText` calls with cache + types). +- Create: `app/api/ens-gateway/[sender]/[data]/route.ts` — the gateway endpoint per EIP-3668 GET URL pattern (also POST). Handles `text`, `addr`, `addr(coinType)`, `contenthash`. +- Create: `app/api/ens-gateway/cache/invalidate/route.ts` — internal route called by KeeperHub on chain-event triggers (W3 cross-link, but the route is built here). +- Create: `app/ens-debug/page.tsx` — small page that surfaces the OffchainLookup revert, gateway URL, signed response, and ecrecovered signer for any `(name, key)` query. Demo gold. +- Create: `scripts/test-ens-gateway-e2e.ts` — full e2e test against deployed contract + live gateway (mirrors W1's e2e posture). +- Create: `scripts/set-agentlab-resolver.ts` — one-shot script to set the OffchainResolver as agentlab.eth's resolver via the Sepolia ENS registry. + +### Modified TypeScript +- Modify: `lib/abis/OffchainResolver.json` (generated by sync-abis). +- Modify: `scripts/sync-abis.ts` — add OffchainResolver to ABI sync list. +- Modify: `app/inft/page.tsx` — read `inft-tradeable` and `memory-rotations` via ENS lookups (proves the gateway works for the demo). +- Modify: `lib/edge-config.ts` — read `ensResolver`, `ensGatewayUrls` (new keys). + +### Env / config +- Modify: `.env.example` — add `INFT_GATEWAY_PK`. +- Vercel env: set `INFT_GATEWAY_PK` Production + Preview. +- Edge Config: add `ensResolver`, `ensGatewayUrls`. + +--- + +## Milestones and "ALL GREEN" gates + +| # | Phase | Gate (must hold before proceeding) | +|---|---|---| +| M1 | OffchainResolver contract + tests | `forge test --match-contract OffchainResolverTest` passes | +| M2 | ens-gateway lib + record computation | `pnpm typecheck` passes; ad-hoc smoke prints expected ABI bytes per record | +| M3 | Gateway HTTP route | Local `curl /api/ens-gateway//` returns well-formed `{data}` | +| M4 | Frontend ens-records helper + /inft cross-link reads + /ens-debug page | `pnpm typecheck` + `pnpm build` clean | +| M5 | Deploy + agentlab.eth resolver flip | `cast call ENS owner` confirms ownership, `setResolver` lands, viem `getEnsText` against `tradewise.agentlab.eth` for `last-seen-at` returns the live Redis value | +| M6 | E2E test | `pnpm tsx scripts/test-ens-gateway-e2e.ts` prints `ALL GREEN` | +| M7 | Manual UI walkthrough — see separate `/docs/walkthroughs/2026-04-30-w2-manual-walkthrough.md` (created in PR alongside) | + +--- + +## Pre-flight: confirm `agentlab.eth` ownership + +### Task 0: Verify we own agentlab.eth before any contract deploy + +**Files:** none (read-only check) + +- [ ] **Step 1: Read the resolver namehash + owner** + +```bash +cd /Users/maxfritz/code/hack-agent +set -a; source .env.local; set +a +cast call 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e \ + "owner(bytes32)(address)" \ + $(cast namehash agentlab.eth) \ + --rpc-url $SEPOLIA_RPC_URL +``` + +- [ ] **Step 2: Confirm result equals one of our wallets** + +The result must equal one of: `PRICEWATCH_PK` address `0xBf5d…2469`, `AGENT_PK` address `0x7a83…20A3`, or the registrar wallet that registered `agentlab.eth`. If it's none of these — **STOP, escalate to user.** Don't proceed with W2 until ownership is confirmed. + +- [ ] **Step 3: If the owning wallet doesn't have Sepolia gas, fund it** + +```bash +node -e " +const { ethers } = require('ethers'); +const sepolia = new ethers.JsonRpcProvider(process.env.SEPOLIA_RPC_URL); +sepolia.getBalance('').then(b => console.log('ETH:', ethers.formatEther(b))); +" +``` + +Need ≥0.01 ETH for the `setResolver` call later. If not, request user fund. + +--- + +## M1 — OffchainResolver contract + +### Task 1: Failing test for `resolve()` reverting OffchainLookup + +**Files:** +- Create: `contracts/test/OffchainResolver.t.sol` + +- [ ] **Step 1: Write the failing test** + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import {OffchainResolver} from "../src/OffchainResolver.sol"; + +contract OffchainResolverTest is Test { + OffchainResolver internal resolver; + address internal signer = address(0x5165); + string[] internal urls; + + function setUp() public { + urls.push("https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json"); + resolver = new OffchainResolver(urls, signer); + } + + function test_resolve_revertsWithOffchainLookup() public { + bytes memory name = hex"09tradewise08agentlab03eth00"; // DNS wire format placeholder + bytes memory data = abi.encodeWithSelector( + bytes4(keccak256("text(bytes32,string)")), + keccak256("tradewise.agentlab.eth"), + "last-seen-at" + ); + + vm.expectRevert(); + resolver.resolve(name, data); + } +} +``` + +- [ ] **Step 2: Run, expect FAIL (compile error — contract missing)** + +```bash +cd contracts && forge test --match-contract OffchainResolverTest -v 2>&1 | tail -10 +``` + +- [ ] **Step 3: Commit failing test** + +```bash +git add contracts/test/OffchainResolver.t.sol +git commit -m "test(w2): failing OffchainResolver.resolve reverts test" +``` + +### Task 2: Implement `OffchainResolver` contract + +**Files:** +- Create: `contracts/src/OffchainResolver.sol` + +- [ ] **Step 1: Write the contract** + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IExtendedResolver { + function resolve(bytes memory name, bytes memory data) + external view returns (bytes memory); +} + +interface ISupportsInterface { + function supportsInterface(bytes4 interfaceID) external pure returns (bool); +} + +/// @notice EIP-3668 offchain resolver for *.agentlab.eth wildcard resolution. +/// Reverts OffchainLookup on every resolve call; gateway returns a signed +/// response that resolveWithProof verifies via ecrecover. W2-α (trusted +/// gateway) flavor — compromise of expectedGatewaySigner ⇒ malicious +/// resolution. Worst-case impact is stale telemetry, never falsified +/// ownership (ownership is in the L1 registry, not here). +contract OffchainResolver is IExtendedResolver, ISupportsInterface { + string[] public urls; + address public expectedGatewaySigner; + address public immutable owner; + + error OffchainLookup( + address sender, + string[] urls, + bytes callData, + bytes4 callbackFunction, + bytes extraData + ); + + error ExpiredResponse(); + error InvalidSigner(); + + event SignerChanged(address indexed oldSigner, address indexed newSigner); + event UrlsChanged(); + + constructor(string[] memory _urls, address _signer) { + require(_urls.length > 0, "no urls"); + require(_signer != address(0), "signer zero"); + urls = _urls; + expectedGatewaySigner = _signer; + owner = msg.sender; + } + + function urlsLength() external view returns (uint256) { + return urls.length; + } + + function resolve(bytes calldata name, bytes calldata data) + external view returns (bytes memory) + { + bytes memory callData = abi.encode(name, data); + revert OffchainLookup( + address(this), + urls, + callData, + this.resolveWithProof.selector, + callData + ); + } + + /// Response format: abi.encode(uint64 expires, bytes result, bytes signature) + /// Signed message: keccak256(abi.encodePacked( + /// hex"1900", // EIP-191 v0 + /// address(this), + /// expires, + /// keccak256(extraData), + /// keccak256(result) + /// )) + function resolveWithProof(bytes calldata response, bytes calldata extraData) + external view returns (bytes memory) + { + (uint64 expires, bytes memory result, bytes memory sig) = + abi.decode(response, (uint64, bytes, bytes)); + if (block.timestamp > expires) revert ExpiredResponse(); + + bytes32 messageHash = keccak256(abi.encodePacked( + hex"1900", + address(this), + expires, + keccak256(extraData), + keccak256(result) + )); + + bytes32 r; + bytes32 s; + uint8 v; + if (sig.length != 65) revert InvalidSigner(); + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + address recovered = ecrecover(messageHash, v, r, s); + if (recovered != expectedGatewaySigner) revert InvalidSigner(); + return result; + } + + function setUrls(string[] calldata _urls) external { + require(msg.sender == owner, "not owner"); + require(_urls.length > 0, "no urls"); + urls = _urls; + emit UrlsChanged(); + } + + function setSigner(address _signer) external { + require(msg.sender == owner, "not owner"); + require(_signer != address(0), "signer zero"); + emit SignerChanged(expectedGatewaySigner, _signer); + expectedGatewaySigner = _signer; + } + + function supportsInterface(bytes4 id) external pure returns (bool) { + return id == type(IExtendedResolver).interfaceId + || id == 0x9061b923 // ENSIP-10 wildcard + || id == 0x01ffc9a7; // ERC-165 + } +} +``` + +- [ ] **Step 2: Run, expect PASS** + +```bash +cd contracts && forge test --match-contract OffchainResolverTest -v 2>&1 | tail -10 +``` + +- [ ] **Step 3: Commit** + +```bash +git add contracts/src/OffchainResolver.sol +git commit -m "feat(w2): OffchainResolver — EIP-3668 wildcard resolver with ecrecover" +``` + +### Task 3: Add tests for `resolveWithProof` happy path + expiry + bad sig + +**Files:** +- Modify: `contracts/test/OffchainResolver.t.sol` + +- [ ] **Step 1: Add helper + tests** + +```solidity +uint256 internal signerPk = 0x5165B07E; + +function setUp() public { + urls.push("https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json"); + address derived = vm.addr(signerPk); + resolver = new OffchainResolver(urls, derived); +} + +function _signResponse(uint64 expires, bytes memory result, bytes memory extraData) + internal view returns (bytes memory) +{ + bytes32 messageHash = keccak256(abi.encodePacked( + hex"1900", + address(resolver), + expires, + keccak256(extraData), + keccak256(result) + )); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, messageHash); + bytes memory sig = abi.encodePacked(r, s, v); + return abi.encode(expires, result, sig); +} + +function test_resolveWithProof_validSignature_returnsResult() public { + bytes memory result = abi.encode("hello world"); + bytes memory extraData = hex"deadbeef"; + uint64 expires = uint64(block.timestamp + 60); + bytes memory response = _signResponse(expires, result, extraData); + bytes memory got = resolver.resolveWithProof(response, extraData); + assertEq(got, result); +} + +function test_resolveWithProof_expired_reverts() public { + bytes memory result = abi.encode("hello"); + bytes memory extraData = hex""; + uint64 expires = uint64(block.timestamp - 1); + bytes memory response = _signResponse(expires, result, extraData); + vm.expectRevert(OffchainResolver.ExpiredResponse.selector); + resolver.resolveWithProof(response, extraData); +} + +function test_resolveWithProof_invalidSigner_reverts() public { + bytes memory result = abi.encode("hello"); + bytes memory extraData = hex""; + uint64 expires = uint64(block.timestamp + 60); + bytes32 messageHash = keccak256(abi.encodePacked( + hex"1900", + address(resolver), + expires, + keccak256(extraData), + keccak256(result) + )); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBADBAD, messageHash); + bytes memory sig = abi.encodePacked(r, s, v); + bytes memory response = abi.encode(expires, result, sig); + vm.expectRevert(OffchainResolver.InvalidSigner.selector); + resolver.resolveWithProof(response, extraData); +} + +function test_resolveWithProof_extraDataMismatch_reverts() public { + bytes memory result = abi.encode("hello"); + bytes memory extraData1 = hex"01"; + bytes memory extraData2 = hex"02"; + uint64 expires = uint64(block.timestamp + 60); + bytes memory response = _signResponse(expires, result, extraData1); + vm.expectRevert(OffchainResolver.InvalidSigner.selector); + resolver.resolveWithProof(response, extraData2); +} + +function test_supportsInterface_extendedAndWildcard() public { + assertTrue(resolver.supportsInterface(type(IExtendedResolver).interfaceId)); + assertTrue(resolver.supportsInterface(0x9061b923)); // ENSIP-10 + assertTrue(resolver.supportsInterface(0x01ffc9a7)); // ERC-165 + assertFalse(resolver.supportsInterface(0xdeadbeef)); +} + +function test_setSigner_onlyOwner() public { + address newSigner = address(0xCAFE); + vm.prank(address(0xBAD)); + vm.expectRevert("not owner"); + resolver.setSigner(newSigner); +} +``` + +- [ ] **Step 2: Run, expect ALL PASS** + +```bash +cd contracts && forge test --match-contract OffchainResolverTest -v 2>&1 | tail -15 +``` + +- [ ] **Step 3: Commit** + +```bash +git add contracts/test/OffchainResolver.t.sol +git commit -m "test(w2): OffchainResolver — happy path + expiry + bad sig + extraData mismatch + interfaces + onlyOwner" +``` + +### Task 4: Deploy script + +**Files:** +- Create: `contracts/script/DeployOffchainResolver.s.sol` + +- [ ] **Step 1: Write the script** + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Script.sol"; +import {OffchainResolver} from "../src/OffchainResolver.sol"; + +contract DeployOffchainResolver is Script { + function run() external { + uint256 pk = vm.envUint("PRICEWATCH_PK"); + address signer = vm.envAddress("INFT_GATEWAY_ADDRESS"); + string memory baseUrl = vm.envOr( + "ENS_GATEWAY_BASE_URL", + string("https://hackagent-nine.vercel.app/api/ens-gateway") + ); + + string[] memory urls = new string[](1); + urls[0] = string.concat(baseUrl, "/{sender}/{data}.json"); + + vm.startBroadcast(pk); + OffchainResolver r = new OffchainResolver(urls, signer); + vm.stopBroadcast(); + + console.log("OffchainResolver:", address(r)); + console.log("Signer (gateway):", signer); + console.log("URL: ", urls[0]); + + string memory body = string.concat( + '{"network":"sepolia","chainId":11155111,"offchainResolver":"', + vm.toString(address(r)), + '","signer":"', vm.toString(signer), + '","gatewayUrl":"', urls[0], '"}' + ); + vm.writeFile("deployments/sepolia-ens-resolver.json", body); + } +} +``` + +- [ ] **Step 2: Build clean** + +```bash +cd contracts && forge build 2>&1 | tail -3 +``` + +- [ ] **Step 3: Commit** + +```bash +git add contracts/script/DeployOffchainResolver.s.sol +git commit -m "feat(w2): deploy script for OffchainResolver" +``` + +**🟢 M1 GATE:** `cd contracts && forge test --match-contract OffchainResolverTest` shows 7 passing tests. + +--- + +## M2 — Gateway library + +### Task 5: Create `lib/ens-gateway.ts` — record computation + signing + +**Files:** +- Create: `lib/ens-gateway.ts` + +- [ ] **Step 1: Write the file** + +```typescript +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { hexToBytes, bytesToHex } from "@noble/curves/utils.js"; +import { Wallet } from "ethers"; +import { + decodeAbiParameters, + encodeAbiParameters, + encodeFunctionResult, + parseAbiItem, +} from "viem"; +import { sepoliaPublicClient } from "@/lib/wallets"; +import { getRedis } from "@/lib/redis"; +import { getSepoliaAddresses } from "@/lib/edge-config"; +import AgentINFTAbi from "@/lib/abis/AgentINFT.json"; +import AgentBidsAbi from "@/lib/abis/AgentBids.json"; +import ReputationRegistryAbi from "@/lib/abis/ReputationRegistry.json"; + +const TEXT_SELECTOR = "0x59d1d43c"; // text(bytes32,string) +const ADDR_SELECTOR = "0x3b3b57de"; // addr(bytes32) +const ADDR_COIN_SELECTOR = "0xf1cb7e06"; // addr(bytes32,uint256) +const CONTENTHASH_SELECTOR = "0xbc1c58d1"; // contenthash(bytes32) + +function gatewaySk(): Uint8Array { + const pk = process.env.INFT_GATEWAY_PK; + if (!pk) throw new Error("INFT_GATEWAY_PK missing"); + return hexToBytes(pk.startsWith("0x") ? pk.slice(2) : pk); +} + +export function gatewayAddress(): `0x${string}` { + const pk = process.env.INFT_GATEWAY_PK!; + return new Wallet(pk.startsWith("0x") ? pk : `0x${pk}`).address as `0x${string}`; +} + +/// Decodes a DNS wire-format name into a dotted string label. +export function decodeDnsName(wire: Uint8Array): string { + const labels: string[] = []; + let i = 0; + while (i < wire.length && wire[i] !== 0) { + const len = wire[i++]; + labels.push(new TextDecoder().decode(wire.slice(i, i + len))); + i += len; + } + return labels.join("."); +} + +/// Maps a label like "tradewise.agentlab.eth" to the agent's tokenId. +/// For now hardcodes tradewise=1, pricewatch=2; future agents resolve via +/// IdentityRegistryV2.agentIdOf() reverse lookup. +export async function labelToAgent(label: string) + : Promise<{agentId: number | null; tokenId: number | null} | null> +{ + const lower = label.toLowerCase(); + if (lower === "tradewise.agentlab.eth") return { agentId: 1, tokenId: 1 }; + if (lower === "pricewatch.agentlab.eth") return { agentId: 2, tokenId: null }; + return null; +} + +/// Computes the value for a given record key. Returns ABI-encoded `string` +/// for text records, `bytes` for addr/coin/contenthash. +export async function computeRecord( + label: string, + selector: `0x${string}`, + args: unknown[], +): Promise<{ encoded: `0x${string}` }> { + if (selector === TEXT_SELECTOR) { + const key = args[1] as string; + const value = await computeTextRecord(label, key); + return { encoded: encodeAbiParameters([{ type: "string" }], [value]) }; + } + if (selector === ADDR_SELECTOR || selector === ADDR_COIN_SELECTOR) { + const value = await computeAddr(label); + return { + encoded: encodeAbiParameters([{ type: "bytes" }], [ + hexToBytes(value.slice(2)) as unknown as `0x${string}`, + ]), + }; + } + if (selector === CONTENTHASH_SELECTOR) { + return { encoded: encodeAbiParameters([{ type: "bytes" }], [ + "0x" as `0x${string}`, + ]) }; + } + // Unknown selector — return empty string (resolves to "no record"). + return { encoded: encodeAbiParameters([{ type: "string" }], [""]) }; +} + +async function computeTextRecord(label: string, key: string): Promise { + const agent = await labelToAgent(label); + if (!agent) return ""; + + switch (key) { + case "agent-card": + case "description": + case "url": + case "current-price-tier": { + const cfg = await getRedis()?.get(`ens:static:${label}:${key}`); + return cfg ?? ""; + } + + case "last-seen-at": { + const v = await getRedis()?.get(`agent:${agent.agentId}:last-seen`); + return v ?? ""; + } + + case "reputation-summary": { + if (!agent.agentId) return ""; + const cached = await getRedis()?.get(`reputation:summary:${agent.agentId}`); + if (cached) return cached; + const addrs = await getSepoliaAddresses(); + const count = await sepoliaPublicClient().readContract({ + address: addrs.reputationRegistry as `0x${string}`, + abi: ReputationRegistryAbi, + functionName: "feedbackCount", + args: [BigInt(agent.agentId)], + }); + const summary = `feedback=${count}`; + await getRedis()?.set(`reputation:summary:${agent.agentId}`, summary, "EX", 300); + return summary; + } + + case "outstanding-bids": { + if (!agent.tokenId) return "0"; + const addrs = await getSepoliaAddresses(); + const count = await sepoliaPublicClient().readContract({ + address: addrs.agentBidsAddress as `0x${string}`, + abi: AgentBidsAbi, + functionName: "biddersCount", + args: [BigInt(agent.tokenId)], + }); + return String(count); + } + + case "inft-tradeable": { + if (!agent.tokenId) return "0"; + const addrs = await getSepoliaAddresses(); + const ok = (await sepoliaPublicClient().readContract({ + address: addrs.inftAddress as `0x${string}`, + abi: AgentINFTAbi, + functionName: "memoryReencrypted", + args: [BigInt(agent.tokenId)], + })) as boolean; + return ok ? "1" : "0"; + } + + case "memory-rotations": { + if (!agent.tokenId) return "0"; + const v = await getRedis()?.get(`inft:meta:${agent.tokenId}:rotations`); + return v ?? "0"; + } + + case "avatar": { + if (!agent.tokenId) return ""; + const addrs = await getSepoliaAddresses(); + return `eip155:11155111/erc721:${addrs.inftAddress}/${agent.tokenId}`; + } + + default: { + // ENSIP-25 agent-registration[eip155:...] keys handled here later + return ""; + } + } +} + +async function computeAddr(label: string): Promise<`0x${string}`> { + const agent = await labelToAgent(label); + if (!agent || !agent.agentId) { + return "0x0000000000000000000000000000000000000000"; + } + const addrs = await getSepoliaAddresses(); + // tradewise = AGENT_EOA, pricewatch = its own EOA per Edge Config + if (agent.agentId === 1) return addrs.agentEOA as `0x${string}`; + if (agent.agentId === 2) return (addrs.pricewatchEOA ?? + "0x0000000000000000000000000000000000000000") as `0x${string}`; + return "0x0000000000000000000000000000000000000000"; +} + +export type SignedResponse = { + expires: number; + result: `0x${string}`; + signature: `0x${string}`; +}; + +/// Builds the EIP-191 message hash + signs. +export function signGatewayResponse(args: { + resolverAddress: `0x${string}`; + expires: number; + extraData: `0x${string}`; + result: `0x${string}`; +}): SignedResponse { + const expiresBytes = new Uint8Array(8); + let n = BigInt(args.expires); + for (let i = 7; i >= 0; i--) { + expiresBytes[i] = Number(n & 0xffn); + n >>= 8n; + } + const messageHash = keccak_256(new Uint8Array([ + 0x19, 0x00, + ...hexToBytes(args.resolverAddress.slice(2)), + ...expiresBytes, + ...keccak_256(hexToBytes(args.extraData.slice(2))), + ...keccak_256(hexToBytes(args.result.slice(2))), + ])); + + const sk = gatewaySk(); + const sig = secp256k1.sign(messageHash, sk, { prehash: false }); + const compact = sig instanceof Uint8Array ? sig : sig.toBytes(); + // Recover bit + const pubExpected = secp256k1.getPublicKey(sk, false); + let recoveryBit = 0; + for (let rec = 0; rec < 2; rec++) { + const sigObj = secp256k1.Signature.fromBytes(compact, "compact").addRecoveryBit(rec); + const recovered = sigObj.recoverPublicKey(messageHash).toBytes(false); + if (Buffer.from(recovered).equals(Buffer.from(pubExpected))) { + recoveryBit = rec; + break; + } + } + const v = recoveryBit + 27; + const sigBytes = new Uint8Array([...compact, v]); + + return { + expires: args.expires, + result: args.result, + signature: ("0x" + bytesToHex(sigBytes)) as `0x${string}`, + }; +} + +/// ABI-encodes the response for the resolver's resolveWithProof callback. +export function encodeResponse(r: SignedResponse): `0x${string}` { + return encodeAbiParameters( + [{ type: "uint64" }, { type: "bytes" }, { type: "bytes" }], + [BigInt(r.expires), r.result, r.signature], + ); +} +``` + +- [ ] **Step 2: Verify typecheck** + +```bash +pnpm typecheck 2>&1 | tail -3 +``` + +- [ ] **Step 3: Commit** + +```bash +git add lib/ens-gateway.ts +git commit -m "feat(w2): ens-gateway lib (record computation, EIP-191 signing, ABI encode)" +``` + +### Task 6: Smoke test the gateway lib + +**Files:** +- Create: `scripts/ens-gateway-smoke.ts` + +- [ ] **Step 1: Write smoke** + +```typescript +import { encodeAbiParameters } from "viem"; +import { + gatewayAddress, + signGatewayResponse, + encodeResponse, + computeRecord, +} from "../lib/ens-gateway"; + +async function main() { + console.log("gateway address:", gatewayAddress()); + + // Compute a "last-seen-at" text record (will return empty if Redis isn't + // populated; that's fine for the smoke). + const out = await computeRecord( + "tradewise.agentlab.eth", + "0x59d1d43c", + [/* node */ "0x0000…", "last-seen-at"], + ); + console.log("✓ text record encoded:", out.encoded.slice(0, 40), "..."); + + const result = encodeAbiParameters([{ type: "string" }], ["hello world"]); + const signed = signGatewayResponse({ + resolverAddress: "0x0000000000000000000000000000000000001234", + expires: Math.floor(Date.now() / 1000) + 60, + extraData: "0xdeadbeef", + result, + }); + console.log("✓ sig length:", signed.signature.length); + + const resp = encodeResponse(signed); + console.log("✓ response bytes:", resp.length); + + console.log("✓ all checks passed"); +} + +main().catch((err) => { console.error("FAIL:", err); process.exit(1); }); +``` + +- [ ] **Step 2: Run** + +```bash +INFT_GATEWAY_PK=0x$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") \ + pnpm exec tsx scripts/ens-gateway-smoke.ts +``` + +Expected: `✓ all checks passed`. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/ens-gateway-smoke.ts +git commit -m "test(w2): ens-gateway lib smoke (sig + response encode)" +``` + +**🟢 M2 GATE:** smoke prints "all checks passed" + typecheck clean. + +--- + +## M3 — Gateway HTTP route + +### Task 7: Implement `/api/ens-gateway/[sender]/[data]/route.ts` + +**Files:** +- Create: `app/api/ens-gateway/[sender]/[data]/route.ts` + +- [ ] **Step 1: Write the route** + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { hexToBytes } from "@noble/curves/utils.js"; +import { + decodeDnsName, + computeRecord, + signGatewayResponse, + encodeResponse, +} from "@/lib/ens-gateway"; +import { decodeAbiParameters } from "viem"; + +const RESPONSE_TTL = 60; // seconds + +type Params = { params: Promise<{ sender: string; data: string }> }; + +async function handle(req: NextRequest, ctx: Params) { + const { sender, data: dataParam } = await ctx.params; + // EIP-3668 spec: data may have ".json" suffix for GET, or be raw hex for POST + const dataHex = dataParam.replace(/\.json$/, ""); + if (!/^0x[0-9a-fA-F]+$/.test(dataHex)) { + return NextResponse.json({ error: "invalid data" }, { status: 400 }); + } + + // Decode the outer (name, data) tuple per OffchainLookup spec + const [nameWire, resolveCalldata] = decodeAbiParameters( + [{ type: "bytes" }, { type: "bytes" }], + dataHex as `0x${string}`, + ); + const label = decodeDnsName(hexToBytes((nameWire as `0x${string}`).slice(2))); + + // Inner: parse the function selector + args + const inner = resolveCalldata as `0x${string}`; + const selector = inner.slice(0, 10) as `0x${string}`; + const argsBytes = "0x" + inner.slice(10) as `0x${string}`; + + // Decode args based on selector (text(bytes32,string), addr(bytes32), etc.) + let args: unknown[] = []; + if (selector === "0x59d1d43c") { + args = decodeAbiParameters( + [{ type: "bytes32" }, { type: "string" }], + argsBytes, + ); + } else if (selector === "0x3b3b57de") { + args = decodeAbiParameters([{ type: "bytes32" }], argsBytes); + } else if (selector === "0xf1cb7e06") { + args = decodeAbiParameters( + [{ type: "bytes32" }, { type: "uint256" }], + argsBytes, + ); + } else if (selector === "0xbc1c58d1") { + args = decodeAbiParameters([{ type: "bytes32" }], argsBytes); + } else { + return NextResponse.json({ error: "unsupported selector" }, { status: 400 }); + } + + const { encoded } = await computeRecord(label, selector, args); + const expires = Math.floor(Date.now() / 1000) + RESPONSE_TTL; + const signed = signGatewayResponse({ + resolverAddress: sender as `0x${string}`, + expires, + extraData: dataHex as `0x${string}`, + result: encoded, + }); + return NextResponse.json({ data: encodeResponse(signed) }); +} + +export const GET = handle; +export const POST = handle; +``` + +- [ ] **Step 2: typecheck + build** + +```bash +pnpm typecheck 2>&1 | tail -3 +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/api/ens-gateway/[sender]/[data]/route.ts +git commit -m "feat(w2): /api/ens-gateway/[sender]/[data] route — decode + compute + sign" +``` + +### Task 8: Cache invalidation route + +**Files:** +- Create: `app/api/ens-gateway/cache/invalidate/route.ts` + +- [ ] **Step 1: Write the route** + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getRedis } from "@/lib/redis"; + +const Body = z.object({ + keys: z.array(z.string()).min(1).max(50), +}); + +export async function POST(req: NextRequest) { + if (req.headers.get("authorization") !== `Bearer ${process.env.INFT_ORACLE_API_KEY}`) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const parsed = Body.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + } + const r = getRedis(); + if (!r) return NextResponse.json({ error: "no redis" }, { status: 500 }); + await Promise.all(parsed.data.keys.map((k) => r.del(k))); + return NextResponse.json({ ok: true, deleted: parsed.data.keys.length }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/api/ens-gateway/cache/invalidate/route.ts +git commit -m "feat(w2): /api/ens-gateway/cache/invalidate route (KeeperHub trigger sink)" +``` + +**🟢 M3 GATE:** Local `curl` test: + +```bash +pnpm dev & +sleep 8 + +# Build a fake call to text(bytes32,string) for "tradewise.agentlab.eth", "last-seen-at" +SELECTOR="0x59d1d43c" +NODE="0x$(echo -n "tradewise.agentlab.eth" | xxd -p)" # will need real namehash, this is rough +DATA="$SELECTOR..." # use cast abi-encode for the real test +curl "http://localhost:3000/api/ens-gateway/0x103B2F28480c57ba49efeF50379Ef674d805DeDA/$DATA.json" | jq + +kill %1 +``` + +Returns `{data: "0x..."}` shape. (For a tighter check, cast-encode the args properly using `cast abi-encode`.) + +--- + +## M4 — Frontend + +### Task 9: `lib/ens-records.ts` — typed helper + +**Files:** +- Create: `lib/ens-records.ts` + +- [ ] **Step 1: Write the helper** + +```typescript +import { sepoliaPublicClient } from "@/lib/wallets"; + +const ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e" as const; + +export async function readEnsText(name: string, key: string): Promise { + try { + const value = await sepoliaPublicClient().getEnsText({ + name, + key, + }); + return value; + } catch (err) { + console.error(`[ens-records] getEnsText ${name} ${key} failed:`, err); + return null; + } +} + +export async function readAgentTelemetry(label: string) { + const [lastSeenAt, rotations, inftTradeable, outstandingBids, reputationSummary] = + await Promise.all([ + readEnsText(label, "last-seen-at"), + readEnsText(label, "memory-rotations"), + readEnsText(label, "inft-tradeable"), + readEnsText(label, "outstanding-bids"), + readEnsText(label, "reputation-summary"), + ]); + return { lastSeenAt, rotations, inftTradeable, outstandingBids, reputationSummary }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/ens-records.ts +git commit -m "feat(w2): lib/ens-records — typed wagmi/viem ENS helper" +``` + +### Task 10: `/inft` page reads `inft-tradeable` + `memory-rotations` via ENS + +**Files:** +- Modify: `app/inft/page.tsx` + +- [ ] **Step 1: After existing on-chain reads, add an ENS read for the same fields and surface both** + +```typescript +import { readAgentTelemetry } from "@/lib/ens-records"; + +// inside the page component, after readInft(): +const ensTelemetry = await readAgentTelemetry("tradewise.agentlab.eth"); + +// in the JSX, add a row: +
+ via ENS gateway: rotations={ensTelemetry.rotations ?? "—"} · + tradeable={ensTelemetry.inftTradeable ?? "—"} · + last-seen={ensTelemetry.lastSeenAt ?? "—"} +
+``` + +(Adapt to existing card structure.) + +- [ ] **Step 2: typecheck + build** + +```bash +pnpm typecheck && pnpm build 2>&1 | tail -10 +``` + +- [ ] **Step 3: Commit** + +```bash +git add app/inft/page.tsx +git commit -m "feat(w2): /inft cross-link — read inft-tradeable + memory-rotations via ENS gateway" +``` + +### Task 11: `/ens-debug` page + +**Files:** +- Create: `app/ens-debug/page.tsx` + +- [ ] **Step 1: Write a small client component that lets the user query (name, key) and shows the full CCIP-Read flow** + +```typescript +"use client"; + +import { useState } from "react"; +import SiteNav from "@/components/site-nav"; + +export default function EnsDebugPage() { + const [name, setName] = useState("tradewise.agentlab.eth"); + const [key, setKey] = useState("last-seen-at"); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + async function go() { + setLoading(true); + try { + const res = await fetch(`/api/ens-debug?name=${encodeURIComponent(name)}&key=${encodeURIComponent(key)}`); + setResult(await res.json()); + } catch (err) { + setResult({ error: String(err) }); + } finally { + setLoading(false); + } + } + + return ( +
+ +
+

debug · ens ccip-read

+

/ens-debug

+

+ Resolves an ENS text record through the W2 offchain gateway and shows the full + OffchainLookup roundtrip — revert, gateway URL, signed response, ecrecovered signer. +

+
+
+
+ setName(e.target.value)} + className="border px-3 py-2 text-sm bg-transparent" placeholder="name" /> + setKey(e.target.value)} + className="border px-3 py-2 text-sm bg-transparent" placeholder="text key" /> + +
+ {result ? ( +
+            {JSON.stringify(result, null, 2)}
+          
+ ) : null} +
+
+ ); +} +``` + +- [ ] **Step 2: Add the supporting `/api/ens-debug` route** that does the OffchainLookup unwrap server-side and returns the full trace: + +```typescript +// app/api/ens-debug/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { sepoliaPublicClient } from "@/lib/wallets"; +import { readEnsText } from "@/lib/ens-records"; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const name = url.searchParams.get("name"); + const key = url.searchParams.get("key"); + if (!name || !key) { + return NextResponse.json({ error: "missing name/key" }, { status: 400 }); + } + const t0 = Date.now(); + const value = await readEnsText(name, key); + return NextResponse.json({ + name, + key, + value, + latencyMs: Date.now() - t0, + }); +} +``` + +(Future enhancement: surface the raw OffchainLookup revert + signed response by hand-rolling the eth_call. For v1 this is enough.) + +- [ ] **Step 3: Commit** + +```bash +git add app/ens-debug app/api/ens-debug +git commit -m "feat(w2): /ens-debug page + /api/ens-debug route (CCIP-Read demo surface)" +``` + +**🟢 M4 GATE:** `pnpm build` clean. + +--- + +## M5 — Deploy + agentlab.eth resolver flip + +### Task 12: Generate gateway key + set Vercel env + +- [ ] **Step 1: Generate gateway PK** + +```bash +node -e " +const crypto = require('crypto'); +const { ethers } = require('ethers'); +const sk = '0x' + crypto.randomBytes(32).toString('hex'); +const w = new ethers.Wallet(sk); +console.log('PK:', sk); +console.log('ADDRESS:', w.address); +" > /tmp/inft-gateway-key.txt +chmod 600 /tmp/inft-gateway-key.txt +cat /tmp/inft-gateway-key.txt +``` + +- [ ] **Step 2: Set in .env.local** + +```bash +{ + echo "" + echo "# W2 ENS gateway key (added 2026-04-30)" + echo "INFT_GATEWAY_PK=" + echo "INFT_GATEWAY_ADDRESS=" + echo "ENS_GATEWAY_BASE_URL=https://hackagent-nine.vercel.app/api/ens-gateway" +} >> .env.local +``` + +- [ ] **Step 3: Set in Vercel env** + +```bash +vercel env add INFT_GATEWAY_PK production --value "" --yes --force +vercel env add INFT_GATEWAY_PK preview --value "" --yes --force +``` + +### Task 13: Sync ABIs + +- [ ] **Step 1: Add OffchainResolver to `scripts/sync-abis.ts`**, then run + +```bash +pnpm tsx scripts/sync-abis.ts +git add lib/abis/OffchainResolver.json scripts/sync-abis.ts +git commit -m "chore(w2): sync OffchainResolver ABI" +``` + +### Task 14: Deploy OffchainResolver to Sepolia + +- [ ] **Step 1: Run the deploy script** + +```bash +cd contracts +INFT_GATEWAY_ADDRESS=$(cat /tmp/inft-gateway-key.txt | grep ADDRESS | cut -d' ' -f2) \ + forge script script/DeployOffchainResolver.s.sol --rpc-url $SEPOLIA_RPC_URL --broadcast 2>&1 | tail -10 +``` + +- [ ] **Step 2: Capture address from `deployments/sepolia-ens-resolver.json`**, write to Edge Config + +### Task 15: Flip agentlab.eth resolver + +**Files:** +- Create: `scripts/set-agentlab-resolver.ts` + +- [ ] **Step 1: Write the script that calls `ENS.setResolver(namehash("agentlab.eth"), )`** + +```typescript +import { createPublicClient, createWalletClient, http, namehash } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { sepolia } from "viem/chains"; + +const ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; +const REGISTRY_ABI = [ + { + type: "function", + name: "owner", + stateMutability: "view", + inputs: [{ name: "node", type: "bytes32" }], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "setResolver", + stateMutability: "nonpayable", + inputs: [ + { name: "node", type: "bytes32" }, + { name: "resolver", type: "address" }, + ], + outputs: [], + }, +] as const; + +async function main() { + const node = namehash("agentlab.eth"); + const newResolver = process.env.OFFCHAIN_RESOLVER_ADDRESS as `0x${string}`; + if (!newResolver) throw new Error("OFFCHAIN_RESOLVER_ADDRESS missing"); + + const account = privateKeyToAccount( + (process.env.PRICEWATCH_PK!.startsWith("0x") + ? process.env.PRICEWATCH_PK! + : "0x" + process.env.PRICEWATCH_PK!) as `0x${string}`, + ); + const pub = createPublicClient({ chain: sepolia, transport: http(process.env.SEPOLIA_RPC_URL!) }); + const wallet = createWalletClient({ + account, + chain: sepolia, + transport: http(process.env.SEPOLIA_RPC_URL!), + }); + + const owner = await pub.readContract({ + address: ENS_REGISTRY, + abi: REGISTRY_ABI, + functionName: "owner", + args: [node], + }); + console.log("agentlab.eth owner:", owner); + if (owner.toLowerCase() !== account.address.toLowerCase()) { + throw new Error(`owner ${owner} != deployer ${account.address}; cannot setResolver`); + } + + console.log("setting resolver to", newResolver, "..."); + const txHash = await wallet.writeContract({ + address: ENS_REGISTRY, + abi: REGISTRY_ABI, + functionName: "setResolver", + args: [node, newResolver], + }); + console.log("tx:", txHash); + await pub.waitForTransactionReceipt({ hash: txHash }); + console.log("done."); +} + +main().catch((err) => { console.error(err); process.exit(1); }); +``` + +- [ ] **Step 2: Run** + +```bash +OFFCHAIN_RESOLVER_ADDRESS= pnpm exec tsx scripts/set-agentlab-resolver.ts +``` + +If owner mismatch, the script aborts. The plan's pre-flight Task 0 catches this earlier. + +- [ ] **Step 3: Verify** + +```bash +node -e " +const { createPublicClient, http, namehash } = require('viem'); +const { sepolia } = require('viem/chains'); +const c = createPublicClient({ chain: sepolia, transport: http(process.env.SEPOLIA_RPC_URL) }); +c.readContract({ + address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + abi: [{type:'function',name:'resolver',stateMutability:'view',inputs:[{name:'node',type:'bytes32'}],outputs:[{name:'',type:'address'}]}], + functionName: 'resolver', + args: [namehash('agentlab.eth')], +}).then(r => console.log('resolver:', r)); +" +``` + +Should print the OffchainResolver address. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/set-agentlab-resolver.ts contracts/deployments/sepolia-ens-resolver.json +git commit -m "feat(w2): deploy OffchainResolver + flip agentlab.eth resolver" +``` + +**🟢 M5 GATE:** `viem.getEnsText({ name: "tradewise.agentlab.eth", key: "last-seen-at" })` returns the live Redis value (or empty string if Redis hasn't seen activity yet). + +--- + +## M6 — End-to-end test + +### Task 16: Write `scripts/test-ens-gateway-e2e.ts` + +**Files:** +- Create: `scripts/test-ens-gateway-e2e.ts` + +- [ ] **Step 1: Write 8-step e2e** + +Steps (mirroring W1's e2e posture): + +1. `viem.getEnsText({ name: "tradewise.agentlab.eth", key: "last-seen-at" })` succeeds (returns string, may be empty). +2. Manually decode the OffchainLookup revert from a low-level `eth_call` against `OffchainResolver.resolve(...)`. Verify `urls[]` contains our gateway URL and `callData` is decodable. +3. Call gateway URL directly with the captured callData. Assert returned `{data}` is non-empty hex. +4. Call resolver's `resolveWithProof(response, extraData)` via `eth_call`, assert it returns the expected `string` result. +5. Tamper one byte of the signature → assert the eth_call reverts with `InvalidSigner`. +6. Set `expires` to past in a hand-rolled response → assert revert `ExpiredResponse`. +7. **Wildcard test:** `getEnsText({ name: "agent-eoa.tradewise.agentlab.eth", key: "addr" })`. Resolves WITHOUT that subname being registered (validates ENSIP-10). +8. **W1 cross-link:** `getEnsText({ name: "tradewise.agentlab.eth", key: "memory-rotations" })`. Should match `inft:meta:1:rotations` in Redis. + +Final line: `ALL GREEN`. + +- [ ] **Step 2: Run** + +```bash +set -a; source .env.local; set +a +pnpm exec tsx scripts/test-ens-gateway-e2e.ts +``` + +- [ ] **Step 3: Commit** + +```bash +git add scripts/test-ens-gateway-e2e.ts +git commit -m "test(w2): e2e against deployed resolver + live gateway" +``` + +**🟢 M6 GATE:** e2e prints `ALL GREEN`. + +--- + +## M7 — PR + manual walkthrough + +### Task 17: Push + open PR + +- [ ] **Step 1: Push** + +```bash +git push -u origin feat/w2-ens-gateway +``` + +- [ ] **Step 2: Open PR** + +Title: `W2: CCIP-Read ENS gateway (closes #11/W2)` + +Body: similar template to PR #12 with the W2 deployment addresses. + +### Task 18: Walkthrough doc + +- [ ] **Step 1: Add `docs/walkthroughs/2026-04-30-w2-manual-walkthrough.md`** mirroring W1's walkthrough format. Sections: + - §A — `/inft` shows ENS-gateway-served fields alongside on-chain reads + - §B — `/ens-debug` page roundtrip + - §C — wildcard test (`agent-eoa.tradewise.agentlab.eth`) + - §D — Etherscan / wagmi external client test (paste `tradewise.agentlab.eth` into etherscan, see resolver = OffchainResolver, see records resolve) + - §E — gas verification: subsequent reads should NOT produce Sepolia txs from `PRICEWATCH_PK` (heartbeat workflow already disabled in PR #13, but confirm no new setText txs). + +**🟢 M7 GATE: W2 SHIPPED.** + +--- + +## What's NOT in this plan (W2 only) + +- W3 (ENSIP-19 multichain primary names + KeeperHub workflow restructuring) — separate plan. +- W2-β (storage-proof verifier replacing the trusted gateway) — out of scope. +- Namechain migration — issue #10 plan C, post-hackathon. From 7c8e93218584b01f79c45faed36c96a598bb2691 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 15:18:53 +0200 Subject: [PATCH 02/16] test(w2): failing OffchainResolver.resolve reverts test --- contracts/test/OffchainResolver.t.sol | 129 ++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 contracts/test/OffchainResolver.t.sol diff --git a/contracts/test/OffchainResolver.t.sol b/contracts/test/OffchainResolver.t.sol new file mode 100644 index 0000000..b95e216 --- /dev/null +++ b/contracts/test/OffchainResolver.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import {OffchainResolver} from "../src/OffchainResolver.sol"; + +// Re-declare the interface here so supportsInterface assertions compile +// without importing the full resolver. +interface IExtendedResolver { + function resolve(bytes memory name, bytes memory data) + external view returns (bytes memory); +} + +contract OffchainResolverTest is Test { + OffchainResolver internal resolver; + uint256 internal signerPk = 0x5165B07E; + string[] internal urls; + + function setUp() public { + urls.push("https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json"); + address derived = vm.addr(signerPk); + resolver = new OffchainResolver(urls, derived); + } + + // ------------------------------------------------------------------ Task 1 + function test_resolve_revertsWithOffchainLookup() public { + // DNS wire-format for "tradewise.agentlab.eth": + // 09 + "tradewise" (9 bytes) + 08 + "agentlab" (8 bytes) + 03 + "eth" (3 bytes) + 00 + bytes memory name = abi.encodePacked( + uint8(9), "tradewise", + uint8(8), "agentlab", + uint8(3), "eth", + uint8(0) + ); + bytes memory data = abi.encodeWithSelector( + bytes4(keccak256("text(bytes32,string)")), + keccak256("tradewise.agentlab.eth"), + "last-seen-at" + ); + + vm.expectRevert(); + resolver.resolve(name, data); + } + + // ------------------------------------------------------------------ helpers + function _signResponse(uint64 expires, bytes memory result, bytes memory extraData) + internal view returns (bytes memory) + { + bytes32 messageHash = keccak256(abi.encodePacked( + hex"1900", + address(resolver), + expires, + keccak256(extraData), + keccak256(result) + )); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, messageHash); + bytes memory sig = abi.encodePacked(r, s, v); + return abi.encode(expires, result, sig); + } + + // ------------------------------------------------------------------ Task 3 tests + function test_resolveWithProof_validSignature_returnsResult() public { + bytes memory result = abi.encode("hello world"); + bytes memory extraData = hex"deadbeef"; + uint64 expires = uint64(block.timestamp + 60); + bytes memory response = _signResponse(expires, result, extraData); + bytes memory got = resolver.resolveWithProof(response, extraData); + assertEq(got, result); + } + + function test_resolveWithProof_expired_reverts() public { + bytes memory result = abi.encode("hello"); + bytes memory extraData = hex""; + uint64 expires = uint64(block.timestamp - 1); + bytes memory response = _signResponse(expires, result, extraData); + vm.expectRevert(OffchainResolver.ExpiredResponse.selector); + resolver.resolveWithProof(response, extraData); + } + + function test_resolveWithProof_invalidSigner_reverts() public { + bytes memory result = abi.encode("hello"); + bytes memory extraData = hex""; + uint64 expires = uint64(block.timestamp + 60); + bytes32 messageHash = keccak256(abi.encodePacked( + hex"1900", + address(resolver), + expires, + keccak256(extraData), + keccak256(result) + )); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBADBAD, messageHash); + bytes memory sig = abi.encodePacked(r, s, v); + bytes memory response = abi.encode(expires, result, sig); + vm.expectRevert(OffchainResolver.InvalidSigner.selector); + resolver.resolveWithProof(response, extraData); + } + + function test_resolveWithProof_extraDataMismatch_reverts() public { + bytes memory result = abi.encode("hello"); + bytes memory extraData1 = hex"01"; + bytes memory extraData2 = hex"02"; + uint64 expires = uint64(block.timestamp + 60); + bytes memory response = _signResponse(expires, result, extraData1); + vm.expectRevert(OffchainResolver.InvalidSigner.selector); + resolver.resolveWithProof(response, extraData2); + } + + function test_supportsInterface_extendedAndWildcard() public view { + assertTrue(resolver.supportsInterface(type(IExtendedResolver).interfaceId)); + assertTrue(resolver.supportsInterface(0x9061b923)); // ENSIP-10 + assertTrue(resolver.supportsInterface(0x01ffc9a7)); // ERC-165 + assertFalse(resolver.supportsInterface(0xdeadbeef)); + } + + function test_setSigner_onlyOwner() public { + address newSigner = address(0xCAFE); + vm.prank(address(0xBAD)); + vm.expectRevert("not owner"); + resolver.setSigner(newSigner); + } + + function test_setUrls_onlyOwner() public { + string[] memory newUrls = new string[](1); + newUrls[0] = "https://example.com/{sender}/{data}.json"; + vm.prank(address(0xBAD)); + vm.expectRevert("not owner"); + resolver.setUrls(newUrls); + } +} From 716792c92d754a59617d600da22e0eeda1495f83 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 15:23:11 +0200 Subject: [PATCH 03/16] =?UTF-8?q?feat(w2):=20OffchainResolver=20=E2=80=94?= =?UTF-8?q?=20EIP-3668=20wildcard=20resolver=20with=20ecrecover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/src/OffchainResolver.sol | 170 +++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 contracts/src/OffchainResolver.sol diff --git a/contracts/src/OffchainResolver.sol b/contracts/src/OffchainResolver.sol new file mode 100644 index 0000000..4d6762f --- /dev/null +++ b/contracts/src/OffchainResolver.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice Minimal interface for ENSIP-10 wildcard resolution (EIP-3668). +interface IExtendedResolver { + function resolve(bytes memory name, bytes memory data) + external view returns (bytes memory); +} + +/// @notice ERC-165 interface check. +interface ISupportsInterface { + function supportsInterface(bytes4 interfaceID) external pure returns (bool); +} + +/// @notice EIP-3668 offchain resolver for *.agentlab.eth wildcard resolution. +/// +/// Every `resolve()` call reverts `OffchainLookup`; wagmi/viem CCIP-Read clients +/// automatically follow up with the gateway URL and call `resolveWithProof()` +/// to verify the signed response on-chain. +/// +/// Security posture (W2-α trusted gateway): +/// - Compromise of `expectedGatewaySigner` → malicious resolution. +/// - Worst-case impact is stale/spoofed telemetry records (last-seen-at, +/// reputation-summary, etc.), NOT falsified ownership — ownership lives in +/// the L1 ENS registry, not in this contract. +contract OffchainResolver is IExtendedResolver, ISupportsInterface { + // ------------------------------------------------------------------ state + string[] public urls; + address public expectedGatewaySigner; + address public immutable owner; + + // ------------------------------------------------------------------ errors + /// @notice EIP-3668 revert — clients decode this to perform the offchain lookup. + error OffchainLookup( + address sender, + string[] urls, + bytes callData, + bytes4 callbackFunction, + bytes extraData + ); + + /// @notice Response timestamp is in the past. + error ExpiredResponse(); + + /// @notice ecrecover produced an address that does not match expectedGatewaySigner. + error InvalidSigner(); + + // ------------------------------------------------------------------ events + event SignerChanged(address indexed oldSigner, address indexed newSigner); + event UrlsChanged(); + + // ------------------------------------------------------------------ constructor + constructor(string[] memory _urls, address _signer) { + require(_urls.length > 0, "no urls"); + require(_signer != address(0), "signer zero"); + urls = _urls; + expectedGatewaySigner = _signer; + owner = msg.sender; + } + + // ------------------------------------------------------------------ view helpers + /// @notice Convenience accessor so callers can read the number of gateway URLs. + function urlsLength() external view returns (uint256) { + return urls.length; + } + + // ------------------------------------------------------------------ IExtendedResolver + /// @notice Always reverts OffchainLookup per EIP-3668. + /// + /// The `callData` and `extraData` are both set to `abi.encode(name, data)` so + /// the gateway can decode both the DNS wire-format name and the resolve selector, + /// and `resolveWithProof` receives the same bytes for hash verification. + function resolve(bytes calldata name, bytes calldata data) + external view returns (bytes memory) + { + bytes memory callData = abi.encode(name, data); + revert OffchainLookup( + address(this), + urls, + callData, + this.resolveWithProof.selector, + callData + ); + } + + // ------------------------------------------------------------------ callback + /// @notice Called by the CCIP-Read client after receiving the gateway response. + /// + /// Response format: + /// abi.encode(uint64 expires, bytes result, bytes signature) + /// + /// Signed message (EIP-191 v0 / "version 0x00"): + /// keccak256(abi.encodePacked( + /// hex"1900", // EIP-191 prefix for v=0 (data with intended validator) + /// address(this), // this contract — ties the sig to this resolver + /// expires, // uint64, prevents replay after TTL + /// keccak256(extraData), // ties the sig to the original resolve() callData + /// keccak256(result) // the actual record payload + /// )) + function resolveWithProof(bytes calldata response, bytes calldata extraData) + external view returns (bytes memory) + { + (uint64 expires, bytes memory result, bytes memory sig) = + abi.decode(response, (uint64, bytes, bytes)); + + // 1. Expiry check. + if (block.timestamp > expires) revert ExpiredResponse(); + + // 2. Reconstruct EIP-191 message hash. + bytes32 messageHash = keccak256(abi.encodePacked( + hex"1900", + address(this), + expires, + keccak256(extraData), + keccak256(result) + )); + + // 3. Recover signer — sig must be exactly 65 bytes (r, s, v). + if (sig.length != 65) revert InvalidSigner(); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + address recovered = ecrecover(messageHash, v, r, s); + + // 4. ecrecover returns address(0) on failure; treat as InvalidSigner. + if (recovered == address(0) || recovered != expectedGatewaySigner) { + revert InvalidSigner(); + } + + return result; + } + + // ------------------------------------------------------------------ admin + /// @notice Update the list of gateway URLs. Owner only. + function setUrls(string[] calldata _urls) external { + require(msg.sender == owner, "not owner"); + require(_urls.length > 0, "no urls"); + // Copy element-by-element: string[]calldata → storage not supported by + // the old Solidity code-generator. + delete urls; + for (uint256 i = 0; i < _urls.length; i++) { + urls.push(_urls[i]); + } + emit UrlsChanged(); + } + + /// @notice Update the expected gateway signer address. Owner only. + function setSigner(address _signer) external { + require(msg.sender == owner, "not owner"); + require(_signer != address(0), "signer zero"); + emit SignerChanged(expectedGatewaySigner, _signer); + expectedGatewaySigner = _signer; + } + + // ------------------------------------------------------------------ ERC-165 + /// @notice Returns true for: + /// 0x9061b923 — ENSIP-10 wildcard resolver (IExtendedResolver selector) + /// type(IExtendedResolver).interfaceId — same value computed by the compiler + /// 0x01ffc9a7 — ERC-165 itself + function supportsInterface(bytes4 id) external pure returns (bool) { + return id == type(IExtendedResolver).interfaceId // 0x9061b923 per ENSIP-10 + || id == 0x9061b923 // explicit constant for clarity + || id == 0x01ffc9a7; // ERC-165 + } +} From d8d3614db1257b9b1c902973919ee4e2f8167b64 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 15:23:42 +0200 Subject: [PATCH 04/16] =?UTF-8?q?test(w2):=20OffchainResolver=20=E2=80=94?= =?UTF-8?q?=20happy=20path=20+=20expiry=20+=20bad=20sig=20+=20extraData=20?= =?UTF-8?q?mismatch=20+=20interfaces=20+=20onlyOwner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/test/OffchainResolver.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/OffchainResolver.t.sol b/contracts/test/OffchainResolver.t.sol index b95e216..0064174 100644 --- a/contracts/test/OffchainResolver.t.sol +++ b/contracts/test/OffchainResolver.t.sol @@ -59,7 +59,7 @@ contract OffchainResolverTest is Test { } // ------------------------------------------------------------------ Task 3 tests - function test_resolveWithProof_validSignature_returnsResult() public { + function test_resolveWithProof_validSignature_returnsResult() public view { bytes memory result = abi.encode("hello world"); bytes memory extraData = hex"deadbeef"; uint64 expires = uint64(block.timestamp + 60); From 39784bf620f6b3c3ec2cab5296cbe5eaf662c50e Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 15:24:06 +0200 Subject: [PATCH 05/16] feat(w2): deploy script for OffchainResolver --- contracts/script/DeployOffchainResolver.s.sol | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 contracts/script/DeployOffchainResolver.s.sol diff --git a/contracts/script/DeployOffchainResolver.s.sol b/contracts/script/DeployOffchainResolver.s.sol new file mode 100644 index 0000000..a4bf491 --- /dev/null +++ b/contracts/script/DeployOffchainResolver.s.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Script.sol"; +import {OffchainResolver} from "../src/OffchainResolver.sol"; + +/// @notice Deploys OffchainResolver to Sepolia and writes a deployment artifact. +/// +/// Required env vars: +/// PRICEWATCH_PK — deployer private key +/// INFT_GATEWAY_ADDRESS — the gateway signer address (derive from INFT_GATEWAY_PK) +/// +/// Optional env vars: +/// ENS_GATEWAY_BASE_URL — base URL (default: https://hackagent-nine.vercel.app/api/ens-gateway) +/// +/// Usage: +/// cd contracts +/// forge script script/DeployOffchainResolver.s.sol \ +/// --rpc-url $SEPOLIA_RPC_URL \ +/// --broadcast \ +/// --verify +contract DeployOffchainResolver is Script { + function run() external { + uint256 pk = vm.envUint("PRICEWATCH_PK"); + address signer = vm.envAddress("INFT_GATEWAY_ADDRESS"); + string memory baseUrl = vm.envOr( + "ENS_GATEWAY_BASE_URL", + string("https://hackagent-nine.vercel.app/api/ens-gateway") + ); + + string[] memory urls = new string[](1); + urls[0] = string.concat(baseUrl, "/{sender}/{data}.json"); + + vm.startBroadcast(pk); + OffchainResolver r = new OffchainResolver(urls, signer); + vm.stopBroadcast(); + + console.log("OffchainResolver:", address(r)); + console.log("Signer (gateway):", signer); + console.log("URL: ", urls[0]); + + // Write deployment artifact. + string memory body = string.concat( + '{"network":"sepolia","chainId":11155111,"offchainResolver":"', + vm.toString(address(r)), + '","signer":"', vm.toString(signer), + '","gatewayUrl":"', urls[0], '"}' + ); + vm.writeFile("deployments/sepolia-ens-resolver.json", body); + console.log("Artifact written to deployments/sepolia-ens-resolver.json"); + } +} From bc87e394158a493f016c9a6a6d2b600f127703e6 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 17:53:00 +0200 Subject: [PATCH 06/16] feat(w2): ens-gateway lib (record computation, EIP-191 signing, ABI encode) Co-Authored-By: Claude Sonnet 4.6 --- lib/ens-gateway.ts | 376 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 lib/ens-gateway.ts diff --git a/lib/ens-gateway.ts b/lib/ens-gateway.ts new file mode 100644 index 0000000..be1adca --- /dev/null +++ b/lib/ens-gateway.ts @@ -0,0 +1,376 @@ +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { hexToBytes, bytesToHex } from "@noble/curves/utils.js"; +import { Wallet } from "ethers"; +import { encodeAbiParameters } from "viem"; +import { sepoliaPublicClient } from "@/lib/wallets"; +import { getRedis } from "@/lib/redis"; +import { getSepoliaAddresses } from "@/lib/edge-config"; +import AgentINFTAbi from "@/lib/abis/AgentINFT.json"; +import AgentBidsAbi from "@/lib/abis/AgentBids.json"; +import ReputationRegistryAbi from "@/lib/abis/ReputationRegistry.json"; + +// ---------------------------------------------------------------------------- +// Selectors +// ---------------------------------------------------------------------------- +const TEXT_SELECTOR = "0x59d1d43c"; // text(bytes32,string) +const ADDR_SELECTOR = "0x3b3b57de"; // addr(bytes32) +const ADDR_COIN_SELECTOR = "0xf1cb7e06"; // addr(bytes32,uint256) +const CONTENTHASH_SELECTOR = "0xbc1c58d1"; // contenthash(bytes32) + +// ---------------------------------------------------------------------------- +// Internal key helpers +// ---------------------------------------------------------------------------- +function gatewaySk(): Uint8Array { + const pk = process.env.INFT_GATEWAY_PK; + if (!pk) throw new Error("INFT_GATEWAY_PK missing"); + return hexToBytes(pk.startsWith("0x") ? pk.slice(2) : pk); +} + +// ---------------------------------------------------------------------------- +// Public: gateway address +// ---------------------------------------------------------------------------- +/** Returns the Ethereum address derived from INFT_GATEWAY_PK. */ +export function gatewayAddress(): `0x${string}` { + const pk = process.env.INFT_GATEWAY_PK!; + return new Wallet(pk.startsWith("0x") ? pk : `0x${pk}`) + .address as `0x${string}`; +} + +// ---------------------------------------------------------------------------- +// Public: DNS wire-format decoder +// ---------------------------------------------------------------------------- +/** + * Decodes a DNS wire-format name (per RFC 1035 §3.1) into a dotted label + * string such as "tradewise.agentlab.eth". + * Format: 1-byte length prefix per label, terminated by 0x00. + */ +export function decodeDnsName(wire: Uint8Array): string { + const labels: string[] = []; + let i = 0; + while (i < wire.length && wire[i] !== 0) { + const len = wire[i++]!; + labels.push(new TextDecoder().decode(wire.slice(i, i + len))); + i += len; + } + return labels.join("."); +} + +// ---------------------------------------------------------------------------- +// Public: label → agent metadata +// ---------------------------------------------------------------------------- +export type AgentInfo = { + agentId: number | null; + tokenId: number | null; +}; + +/** + * Maps a fully-qualified label like "tradewise.agentlab.eth" to the agent's + * identifiers. Hardcoded for v1; W3 will extend with on-chain lookup. + */ +export async function labelToAgent( + label: string, +): Promise { + const lower = label.toLowerCase(); + if (lower === "tradewise.agentlab.eth") return { agentId: 1, tokenId: 1 }; + if (lower === "pricewatch.agentlab.eth") return { agentId: 2, tokenId: null }; + return null; +} + +// ---------------------------------------------------------------------------- +// Public: computeRecord +// ---------------------------------------------------------------------------- +/** + * Computes the ABI-encoded value for the given resolve selector. + * + * @param label Fully-qualified ENS name (e.g. "tradewise.agentlab.eth") + * @param selector 4-byte function selector as 0x-prefixed hex + * @param args Decoded ABI arguments from the original resolve() calldata + */ +export async function computeRecord( + label: string, + selector: `0x${string}`, + args: unknown[], +): Promise<{ encoded: `0x${string}` }> { + if (selector === TEXT_SELECTOR) { + const key = args[1] as string; + const value = await computeTextRecord(label, key); + return { + encoded: encodeAbiParameters([{ type: "string" }], [value]), + }; + } + + if (selector === ADDR_SELECTOR || selector === ADDR_COIN_SELECTOR) { + const addr = await computeAddr(label); + // addr(bytes32) → bytes (the 20-byte address as ABI bytes) + const addrBytes = hexToBytes(addr.slice(2)); + return { + encoded: encodeAbiParameters( + [{ type: "bytes" }], + [("0x" + bytesToHex(addrBytes)) as `0x${string}`], + ), + }; + } + + if (selector === CONTENTHASH_SELECTOR) { + return { + encoded: encodeAbiParameters([{ type: "bytes" }], ["0x"]), + }; + } + + // Unknown selector — return empty string. + return { + encoded: encodeAbiParameters([{ type: "string" }], [""]), + }; +} + +// ---------------------------------------------------------------------------- +// Internal: text record computation per spec table +// ---------------------------------------------------------------------------- +async function computeTextRecord( + label: string, + key: string, +): Promise { + const agent = await labelToAgent(label); + if (!agent) return ""; + + switch (key) { + // ---- Edge Config-fed static fields (v1: read from Redis directly) ---- + case "agent-card": + case "description": + case "url": + case "current-price-tier": { + const v = await getRedis()?.get(`ens:static:${label}:${key}`); + return v ?? ""; + } + + // ---- W1 cross-link: heartbeat pulse ---- + case "last-seen-at": { + const v = await getRedis()?.get(`agent:${agent.agentId}:last-seen`); + return v ?? ""; + } + + // ---- reputation-summary: Redis cache with live fallback ---- + case "reputation-summary": { + if (agent.agentId === null) return ""; + const cacheKey = `reputation:summary:${agent.agentId}`; + const cached = await getRedis()?.get(cacheKey); + if (cached) return cached; + // Cache miss → live on-chain read + try { + const addrs = await getSepoliaAddresses(); + const count = (await sepoliaPublicClient().readContract({ + address: addrs.reputationRegistry as `0x${string}`, + abi: ReputationRegistryAbi as readonly unknown[], + functionName: "feedbackCount", + args: [BigInt(agent.agentId)], + })) as bigint; + const summary = `feedback=${count}`; + // Cache for 5 minutes + await getRedis()?.set(cacheKey, summary, "EX", 300); + return summary; + } catch { + return ""; + } + } + + // ---- outstanding-bids: on-chain AgentBids.biddersCount ---- + case "outstanding-bids": { + if (agent.tokenId === null) return "0"; + try { + const addrs = await getSepoliaAddresses(); + const count = (await sepoliaPublicClient().readContract({ + address: addrs.agentBidsAddress as `0x${string}`, + abi: AgentBidsAbi as readonly unknown[], + functionName: "biddersCount", + args: [BigInt(agent.tokenId)], + })) as bigint; + return String(count); + } catch { + return "0"; + } + } + + // ---- compliance-status: skip v1 ---- + case "compliance-status": + return ""; + + // ---- tvl: skip v1 ---- + case "tvl": + return "0"; + + // ---- inft-tradeable: on-chain check via encryptedMemoryRoot ---- + // The AgentINFT ABI does not expose `memoryReencrypted`; we proxy it by + // checking whether encryptedMemoryRoot is non-zero (bytes32(0) == not set). + case "inft-tradeable": { + if (agent.tokenId === null) return "0"; + try { + const addrs = await getSepoliaAddresses(); + if (!addrs.inftAddress) return "0"; + const root = (await sepoliaPublicClient().readContract({ + address: addrs.inftAddress as `0x${string}`, + abi: AgentINFTAbi as readonly unknown[], + functionName: "encryptedMemoryRoot", + args: [BigInt(agent.tokenId)], + })) as `0x${string}`; + // Non-zero root means memory has been set (tradeable). + const isSet = + root !== + "0x0000000000000000000000000000000000000000000000000000000000000000"; + return isSet ? "1" : "0"; + } catch { + return "0"; + } + } + + // ---- memory-rotations: Redis inft:meta::rotations ---- + case "memory-rotations": { + if (agent.tokenId === null) return "0"; + const v = await getRedis()?.get( + `inft:meta:${agent.tokenId}:rotations`, + ); + return v ?? "0"; + } + + // ---- avatar: eip155:/erc721:/ ---- + case "avatar": { + if (agent.tokenId === null) return ""; + const addrs = await getSepoliaAddresses(); + if (!addrs.inftAddress) return ""; + return `eip155:11155111/erc721:${addrs.inftAddress}/${agent.tokenId}`; + } + + default: + return ""; + } +} + +// ---------------------------------------------------------------------------- +// Internal: addr resolution +// ---------------------------------------------------------------------------- +async function computeAddr(label: string): Promise<`0x${string}`> { + const zero = "0x0000000000000000000000000000000000000000" as `0x${string}`; + const agent = await labelToAgent(label); + if (!agent || agent.agentId === null) return zero; + + const addrs = await getSepoliaAddresses(); + if (agent.agentId === 1) { + return (addrs.agentEOA as `0x${string}`) ?? zero; + } + if (agent.agentId === 2) { + return (addrs.pricewatchEOA as `0x${string}`) ?? zero; + } + return zero; +} + +// ---------------------------------------------------------------------------- +// Public: signing +// ---------------------------------------------------------------------------- +export type SignedResponse = { + expires: number; + result: `0x${string}`; + signature: `0x${string}`; +}; + +/** + * Signs the EIP-3668 gateway response. + * + * The contract's resolveWithProof verifies: + * keccak256(abi.encodePacked( + * hex"1900", + * address(this), + * expires, // uint64 big-endian 8 bytes + * keccak256(extraData), + * keccak256(result) + * )) + * + * NOTE: The 0x1900 prefix is INSIDE the outer keccak256 input — this is + * NOT wrapped in the standard EIP-191 "Ethereum Signed Message:\n32" prefix. + * We sign the raw digest directly. + */ +export function signGatewayResponse(args: { + resolverAddress: `0x${string}`; + expires: number; + extraData: `0x${string}`; + result: `0x${string}`; +}): SignedResponse { + // Encode expires as 8-byte big-endian (uint64). + const expiresBytes = new Uint8Array(8); + let n = BigInt(args.expires); + for (let i = 7; i >= 0; i--) { + expiresBytes[i] = Number(n & 0xffn); + n >>= 8n; + } + + const resolverBytes = hexToBytes(args.resolverAddress.slice(2)); + const extraDataBytes = hexToBytes( + args.extraData.startsWith("0x") ? args.extraData.slice(2) : args.extraData, + ); + const resultBytes = hexToBytes( + args.result.startsWith("0x") ? args.result.slice(2) : args.result, + ); + + // Build the message exactly as the contract does. + const messageHash = keccak_256( + new Uint8Array([ + 0x19, + 0x00, + ...resolverBytes, + ...expiresBytes, + ...keccak_256(extraDataBytes), + ...keccak_256(resultBytes), + ]), + ); + + const sk = gatewaySk(); + + // secp256k1.sign() returns a raw 64-byte Uint8Array (compact r || s). + // prehash: false because messageHash is already the keccak256 digest. + const compact = secp256k1.sign(messageHash, sk, { prehash: false }); + + // Recover the expected uncompressed public key to determine the recovery bit. + // getPublicKey(sk, false) → 65-byte uncompressed form (0x04 || x || y). + const pubExpected = secp256k1.getPublicKey(sk, false); + let recoveryBit = 0; + for (let rec = 0; rec < 2; rec++) { + try { + // Signature.fromBytes with 64-byte compact input defaults to compact format. + const sigObj = secp256k1.Signature.fromBytes(compact).addRecoveryBit(rec); + // Point.toBytes(false) returns the 65-byte uncompressed public key. + const recovered = sigObj + .recoverPublicKey(messageHash) + .toBytes(false); + if (Buffer.from(recovered).equals(Buffer.from(pubExpected))) { + recoveryBit = rec; + break; + } + } catch { + // ignore + } + } + + // Ethereum v = 27 + recoveryBit + const v = recoveryBit + 27; + // Signature layout expected by Solidity: r (32) || s (32) || v (1) + const sigBytes = new Uint8Array([...compact, v]); + + return { + expires: args.expires, + result: args.result, + signature: `0x${bytesToHex(sigBytes)}` as `0x${string}`, + }; +} + +// ---------------------------------------------------------------------------- +// Public: response encoding +// ---------------------------------------------------------------------------- +/** + * ABI-encodes the signed gateway response as (uint64 expires, bytes result, bytes signature) + * for the resolveWithProof callback. + */ +export function encodeResponse(r: SignedResponse): `0x${string}` { + return encodeAbiParameters( + [{ type: "uint64" }, { type: "bytes" }, { type: "bytes" }], + [BigInt(r.expires), r.result, r.signature], + ); +} From 895de47e0575d31e1cceb3779fc087719a32d4fa Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 17:53:07 +0200 Subject: [PATCH 07/16] test(w2): ens-gateway lib smoke (sig + response encode) Co-Authored-By: Claude Sonnet 4.6 --- scripts/ens-gateway-smoke.ts | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 scripts/ens-gateway-smoke.ts diff --git a/scripts/ens-gateway-smoke.ts b/scripts/ens-gateway-smoke.ts new file mode 100644 index 0000000..03ffdc0 --- /dev/null +++ b/scripts/ens-gateway-smoke.ts @@ -0,0 +1,124 @@ +/** + * Smoke test for lib/ens-gateway.ts (Task 6). + * + * Run with: + * INFT_GATEWAY_PK=0x$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") \ + * pnpm exec tsx scripts/ens-gateway-smoke.ts + * + * Does NOT require Redis, Edge Config, or an RPC endpoint — all live reads + * are exercised only via the text record paths that need them; the smoke avoids + * those paths intentionally. + */ + +import { encodeAbiParameters } from "viem"; +import { + gatewayAddress, + decodeDnsName, + labelToAgent, + computeRecord, + signGatewayResponse, + encodeResponse, +} from "../lib/ens-gateway"; + +async function main() { + // 1. Gateway address derived from env PK. + const addr = gatewayAddress(); + console.log("gateway address:", addr); + if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) { + throw new Error(`gatewayAddress returned unexpected value: ${addr}`); + } + console.log("✓ gatewayAddress OK"); + + // 2. DNS wire-format decoder. + // "tradewise.agentlab.eth" in wire format: + // 09 t r a d e w i s e 08 a g e n t l a b 03 e t h 00 + const wire = new Uint8Array([ + 0x09, + ...Array.from("tradewise").map((c) => c.charCodeAt(0)), + 0x08, + ...Array.from("agentlab").map((c) => c.charCodeAt(0)), + 0x03, + ...Array.from("eth").map((c) => c.charCodeAt(0)), + 0x00, + ]); + const decoded = decodeDnsName(wire); + if (decoded !== "tradewise.agentlab.eth") { + throw new Error(`decodeDnsName returned "${decoded}", expected "tradewise.agentlab.eth"`); + } + console.log("✓ decodeDnsName OK:", decoded); + + // 3. labelToAgent hardcoded mapping. + const agent1 = await labelToAgent("tradewise.agentlab.eth"); + if (!agent1 || agent1.agentId !== 1 || agent1.tokenId !== 1) { + throw new Error(`labelToAgent(tradewise) returned unexpected: ${JSON.stringify(agent1)}`); + } + const agent2 = await labelToAgent("pricewatch.agentlab.eth"); + if (!agent2 || agent2.agentId !== 2 || agent2.tokenId !== null) { + throw new Error(`labelToAgent(pricewatch) returned unexpected: ${JSON.stringify(agent2)}`); + } + const agentNull = await labelToAgent("unknown.agentlab.eth"); + if (agentNull !== null) { + throw new Error(`labelToAgent(unknown) should return null, got: ${JSON.stringify(agentNull)}`); + } + console.log("✓ labelToAgent OK"); + + // 4. computeRecord for a text selector — Redis/RPC paths gracefully return "" + // when those services are absent. + const textOut = await computeRecord( + "tradewise.agentlab.eth", + "0x59d1d43c", + ["0x0000000000000000000000000000000000000000000000000000000000000000", "last-seen-at"], + ); + if (!textOut.encoded.startsWith("0x")) { + throw new Error(`computeRecord text result must be 0x-prefixed hex`); + } + console.log( + "✓ computeRecord(text) encoded:", + textOut.encoded.slice(0, 66) + "...", + ); + + // 5. computeRecord for contenthash selector — must return 0x-prefixed. + const chOut = await computeRecord( + "tradewise.agentlab.eth", + "0xbc1c58d1", + ["0x0000000000000000000000000000000000000000000000000000000000000000"], + ); + if (!chOut.encoded.startsWith("0x")) { + throw new Error(`computeRecord contenthash result must be 0x-prefixed hex`); + } + console.log("✓ computeRecord(contenthash) OK"); + + // 6. signGatewayResponse + encodeResponse. + const result = encodeAbiParameters([{ type: "string" }], ["hello world"]); + const resolverAddress = "0x0000000000000000000000000000000000001234" as `0x${string}`; + const extraData = "0xdeadbeef" as `0x${string}`; + const expires = Math.floor(Date.now() / 1000) + 60; + + const signed = signGatewayResponse({ + resolverAddress, + expires, + extraData, + result, + }); + + // Signature must be exactly 65 bytes → 0x + 130 hex chars = 132 total. + if (signed.signature.length !== 132) { + throw new Error( + `signature length ${signed.signature.length} != 132 (expected 65 bytes as 0x-hex)`, + ); + } + console.log("✓ signGatewayResponse signature length OK:", signed.signature.length); + + const encoded = encodeResponse(signed); + if (!encoded.startsWith("0x") || encoded.length < 10) { + throw new Error(`encodeResponse returned invalid hex: ${encoded.slice(0, 40)}`); + } + console.log("✓ encodeResponse bytes length:", (encoded.length - 2) / 2); + + console.log("\n✓ all checks passed"); +} + +main().catch((err) => { + console.error("FAIL:", err); + process.exit(1); +}); From 043087da94447d6dc974ed3f10b3b8efb9a86c6c Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 23:40:03 +0200 Subject: [PATCH 08/16] fix(w2): inft-tradeable uses AgentINFT.memoryReencrypted (W1 ABI now available post-merge) --- lib/ens-gateway.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/ens-gateway.ts b/lib/ens-gateway.ts index be1adca..e3ff9bb 100644 --- a/lib/ens-gateway.ts +++ b/lib/ens-gateway.ts @@ -199,25 +199,22 @@ async function computeTextRecord( case "tvl": return "0"; - // ---- inft-tradeable: on-chain check via encryptedMemoryRoot ---- - // The AgentINFT ABI does not expose `memoryReencrypted`; we proxy it by - // checking whether encryptedMemoryRoot is non-zero (bytes32(0) == not set). + // ---- inft-tradeable: AgentINFT.memoryReencrypted(tokenId) ---- + // True iff the last transfer went through the proof path (oracle + // re-encrypted memory to current owner). False after a raw transferFrom + // bypass — the new owner cannot decrypt the memory blob. case "inft-tradeable": { if (agent.tokenId === null) return "0"; try { const addrs = await getSepoliaAddresses(); if (!addrs.inftAddress) return "0"; - const root = (await sepoliaPublicClient().readContract({ + const ok = (await sepoliaPublicClient().readContract({ address: addrs.inftAddress as `0x${string}`, abi: AgentINFTAbi as readonly unknown[], - functionName: "encryptedMemoryRoot", + functionName: "memoryReencrypted", args: [BigInt(agent.tokenId)], - })) as `0x${string}`; - // Non-zero root means memory has been set (tradeable). - const isSet = - root !== - "0x0000000000000000000000000000000000000000000000000000000000000000"; - return isSet ? "1" : "0"; + })) as boolean; + return ok ? "1" : "0"; } catch { return "0"; } From 0aa7b87e9957c23c4707e5b16d8aa1b9c238db47 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 00:01:39 +0200 Subject: [PATCH 09/16] =?UTF-8?q?feat(w2):=20/api/ens-gateway/[sender]/[da?= =?UTF-8?q?ta]=20route=20=E2=80=94=20decode=20+=20compute=20+=20sign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIP-3668 CCIP-Read gateway endpoint. Handles GET and POST per the {base}/{sender}/{data}.json URL pattern. Decodes the outer (name, resolveCalldata) tuple, DNS wire-format name → label, dispatches by selector to computeRecord, signs with signGatewayResponse, returns {data: encodeResponse(signed)}. Co-Authored-By: Claude Sonnet 4.6 --- app/api/ens-gateway/[sender]/[data]/route.ts | 90 ++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 app/api/ens-gateway/[sender]/[data]/route.ts diff --git a/app/api/ens-gateway/[sender]/[data]/route.ts b/app/api/ens-gateway/[sender]/[data]/route.ts new file mode 100644 index 0000000..3f2ba4a --- /dev/null +++ b/app/api/ens-gateway/[sender]/[data]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hexToBytes } from "@noble/curves/utils.js"; +import { decodeAbiParameters } from "viem"; +import { + decodeDnsName, + computeRecord, + signGatewayResponse, + encodeResponse, +} from "@/lib/ens-gateway"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const RESPONSE_TTL = 60; // seconds + +type Params = { params: Promise<{ sender: string; data: string }> }; + +async function handle(req: NextRequest, ctx: Params): Promise { + const { sender, data: dataParam } = await ctx.params; + + // EIP-3668 GET URL pattern: {base}/{sender}/{data}.json — strip .json suffix + const dataHex = dataParam.replace(/\.json$/, ""); + if (!/^0x[0-9a-fA-F]+$/.test(dataHex)) { + return NextResponse.json({ error: "invalid data" }, { status: 400 }); + } + + let nameWire: `0x${string}`; + let resolveCalldata: `0x${string}`; + try { + // Decode the outer (bytes name, bytes resolveCalldata) tuple per EIP-3668 / OffchainLookup spec + const decoded = decodeAbiParameters( + [{ type: "bytes" }, { type: "bytes" }], + dataHex as `0x${string}`, + ); + nameWire = decoded[0] as `0x${string}`; + resolveCalldata = decoded[1] as `0x${string}`; + } catch { + return NextResponse.json({ error: "invalid calldata encoding" }, { status: 400 }); + } + + // Decode DNS wire-format name → dotted label string + const label = decodeDnsName(hexToBytes(nameWire.slice(2))); + + // Parse the inner resolve calldata: 4-byte selector + ABI-encoded args + const selector = resolveCalldata.slice(0, 10) as `0x${string}`; + const argsHex = ("0x" + resolveCalldata.slice(10)) as `0x${string}`; + + let args: unknown[] = []; + if (selector === "0x59d1d43c") { + // text(bytes32 node, string key) + args = [...decodeAbiParameters( + [{ type: "bytes32" }, { type: "string" }], + argsHex, + )]; + } else if (selector === "0x3b3b57de") { + // addr(bytes32 node) + args = [...decodeAbiParameters( + [{ type: "bytes32" }], + argsHex, + )]; + } else if (selector === "0xf1cb7e06") { + // addr(bytes32 node, uint256 coinType) + args = [...decodeAbiParameters( + [{ type: "bytes32" }, { type: "uint256" }], + argsHex, + )]; + } else if (selector === "0xbc1c58d1") { + // contenthash(bytes32 node) + args = [...decodeAbiParameters( + [{ type: "bytes32" }], + argsHex, + )]; + } else { + return NextResponse.json({ error: "unsupported selector" }, { status: 400 }); + } + + const { encoded } = await computeRecord(label, selector, args); + const expires = Math.floor(Date.now() / 1000) + RESPONSE_TTL; + const signed = signGatewayResponse({ + resolverAddress: sender as `0x${string}`, + expires, + extraData: dataHex as `0x${string}`, + result: encoded, + }); + + return NextResponse.json({ data: encodeResponse(signed) }); +} + +export const GET = handle; +export const POST = handle; From 73d3b9407a7034709c73b4ce0422834127aa5b33 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 00:01:44 +0200 Subject: [PATCH 10/16] feat(w2): /api/ens-gateway/cache/invalidate route (KeeperHub trigger sink) Internal POST route gated by KEEPERHUB_WEBHOOK_SECRET bearer auth. Accepts {keys: string[]} (max 50) and calls redis.del() for each key. Returns {ok: true, deleted: N}. Called by KeeperHub workflows on chain-event triggers to evict stale ENS record cache entries. Co-Authored-By: Claude Sonnet 4.6 --- app/api/ens-gateway/cache/invalidate/route.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/api/ens-gateway/cache/invalidate/route.ts diff --git a/app/api/ens-gateway/cache/invalidate/route.ts b/app/api/ens-gateway/cache/invalidate/route.ts new file mode 100644 index 0000000..a6984af --- /dev/null +++ b/app/api/ens-gateway/cache/invalidate/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getRedis } from "@/lib/redis"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const Body = z.object({ + keys: z.array(z.string()).min(1).max(50), +}); + +export async function POST(req: NextRequest): Promise { + // Gate on KEEPERHUB_WEBHOOK_SECRET — invalidations come from KeeperHub workflows + const secret = process.env.KEEPERHUB_WEBHOOK_SECRET; + if (!secret || req.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid json" }, { status: 400 }); + } + + const parsed = Body.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + } + + const r = getRedis(); + if (!r) { + return NextResponse.json({ error: "no redis" }, { status: 500 }); + } + + await Promise.all(parsed.data.keys.map((k) => r.del(k))); + + return NextResponse.json({ ok: true, deleted: parsed.data.keys.length }); +} From c4d6cd2b5d2ebf432f569ca5f1c334287a2abe1b Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 00:05:02 +0200 Subject: [PATCH 11/16] =?UTF-8?q?feat(w2):=20lib/ens-records=20=E2=80=94?= =?UTF-8?q?=20typed=20wagmi/viem=20ENS=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- lib/ens-records.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 lib/ens-records.ts diff --git a/lib/ens-records.ts b/lib/ens-records.ts new file mode 100644 index 0000000..63d8e7a --- /dev/null +++ b/lib/ens-records.ts @@ -0,0 +1,26 @@ +import { sepoliaPublicClient } from "@/lib/wallets"; + +export async function readEnsText(name: string, key: string): Promise { + try { + const value = await sepoliaPublicClient().getEnsText({ + name, + key, + }); + return value; + } catch (err) { + console.error(`[ens-records] getEnsText ${name} ${key} failed:`, err); + return null; + } +} + +export async function readAgentTelemetry(label: string) { + const [lastSeenAt, rotations, inftTradeable, outstandingBids, reputationSummary] = + await Promise.all([ + readEnsText(label, "last-seen-at"), + readEnsText(label, "memory-rotations"), + readEnsText(label, "inft-tradeable"), + readEnsText(label, "outstanding-bids"), + readEnsText(label, "reputation-summary"), + ]); + return { lastSeenAt, rotations, inftTradeable, outstandingBids, reputationSummary }; +} From a671e36e344cab73e27bf0d769eb459403f41027 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 00:05:05 +0200 Subject: [PATCH 12/16] =?UTF-8?q?feat(w2):=20/inft=20cross-link=20?= =?UTF-8?q?=E2=80=94=20read=20inft-tradeable=20+=20memory-rotations=20via?= =?UTF-8?q?=20ENS=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/inft/page.tsx | 48 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/app/inft/page.tsx b/app/inft/page.tsx index 52703a3..f794bbd 100644 --- a/app/inft/page.tsx +++ b/app/inft/page.tsx @@ -2,6 +2,7 @@ import { getSepoliaAddresses } from "@/lib/edge-config"; import { readInft } from "@/lib/inft"; import { AGENT_ENS } from "@/lib/ens"; import { readStandingBids, readBidHistory, formatUsdc } from "@/lib/bids"; +import { readAgentTelemetry } from "@/lib/ens-records"; import BidControls from "./bid-controls"; import SiteNav from "@/components/site-nav"; import MemoryStaleBadge from "@/components/memory-stale-badge"; @@ -34,12 +35,18 @@ export default async function InftPage() { }) : null; - const [standingBids, bidHistory] = bidsAddress - ? await Promise.all([ - readStandingBids({ bidsAddress, tokenId }), - readBidHistory({ bidsAddress, tokenId, limit: 10 }), - ]) - : [[], []]; + const [bidsResult, ensTelemetry] = await Promise.all([ + bidsAddress + ? Promise.all([ + readStandingBids({ bidsAddress, tokenId }), + readBidHistory({ bidsAddress, tokenId, limit: 10 }), + ]) + : null, + readAgentTelemetry("tradewise.agentlab.eth"), + ]); + + const standingBids = bidsResult ? bidsResult[0] : []; + const bidHistory = bidsResult ? bidsResult[1] : []; return (
@@ -162,10 +169,35 @@ export default async function InftPage() { - {/* ── Bidding ── */} + {/* ── ENS gateway telemetry ── */}
§02 +
+

live telemetry

+

via ENS gateway · W2 CCIP-Read

+
+
+
+
+ + + + + +
+

+ Records resolved from tradewise.agentlab.eth via the W2 offchain + resolver (EIP-3668). Values show "—" until the OffchainResolver is deployed + and agentlab.eth's resolver slot is flipped (M5). +

+
+
+ + {/* ── Bidding ── */} +
+
+ §03

bidding

@@ -238,7 +270,7 @@ export default async function InftPage() { {bidHistory.length > 0 ? (

- §03 + §04

bid history

on-chain audit trail

From af79e3c214f7699d5c5206a6eca4128392bfc4c0 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 00:05:08 +0200 Subject: [PATCH 13/16] feat(w2): /ens-debug page + /api/ens-debug route (CCIP-Read demo surface) Co-Authored-By: Claude Sonnet 4.6 --- app/api/ens-debug/route.ts | 19 +++++++++++ app/ens-debug/page.tsx | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 app/api/ens-debug/route.ts create mode 100644 app/ens-debug/page.tsx diff --git a/app/api/ens-debug/route.ts b/app/api/ens-debug/route.ts new file mode 100644 index 0000000..9c61595 --- /dev/null +++ b/app/api/ens-debug/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readEnsText } from "@/lib/ens-records"; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const name = url.searchParams.get("name"); + const key = url.searchParams.get("key"); + if (!name || !key) { + return NextResponse.json({ error: "missing name/key" }, { status: 400 }); + } + const t0 = Date.now(); + const value = await readEnsText(name, key); + return NextResponse.json({ + name, + key, + value, + latencyMs: Date.now() - t0, + }); +} diff --git a/app/ens-debug/page.tsx b/app/ens-debug/page.tsx new file mode 100644 index 0000000..a88958b --- /dev/null +++ b/app/ens-debug/page.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useState } from "react"; +import SiteNav from "@/components/site-nav"; + +export default function EnsDebugPage() { + const [name, setName] = useState("tradewise.agentlab.eth"); + const [key, setKey] = useState("last-seen-at"); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + async function go() { + setLoading(true); + try { + const res = await fetch( + `/api/ens-debug?name=${encodeURIComponent(name)}&key=${encodeURIComponent(key)}`, + ); + setResult(await res.json()); + } catch (err) { + setResult({ error: String(err) }); + } finally { + setLoading(false); + } + } + + return ( +
+ +
+

debug · ens ccip-read

+

/ens-debug

+

+ Resolves an ENS text record through the W2 offchain gateway and shows + the full OffchainLookup roundtrip — revert, gateway URL, signed + response, ecrecovered signer. +

+
+
+
+ setName(e.target.value)} + className="border px-3 py-2 text-sm bg-transparent" + placeholder="name" + /> + setKey(e.target.value)} + className="border px-3 py-2 text-sm bg-transparent" + placeholder="text key" + /> + +
+ {result ? ( +
+            {JSON.stringify(result, null, 2)}
+          
+ ) : null} +
+
+ ); +} From a4ed5ad8c2e8ccb0325b12b0c2b2890816cb3968 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 10:32:38 +0200 Subject: [PATCH 14/16] =?UTF-8?q?feat(w2):=20M5=20deploy=20=E2=80=94=20Off?= =?UTF-8?q?chainResolver=20on=20Sepolia=20+=20flip=20agentlab.eth=20resolv?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed: OffchainResolver: 0x4F956e6521A4B87b9f9b2D5ED191fB6134Bc8C17 Gateway signer: 0xe358F777daF973E64d0F9b2e73bc34e4C7F65c9b Gateway URL: https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json Resolver flips on Sepolia ENS Registry: agentlab.eth → OffchainResolver (was ENSv1 PublicResolver) tradewise.agentlab.eth → OffchainResolver pricewatch.agentlab.eth → OffchainResolver After this, every ENS query for *.agentlab.eth flows through our gateway: the OffchainResolver reverts OffchainLookup, viem/ENS clients GET our /api/ens-gateway endpoint per EIP-3668, gateway computes the record value live (Redis + on-chain reads), signs with INFT_GATEWAY_PK, resolveWithProof verifies the sig with ecrecover. Zero gas per read, live data, every record dynamic. Vercel env: INFT_GATEWAY_PK set on Production. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deployments/sepolia-ens-resolver.json | 1 + scripts/set-agentlab-resolver.ts | 109 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 contracts/deployments/sepolia-ens-resolver.json create mode 100644 scripts/set-agentlab-resolver.ts diff --git a/contracts/deployments/sepolia-ens-resolver.json b/contracts/deployments/sepolia-ens-resolver.json new file mode 100644 index 0000000..29c8506 --- /dev/null +++ b/contracts/deployments/sepolia-ens-resolver.json @@ -0,0 +1 @@ +{"network":"sepolia","chainId":11155111,"offchainResolver":"0x4F956e6521A4B87b9f9b2D5ED191fB6134Bc8C17","signer":"0xe358F777daF973E64d0F9b2e73bc34e4C7F65c9b","gatewayUrl":"https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json"} \ No newline at end of file diff --git a/scripts/set-agentlab-resolver.ts b/scripts/set-agentlab-resolver.ts new file mode 100644 index 0000000..15f16a7 --- /dev/null +++ b/scripts/set-agentlab-resolver.ts @@ -0,0 +1,109 @@ +/** + * Sets the OffchainResolver as agentlab.eth's resolver in the Sepolia ENS + * registry. Owner of agentlab.eth must be the AGENT_PK wallet (verified + * in pre-flight). + * + * Required env: AGENT_PK, SEPOLIA_RPC_URL, OFFCHAIN_RESOLVER_ADDRESS. + */ +import { createPublicClient, createWalletClient, http, namehash, type Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { sepolia } from "viem/chains"; + +const ENS_REGISTRY = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; + +const REGISTRY_ABI = [ + { + type: "function", + name: "owner", + stateMutability: "view", + inputs: [{ name: "node", type: "bytes32" }], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "resolver", + stateMutability: "view", + inputs: [{ name: "node", type: "bytes32" }], + outputs: [{ name: "", type: "address" }], + }, + { + type: "function", + name: "setResolver", + stateMutability: "nonpayable", + inputs: [ + { name: "node", type: "bytes32" }, + { name: "resolver", type: "address" }, + ], + outputs: [], + }, +] as const; + +function envOrThrow(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`missing ${name}`); + return v; +} + +async function main() { + const node = namehash("agentlab.eth"); + const newResolver = envOrThrow("OFFCHAIN_RESOLVER_ADDRESS") as `0x${string}`; + const pkRaw = envOrThrow("AGENT_PK"); + const pk = (pkRaw.startsWith("0x") ? pkRaw : `0x${pkRaw}`) as Hex; + const rpcUrl = envOrThrow("SEPOLIA_RPC_URL"); + + const account = privateKeyToAccount(pk); + const pub = createPublicClient({ chain: sepolia, transport: http(rpcUrl) }); + const wallet = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) }); + + const owner = (await pub.readContract({ + address: ENS_REGISTRY, + abi: REGISTRY_ABI, + functionName: "owner", + args: [node], + })) as `0x${string}`; + console.log("agentlab.eth owner: ", owner); + console.log("AGENT (broadcaster): ", account.address); + + if (owner.toLowerCase() !== account.address.toLowerCase()) { + throw new Error(`owner ${owner} != broadcaster ${account.address}; cannot setResolver`); + } + + const currentResolver = (await pub.readContract({ + address: ENS_REGISTRY, + abi: REGISTRY_ABI, + functionName: "resolver", + args: [node], + })) as `0x${string}`; + console.log("current resolver: ", currentResolver); + console.log("target resolver: ", newResolver); + + if (currentResolver.toLowerCase() === newResolver.toLowerCase()) { + console.log("\n✓ already set; nothing to do"); + return; + } + + console.log("\n→ ENS.setResolver(agentlab.eth, OffchainResolver)..."); + const txHash = await wallet.writeContract({ + address: ENS_REGISTRY, + abi: REGISTRY_ABI, + functionName: "setResolver", + args: [node, newResolver], + }); + console.log("tx:", txHash); + const receipt = await pub.waitForTransactionReceipt({ hash: txHash }); + console.log("block:", receipt.blockNumber); + + const verified = (await pub.readContract({ + address: ENS_REGISTRY, + abi: REGISTRY_ABI, + functionName: "resolver", + args: [node], + })) as `0x${string}`; + console.log("resolver post-set: ", verified); + console.log("\n✓ done."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 769e6d47b1e1e326cb83605e85e70ba62bdf6753 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 10:36:41 +0200 Subject: [PATCH 15/16] test(w2): e2e against deployed resolver + local dev gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates full EIP-3668 pipeline: callData construction → local gateway POST → local sig verification → on-chain resolveWithProof (eth_call). Covers tamper/expiry revert tests, wildcard infrastructure, and W1 cross-link (inft-tradeable = 1 confirmed on-chain via AgentINFT.memoryReencrypted). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/test-ens-gateway-e2e.ts | 579 ++++++++++++++++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 scripts/test-ens-gateway-e2e.ts diff --git a/scripts/test-ens-gateway-e2e.ts b/scripts/test-ens-gateway-e2e.ts new file mode 100644 index 0000000..42d2a9a --- /dev/null +++ b/scripts/test-ens-gateway-e2e.ts @@ -0,0 +1,579 @@ +/** + * End-to-end test for the ENS gateway pipeline (W2 Milestone 6). + * + * Tests the full EIP-3668 flow: OffchainLookup callData construction → + * local gateway response → signature verification → on-chain resolveWithProof. + * + * Usage: + * # Terminal 1: + * pnpm dev + * + * # Terminal 2: + * set -a; source .env.local; set +a + * pnpm exec tsx scripts/test-ens-gateway-e2e.ts + * + * Prints "ALL GREEN" and exits 0 on success. + */ + +import { + encodeAbiParameters, + decodeAbiParameters, + keccak256, + createPublicClient, + http, + type Address, + type Hex, +} from "viem"; +import { sepolia } from "viem/chains"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { hexToBytes, bytesToHex } from "@noble/curves/utils.js"; +import { Wallet } from "ethers"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const RESOLVER_ADDR = "0x4F956e6521A4B87b9f9b2D5ED191fB6134Bc8C17" as Address; +const LOCAL_GATEWAY_BASE = "http://localhost:3000/api/ens-gateway"; + +// Selectors +const TEXT_SELECTOR = "0x59d1d43c" as Hex; // text(bytes32,string) +const ADDR_SELECTOR = "0x3b3b57de" as Hex; // addr(bytes32) + +// ─── OffchainResolver ABI (minimal — only what we need) ─────────────────────── +const RESOLVER_ABI = [ + { + type: "function", + name: "resolveWithProof", + stateMutability: "view", + inputs: [ + { name: "response", type: "bytes" }, + { name: "extraData", type: "bytes" }, + ], + outputs: [{ name: "", type: "bytes" }], + }, + { + type: "error", + name: "ExpiredResponse", + inputs: [], + }, + { + type: "error", + name: "InvalidSigner", + inputs: [], + }, +] as const; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function envOrThrow(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing required env var: ${name}`); + return v; +} + +let passed = 0; +let failed = 0; + +function ok(label: string) { + console.log(`✓ ${label}`); + passed++; +} + +function fail(label: string, err?: unknown) { + const msg = err instanceof Error ? err.message : String(err ?? ""); + console.error(`✗ ${label}${msg ? `: ${msg}` : ""}`); + failed++; +} + +/** DNS wire-format encode a dotted name. e.g. "a.b.eth" → Uint8Array */ +function encodeDnsWire(name: string): Uint8Array { + const labels = name.split("."); + const parts: number[] = []; + for (const label of labels) { + const bytes = Array.from(new TextEncoder().encode(label)); + parts.push(bytes.length, ...bytes); + } + parts.push(0x00); // terminator + return new Uint8Array(parts); +} + +/** Compute the namehash (node) for a dotted ENS name. */ +function namehash(name: string): Hex { + let node = new Uint8Array(32); // 0x000... + if (name === "") return ("0x" + bytesToHex(node)) as Hex; + const labels = name.split(".").reverse(); + for (const label of labels) { + const labelHash = keccak_256(new TextEncoder().encode(label)); + node = keccak_256(new Uint8Array([...node, ...labelHash])); + } + return ("0x" + bytesToHex(node)) as Hex; +} + +/** + * Builds the outer abi.encode(name, resolveCalldata) that the OffchainResolver + * passes as both `callData` and `extraData` in the OffchainLookup revert. + */ +function buildOffchainLookupCallData( + dnsName: Uint8Array, + innerCalldata: Hex, +): Hex { + const nameHex = ("0x" + bytesToHex(dnsName)) as Hex; + return encodeAbiParameters( + [{ type: "bytes" }, { type: "bytes" }], + [nameHex, innerCalldata], + ); +} + +/** Build inner calldata for text(bytes32 node, string key). */ +function buildTextCalldata(node: Hex, key: string): Hex { + const argsEncoded = encodeAbiParameters( + [{ type: "bytes32" }, { type: "string" }], + [node, key], + ); + return (TEXT_SELECTOR + argsEncoded.slice(2)) as Hex; +} + +/** Build inner calldata for addr(bytes32 node). */ +function buildAddrCalldata(node: Hex): Hex { + const argsEncoded = encodeAbiParameters( + [{ type: "bytes32" }], + [node], + ); + return (ADDR_SELECTOR + argsEncoded.slice(2)) as Hex; +} + +/** POST callData to local gateway, return raw { data: "0x..." } body. */ +async function postToGateway( + resolverAddr: Address, + callData: Hex, +): Promise<{ data: Hex }> { + const url = `${LOCAL_GATEWAY_BASE}/${resolverAddr}/${callData}.json`; + const resp = await fetch(url, { method: "POST" }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + throw new Error(`Gateway returned HTTP ${resp.status}: ${body}`); + } + return resp.json() as Promise<{ data: Hex }>; +} + +/** + * ABI-decode the gateway response bytes as (uint64 expires, bytes result, bytes signature). + */ +function decodeGatewayResponse(responseHex: Hex): { + expires: bigint; + result: Hex; + signature: Hex; +} { + const decoded = decodeAbiParameters( + [{ type: "uint64" }, { type: "bytes" }, { type: "bytes" }], + responseHex, + ); + return { + expires: decoded[0] as bigint, + result: decoded[1] as Hex, + signature: decoded[2] as Hex, + }; +} + +/** + * Verify the EIP-191 v0 gateway signature locally. + * Hash = keccak256(0x1900 || resolverAddr || expires(8B BE) || keccak256(extraData) || keccak256(result)) + * Returns the recovered signer address. + */ +function recoverGatewaySigner(args: { + resolverAddress: Address; + expires: bigint; + extraData: Hex; + result: Hex; + signature: Hex; +}): Address { + const expiresBytes = new Uint8Array(8); + let n = args.expires; + for (let i = 7; i >= 0; i--) { + expiresBytes[i] = Number(n & 0xffn); + n >>= 8n; + } + + const resolverBytes = hexToBytes(args.resolverAddress.slice(2)); + const extraDataBytes = hexToBytes(args.extraData.slice(2)); + const resultBytes = hexToBytes(args.result.slice(2)); + + const messageHash = keccak_256( + new Uint8Array([ + 0x19, + 0x00, + ...resolverBytes, + ...expiresBytes, + ...keccak_256(extraDataBytes), + ...keccak_256(resultBytes), + ]), + ); + + const sigBytes = hexToBytes(args.signature.slice(2)); + if (sigBytes.length !== 65) { + throw new Error(`Signature length ${sigBytes.length} != 65`); + } + + const compact = sigBytes.slice(0, 64); + const v = sigBytes[64]!; + const recoveryBit = v === 27 ? 0 : v === 28 ? 1 : v - 27; + + const sigObj = secp256k1.Signature.fromBytes(compact).addRecoveryBit(recoveryBit); + const recoveredPub = sigObj.recoverPublicKey(messageHash).toBytes(false); // 65B uncompressed + const addrHash = keccak_256(recoveredPub.slice(1)); + const addrBytes = addrHash.slice(12); + return ("0x" + bytesToHex(addrBytes)) as Address; +} + +/** Derive Ethereum address from a private key (0x-prefixed or raw hex). */ +function pkToAddress(pk: string): Address { + const normalized = pk.startsWith("0x") ? pk : `0x${pk}`; + return new Wallet(normalized).address as Address; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + console.log("=== ENS Gateway E2E Test (W2 M6) ==="); + console.log(`resolver: ${RESOLVER_ADDR}`); + console.log(`gateway: ${LOCAL_GATEWAY_BASE}`); + console.log(); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 0 – Pre-flight: check required env vars + local dev server + // ══════════════════════════════════════════════════════════════════════════ + console.log("--- Pre-flight ---"); + + let rpcUrl: string; + let gatewayPk: string; + let expectedSigner: Address; + + try { + rpcUrl = envOrThrow("SEPOLIA_RPC_URL"); + gatewayPk = envOrThrow("INFT_GATEWAY_PK"); + expectedSigner = pkToAddress(gatewayPk); + + console.log(`SEPOLIA_RPC_URL: ${rpcUrl}`); + console.log(`INFT_GATEWAY_PK: ${gatewayPk.slice(0, 10)}... (derived signer: ${expectedSigner})`); + console.log(`expected signer: ${expectedSigner}`); + ok("pre-flight: env vars present"); + } catch (e) { + fail("pre-flight: env vars", e); + console.error("Cannot continue — required env vars missing."); + process.exit(1); + } + + // Check local dev server + try { + const pingResp = await fetch("http://localhost:3000", { method: "GET" }); + // Any response (even 404) means the server is up + console.log(`Local dev server responded with HTTP ${pingResp.status}`); + ok("pre-flight: local dev server is running"); + } catch { + console.error("\nFAIL: Local dev server is not running on port 3000."); + console.error("Start it first: pnpm dev"); + process.exit(1); + } + + // ══════════════════════════════════════════════════════════════════════════ + // SETUP: Create public client for on-chain calls + // ══════════════════════════════════════════════════════════════════════════ + const publicClient = createPublicClient({ + chain: sepolia, + transport: http(rpcUrl), + }); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 1 – Construct OffchainLookup callData + POST to local gateway + // (tradewise.agentlab.eth, key: last-seen-at) + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 1: Construct callData for tradewise.agentlab.eth (last-seen-at) ---"); + + const tradewiseName = "tradewise.agentlab.eth"; + const tradewiseNode = namehash(tradewiseName); + const tradewiseDns = encodeDnsWire(tradewiseName); + + const textCalldata = buildTextCalldata(tradewiseNode, "last-seen-at"); + const outerCallData = buildOffchainLookupCallData(tradewiseDns, textCalldata); + + console.log(`DNS wire bytes: ${bytesToHex(tradewiseDns)}`); + console.log(`node (namehash): ${tradewiseNode}`); + console.log(`inner calldata (first 10 bytes): ${textCalldata.slice(0, 12)}...`); + console.log(`outer callData (first 20 bytes): ${outerCallData.slice(0, 22)}...`); + ok("step 1: callData constructed"); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 2 – POST to local gateway, assert non-empty response + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 2: POST to local gateway ---"); + + let gatewayResponseHex: Hex; + try { + const body = await postToGateway(RESOLVER_ADDR, outerCallData); + if (!body.data || !body.data.startsWith("0x") || body.data.length < 4) { + throw new Error(`Unexpected response body: ${JSON.stringify(body)}`); + } + gatewayResponseHex = body.data; + console.log(`gateway response: ${gatewayResponseHex.slice(0, 40)}...`); + ok("step 2: gateway returned non-empty 0x-prefixed data"); + } catch (e) { + fail("step 2: POST to local gateway", e); + console.error("Cannot continue — gateway request failed."); + process.exit(1); + } + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 3 – Decode + verify signature locally + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 3: Decode + verify signature locally ---"); + + let decoded: { expires: bigint; result: Hex; signature: Hex }; + try { + decoded = decodeGatewayResponse(gatewayResponseHex); + console.log(`expires: ${decoded.expires} (${new Date(Number(decoded.expires) * 1000).toISOString()})`); + console.log(`result: ${decoded.result.slice(0, 40)}...`); + console.log(`sig: ${decoded.signature.slice(0, 20)}...`); + + const recovered = recoverGatewaySigner({ + resolverAddress: RESOLVER_ADDR, + expires: decoded.expires, + extraData: outerCallData, + result: decoded.result, + signature: decoded.signature, + }); + console.log(`recovered signer: ${recovered}`); + console.log(`expected signer: ${expectedSigner}`); + + if (recovered.toLowerCase() !== expectedSigner.toLowerCase()) { + throw new Error( + `Signer mismatch: recovered=${recovered}, expected=${expectedSigner}`, + ); + } + ok("step 3: local signature verification passed"); + } catch (e) { + fail("step 3: local signature verification", e); + console.error("Cannot continue — signature invalid."); + process.exit(1); + } + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 4 – On-chain resolveWithProof eth_call + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 4: On-chain resolveWithProof eth_call ---"); + + let resolvedResult: Hex; + try { + const callResult = await publicClient.readContract({ + address: RESOLVER_ADDR, + abi: RESOLVER_ABI, + functionName: "resolveWithProof", + args: [gatewayResponseHex, outerCallData], + }); + resolvedResult = callResult as Hex; + console.log(`resolveWithProof result: ${resolvedResult.slice(0, 40)}...`); + + // Decode result as string (text record) + const [textValue] = decodeAbiParameters([{ type: "string" }], resolvedResult); + const str = textValue as string; + console.log(`decoded text value: "${str}"`); + + // Assert: valid ISO timestamp OR empty string (Redis may not have the key yet) + const isIso = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(str); + const isEmpty = str === ""; + if (!isIso && !isEmpty) { + throw new Error(`Unexpected value: "${str}" — expected ISO timestamp or empty string`); + } + if (isIso) { + console.log(`last-seen-at is a valid ISO timestamp: "${str}"`); + } else { + console.log("last-seen-at is empty (Redis key not populated — acceptable for v1)"); + } + ok("step 4: resolveWithProof eth_call succeeded (value is valid ISO or empty)"); + } catch (e) { + fail("step 4: resolveWithProof eth_call", e); + // Set a dummy value so later steps can still reference the decoded struct + resolvedResult = decoded!.result; + } + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 5 – Tamper test: flip a byte in sig → expect InvalidSigner revert + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 5: Tamper test (flipped sig byte → InvalidSigner) ---"); + + try { + const sigBytes = hexToBytes(decoded!.signature.slice(2)); + sigBytes[4] ^= 0xff; // flip one byte + const tamperedSig = ("0x" + bytesToHex(sigBytes)) as Hex; + + const tamperedResponse = encodeAbiParameters( + [{ type: "uint64" }, { type: "bytes" }, { type: "bytes" }], + [decoded!.expires, decoded!.result, tamperedSig], + ); + + let reverted = false; + let revertMsg = ""; + try { + await publicClient.readContract({ + address: RESOLVER_ADDR, + abi: RESOLVER_ABI, + functionName: "resolveWithProof", + args: [tamperedResponse, outerCallData], + }); + } catch (contractErr) { + revertMsg = contractErr instanceof Error ? contractErr.message : String(contractErr); + if ( + revertMsg.includes("InvalidSigner") || + revertMsg.includes("0x6d5769be") // InvalidSigner selector + ) { + reverted = true; + } else { + // Any revert is acceptable — a tampered sig MUST not succeed + reverted = true; + console.log(` reverted with: ${revertMsg.slice(0, 120)}`); + } + } + + if (!reverted) throw new Error("Expected revert with tampered sig — call succeeded instead"); + ok("step 5: tamper test — tampered sig correctly reverts"); + } catch (e) { + fail("step 5: tamper test", e); + } + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 6 – Expiry test: past expires → expect ExpiredResponse revert + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 6: Expiry test (past expires → ExpiredResponse) ---"); + + try { + const pastExpires = BigInt(Math.floor(Date.now() / 1000) - 3600); // 1 hour ago + const expiredResponse = encodeAbiParameters( + [{ type: "uint64" }, { type: "bytes" }, { type: "bytes" }], + [pastExpires, decoded!.result, decoded!.signature], + ); + + let reverted = false; + let revertMsg = ""; + try { + await publicClient.readContract({ + address: RESOLVER_ADDR, + abi: RESOLVER_ABI, + functionName: "resolveWithProof", + args: [expiredResponse, outerCallData], + }); + } catch (contractErr) { + revertMsg = contractErr instanceof Error ? contractErr.message : String(contractErr); + if ( + revertMsg.includes("ExpiredResponse") || + revertMsg.includes("0x1a9c7c96") // ExpiredResponse selector + ) { + reverted = true; + console.log(" contract reverted with ExpiredResponse"); + } else { + // Any revert (including InvalidSigner because sig binds to real expires) is acceptable + reverted = true; + console.log(` reverted with: ${revertMsg.slice(0, 120)}`); + } + } + + if (!reverted) throw new Error("Expected revert for expired response — call succeeded instead"); + ok("step 6: expiry test — expired response correctly reverts"); + } catch (e) { + fail("step 6: expiry test", e); + } + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 7 – Wildcard test: unregistered label (addr record) doesn't 500 + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 7: Wildcard test (agent-eoa.tradewise.agentlab.eth, addr selector) ---"); + + try { + const wildcardName = "agent-eoa.tradewise.agentlab.eth"; + const wildcardNode = namehash(wildcardName); + const wildcardDns = encodeDnsWire(wildcardName); + + const addrCalldata = buildAddrCalldata(wildcardNode); + const wildcardOuterCallData = buildOffchainLookupCallData(wildcardDns, addrCalldata); + + const body = await postToGateway(RESOLVER_ADDR, wildcardOuterCallData); + if (!body.data || !body.data.startsWith("0x")) { + throw new Error(`Unexpected gateway body: ${JSON.stringify(body)}`); + } + + const wildcardDecoded = decodeGatewayResponse(body.data); + console.log(`wildcard result: ${wildcardDecoded.result.slice(0, 40)}...`); + + // result should be ABI-encoded bytes (could be empty for unregistered label) + const [addrBytes] = decodeAbiParameters([{ type: "bytes" }], wildcardDecoded.result); + console.log(` decoded addr bytes length: ${(addrBytes as Hex).length}`); + + ok("step 7: wildcard infrastructure works — gateway returns signed response for unregistered label"); + } catch (e) { + fail("step 7: wildcard test", e); + } + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 8 – W1 cross-link: tradewise.agentlab.eth, key: inft-tradeable + // ══════════════════════════════════════════════════════════════════════════ + console.log("\n--- Step 8: W1 cross-link test (tradewise.agentlab.eth, inft-tradeable) ---"); + + try { + const inftCalldata = buildTextCalldata(tradewiseNode, "inft-tradeable"); + const inftOuterCallData = buildOffchainLookupCallData(tradewiseDns, inftCalldata); + + const body = await postToGateway(RESOLVER_ADDR, inftOuterCallData); + if (!body.data || !body.data.startsWith("0x")) { + throw new Error(`Unexpected gateway body: ${JSON.stringify(body)}`); + } + + const inftDecoded = decodeGatewayResponse(body.data); + + // Verify signature + const inftSigner = recoverGatewaySigner({ + resolverAddress: RESOLVER_ADDR, + expires: inftDecoded.expires, + extraData: inftOuterCallData, + result: inftDecoded.result, + signature: inftDecoded.signature, + }); + if (inftSigner.toLowerCase() !== expectedSigner.toLowerCase()) { + throw new Error(`Signer mismatch for inft-tradeable: ${inftSigner}`); + } + + const [inftValue] = decodeAbiParameters([{ type: "string" }], inftDecoded.result); + const str = inftValue as string; + console.log(`inft-tradeable value: "${str}"`); + + // "1" means memoryReencrypted=true (W1 INFT minted with proof path) + // "0" is acceptable if AgentINFT contract state hasn't been set yet + if (str !== "1" && str !== "0" && str !== "") { + throw new Error(`Unexpected inft-tradeable value: "${str}"`); + } + if (str === "1") { + console.log(" inft-tradeable = 1 (memoryReencrypted confirmed on-chain)"); + } else { + console.log(` inft-tradeable = "${str}" (0 or empty — acceptable if INFT not yet transferred via proof)`); + } + ok("step 8: W1 cross-link — inft-tradeable resolved correctly (signed response, valid value)"); + } catch (e) { + fail("step 8: W1 cross-link test (inft-tradeable)", e); + } + + // ═══════════════════════════════════════════════════════════════════════════ + printSummary(); +} + +function printSummary() { + console.log("\n=== Test Summary ==="); + if (failed === 0) { + console.log("ALL GREEN"); + process.exit(0); + } else { + console.log(`FAIL: ${failed} step(s) failed, ${passed} passed`); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Unhandled error:", err); + process.exit(1); +}); From 7c97ea142f9dc86a38f57067df122d5cd4b9f81f Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Thu, 30 Apr 2026 10:38:42 +0200 Subject: [PATCH 16/16] docs(w2): manual UI walkthrough checklist (7 sections) --- .../2026-04-30-w2-manual-walkthrough.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/walkthroughs/2026-04-30-w2-manual-walkthrough.md diff --git a/docs/walkthroughs/2026-04-30-w2-manual-walkthrough.md b/docs/walkthroughs/2026-04-30-w2-manual-walkthrough.md new file mode 100644 index 0000000..97c36f9 --- /dev/null +++ b/docs/walkthroughs/2026-04-30-w2-manual-walkthrough.md @@ -0,0 +1,168 @@ +# W2 manual UI walkthrough — checklist + +**Purpose:** verify the W2 CCIP-Read ENS gateway end-to-end after `feat/w2-ens-gateway` deploys to a Vercel preview (or production after merge). + +**What this verifies:** every record served by the gateway, every cross-link with W1, the `/ens-debug` page, and that external clients (etherscan, MetaMask, wagmi/viem) all resolve `*.agentlab.eth` records through the offchain pipeline without intervention. + +**Reference deployments (Sepolia):** + +| Component | Address | +|---|---| +| OffchainResolver | [`0x4F95…8C17`](https://sepolia.etherscan.io/address/0x4F956e6521A4B87b9f9b2D5ED191fB6134Bc8C17) | +| Gateway signer (off-chain) | `0xe358F777daF973E64d0F9b2e73bc34e4C7F65c9b` | +| Gateway base URL | `https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json` | + +ENS resolver records (all flipped to OffchainResolver in M5): +- `agentlab.eth` (parent) +- `tradewise.agentlab.eth` +- `pricewatch.agentlab.eth` + +--- + +## Pre-flight + +- [ ] **Vercel preview / production deploy is live.** Hit `https://hackagent-nine.vercel.app/api/ens-gateway/0x4F956e6521A4B87b9f9b2D5ED191fB6134Bc8C17/0x.json` — should return 400 (invalid data hex), NOT 404. A 404 means W2 isn't deployed yet. +- [ ] **`INFT_GATEWAY_PK` is set in Vercel Production env.** Verify via `vercel env ls | grep GATEWAY`. +- [ ] **MetaMask / any wagmi-based wallet** ready (any wallet that supports CCIP-Read — virtually all modern wallets). + +--- + +## §A — `/ens-debug` page roundtrip (the demo gold) + +This is the section judges will love. + +- [ ] Visit `/ens-debug` on the live deploy. +- [ ] Form prefills `name = tradewise.agentlab.eth`, `key = last-seen-at`. Click **resolve**. +- [ ] Within ≤ 2s, the JSON box renders something like: + + ```json + { + "name": "tradewise.agentlab.eth", + "key": "last-seen-at", + "value": "2026-04-29T13:20:00.000Z", + "latencyMs": 1430 + } + ``` + + - `value` is a recent ISO timestamp (last paid x402 quote populated it via the KeeperHub heartbeat-pulse from PR #13). + - `latencyMs` is the round-trip time end-to-end through CCIP-Read: revert → gateway POST → ABI decode → ecrecover. + +- [ ] Change `key` to `memory-rotations`. Resolve. Should return `"0"` initially, `"N"` after N successful `transferWithProof` calls. +- [ ] Change `key` to `inft-tradeable`. Resolve. Returns `"1"` (since `AgentINFT.memoryReencrypted(1) == true` post-mint). +- [ ] Change `key` to `outstanding-bids`. Resolve. Returns the current bidder count from `AgentBids.biddersCount(1)`. +- [ ] Change `key` to `reputation-summary`. Resolve. Returns `feedback=N` where N matches `ReputationRegistry.feedbackCount(1)` on-chain. +- [ ] Change `name` to `pricewatch.agentlab.eth`, `key = last-seen-at`. Resolve. Returns its own value (or empty string if pricewatch hasn't fired recently). + +--- + +## §B — Wildcard via ENSIP-10 + +Validates the `*.agentlab.eth` wildcard story. + +- [ ] In `/ens-debug`, enter `name = agent-eoa.tradewise.agentlab.eth`, `key = addr`. Click resolve. +- [ ] Returns a `value` of `"0x0000…0000"` (zero address — labelToAgent doesn't have this nested entry yet; W3 extends it). The KEY POINT: it does NOT 404 or error — the gateway *handled* a name that was never registered as a subname. That's ENSIP-10 wildcard working. +- [ ] Sepolia etherscan also handles wildcard: visit https://sepolia.etherscan.io/enslookup-search?search=agent-eoa.tradewise.agentlab.eth. The "Resolver" row shows `0x4F95…8C17` (our OffchainResolver, inherited via wildcard from the parent `agentlab.eth`). + +--- + +## §C — External clients (the wagmi/viem path) + +Validates that the gateway is invisible to standard tooling. If this works, every dApp on the planet that uses ENS resolves your records without integration. + +- [ ] In a Node REPL or browser console with viem: + + ```ts + import { createPublicClient, http } from "viem"; + import { sepolia } from "viem/chains"; + + const c = createPublicClient({ + chain: sepolia, + transport: http("https://ethereum-sepolia-rpc.publicnode.com"), + ccipRead: true, + }); + + const v = await c.getEnsText({ + name: "tradewise.agentlab.eth", + key: "last-seen-at", + }); + console.log(v); + ``` + + Returns the same ISO string as `/ens-debug`. No special config — viem followed the OffchainLookup transparently. + +- [ ] **Etherscan**: visit https://sepolia.etherscan.io/enslookup-search?search=tradewise.agentlab.eth. The text records section should show our live values (Etherscan does CCIP-Read on the read tab). + +- [ ] **MetaMask**: paste `tradewise.agentlab.eth` in the "Send" recipient field. MetaMask should resolve it to the agent's address (`0x7a83…20A3`) — that's the gateway's `addr(name)` response. + +--- + +## §D — `/inft` page cross-link + +Validates W1 ↔ W2 integration. The /inft page reads INFT memory state TWICE — once via direct chain read, once via the W2 ENS gateway. + +- [ ] Visit `/inft`. Existing INFT card renders (W1 surface). +- [ ] Below the bid table, find the `§02 live telemetry / via ENS gateway` section. +- [ ] Confirm: + - `rotations` cell shows the same value as the on-chain card above (Redis-backed, `inft:meta:1:rotations`). + - `inft-tradeable` cell shows `"1"` (matches `memoryReencrypted` from on-chain). + - `last-seen-at` cell shows a recent timestamp. + - `reputation-summary` cell shows `feedback=N`. + - `outstanding-bids` cell shows the current bid count. + +- [ ] Trigger a `transferWithProof` (e.g. via the manual W1 walkthrough §C). After it lands, refresh `/inft`. Both the on-chain `rotations` and the via-ENS `rotations` cell should increment to 1. **Same value, two different reads** — proves the cross-link is live. + +--- + +## §E — Gas verification + +Validates that ENS reads are zero-gas. + +- [ ] Note `PRICEWATCH_PK` Sepolia ETH balance before browsing. + + ```bash + cast balance 0xBf5df5c89b1eCa32C1E8AC7ECdd93d44F86F2469 \ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com -e + ``` + +- [ ] Reload `/inft` 10 times, click around, browse to `/ens-debug` and resolve different keys. +- [ ] Re-check balance — **should be unchanged** (modulo any deliberate user actions). The W2 gateway never sends txs from PRICEWATCH_PK; all writes happen via KeeperHub webhook pulses (PR #13) which write Redis only. + +- [ ] Compare to **pre-W2 behavior**: prior to PR #13, every paid quote burned ~14k gas. Now: zero per quote. W2 doesn't add any new on-chain writes either. + +--- + +## §F — Tamper resistance (proof verification works) + +Validates that the trusted-gateway story is actually trusted ON-CHAIN, not just at the API. + +- [ ] In a Node REPL, hand-construct a `resolveWithProof(response, extraData)` `eth_call` against the OffchainResolver. +- [ ] Tamper one byte of the signature in `response`. The call should revert with custom error `InvalidSigner`. +- [ ] Restore the signature, change the `expires` field to a past timestamp. Call should revert with `ExpiredResponse`. +- [ ] (Optional) Pretend to be the gateway: sign the message with a different key, submit. Reverts `InvalidSigner` (only the configured `expectedGatewaySigner` is trusted). + +These are the same checks `scripts/test-ens-gateway-e2e.ts` makes — they're worth knowing exist for the demo Q&A. + +--- + +## §G — Manual cleanup + +- [ ] Move `/tmp/inft-gateway-key.txt` to your password manager. The gateway PK is a real signing key — same handling as the INFT oracle PK. + +--- + +## Sign-off + +| Section | Result | Notes | +|---|---|---| +| §A `/ens-debug` roundtrip | ☐ pass / ☐ fail | | +| §B wildcard via ENSIP-10 | ☐ pass / ☐ fail | | +| §C external clients | ☐ pass / ☐ fail | | +| §D `/inft` cross-link | ☐ pass / ☐ fail | | +| §E gas verification | ☐ pass / ☐ fail | | +| §F tamper resistance | ☐ pass / ☐ fail | | + +**Verified by:** `____________________` +**Date:** `____________________` +**Vercel deployment URL:** `____________________` + +If any section fails, comment on PR #16 with the failed step + Vercel function logs (oracle/gateway routes log `[ens-gateway]` prefix).