Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f332bfa
docs: W2 implementation plan (CCIP-Read ENS gateway)
fritzschoff Apr 29, 2026
7c8e932
test(w2): failing OffchainResolver.resolve reverts test
fritzschoff Apr 29, 2026
716792c
feat(w2): OffchainResolver — EIP-3668 wildcard resolver with ecrecover
fritzschoff Apr 29, 2026
d8d3614
test(w2): OffchainResolver — happy path + expiry + bad sig + extraDat…
fritzschoff Apr 29, 2026
39784bf
feat(w2): deploy script for OffchainResolver
fritzschoff Apr 29, 2026
bc87e39
feat(w2): ens-gateway lib (record computation, EIP-191 signing, ABI e…
fritzschoff Apr 29, 2026
895de47
test(w2): ens-gateway lib smoke (sig + response encode)
fritzschoff Apr 29, 2026
043087d
fix(w2): inft-tradeable uses AgentINFT.memoryReencrypted (W1 ABI now …
fritzschoff Apr 29, 2026
0aa7b87
feat(w2): /api/ens-gateway/[sender]/[data] route — decode + compute +…
fritzschoff Apr 29, 2026
73d3b94
feat(w2): /api/ens-gateway/cache/invalidate route (KeeperHub trigger …
fritzschoff Apr 29, 2026
c4d6cd2
feat(w2): lib/ens-records — typed wagmi/viem ENS helper
fritzschoff Apr 29, 2026
a671e36
feat(w2): /inft cross-link — read inft-tradeable + memory-rotations v…
fritzschoff Apr 29, 2026
af79e3c
feat(w2): /ens-debug page + /api/ens-debug route (CCIP-Read demo surf…
fritzschoff Apr 29, 2026
a4ed5ad
feat(w2): M5 deploy — OffchainResolver on Sepolia + flip agentlab.eth…
fritzschoff Apr 30, 2026
769e6d4
test(w2): e2e against deployed resolver + local dev gateway
fritzschoff Apr 30, 2026
7c97ea1
docs(w2): manual UI walkthrough checklist (7 sections)
fritzschoff Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/api/ens-debug/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
90 changes: 90 additions & 0 deletions app/api/ens-gateway/[sender]/[data]/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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;
39 changes: 39 additions & 0 deletions app/api/ens-gateway/cache/invalidate/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
// 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 });
}
64 changes: 64 additions & 0 deletions app/ens-debug/page.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>(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 (
<main className="mx-auto max-w-4xl px-6 md:px-10 pb-24">
<SiteNav active="docs" />
<header className="pt-6 pb-10 border-b-2 border-(--color-fg)">
<p className="tag mb-2">debug · ens ccip-read</p>
<h1 className="display text-3xl">/ens-debug</h1>
<p className="mt-3 text-sm text-(--color-muted)">
Resolves an ENS text record through the W2 offchain gateway and shows
the full OffchainLookup roundtrip — revert, gateway URL, signed
response, ecrecovered signer.
</p>
</header>
<section className="mt-10 card-flat space-y-4">
<div className="grid grid-cols-[1fr_1fr_auto] gap-3">
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="border px-3 py-2 text-sm bg-transparent"
placeholder="name"
/>
<input
value={key}
onChange={(e) => setKey(e.target.value)}
className="border px-3 py-2 text-sm bg-transparent"
placeholder="text key"
/>
<button onClick={go} disabled={loading} className="link link-amber">
{loading ? "resolving..." : "resolve"}
</button>
</div>
{result ? (
<pre className="text-[11px] bg-(--color-bg-soft) p-4 rounded overflow-x-auto">
{JSON.stringify(result, null, 2)}
</pre>
) : null}
</section>
</main>
);
}
48 changes: 40 additions & 8 deletions app/inft/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<main className="mx-auto max-w-5xl px-6 md:px-10 pb-24">
Expand Down Expand Up @@ -162,10 +169,35 @@ export default async function InftPage() {
</div>
</div>

{/* ── Bidding ── */}
{/* ── ENS gateway telemetry ── */}
<div>
<div className="flex items-baseline gap-5 mb-5">
<span className="section-marker">§02</span>
<div>
<h2 className="display text-2xl">live telemetry</h2>
<p className="tag mt-1">via ENS gateway · W2 CCIP-Read</p>
</div>
</div>
<div className="card-flat">
<dl className="grid grid-cols-1 gap-3 text-xs font-mono">
<Row label="rotations" value={ensTelemetry.rotations ?? "—"} />
<Row label="inft-tradeable" value={ensTelemetry.inftTradeable ?? "—"} />
<Row label="last-seen-at" value={ensTelemetry.lastSeenAt ?? "—"} />
<Row label="reputation-summary" value={ensTelemetry.reputationSummary ?? "—"} />
<Row label="outstanding-bids" value={ensTelemetry.outstandingBids ?? "—"} />
</dl>
<p className="mt-3 text-[11px] text-(--color-muted)">
Records resolved from <code>tradewise.agentlab.eth</code> via the W2 offchain
resolver (EIP-3668). Values show &quot;—&quot; until the OffchainResolver is deployed
and <code>agentlab.eth</code>&apos;s resolver slot is flipped (M5).
</p>
</div>
</div>

{/* ── Bidding ── */}
<div>
<div className="flex items-baseline gap-5 mb-5">
<span className="section-marker">§03</span>
<div>
<h2 className="display text-2xl">bidding</h2>
<p className="tag mt-1">
Expand Down Expand Up @@ -238,7 +270,7 @@ export default async function InftPage() {
{bidHistory.length > 0 ? (
<div>
<div className="flex items-baseline gap-5 mb-5">
<span className="section-marker">§03</span>
<span className="section-marker">§04</span>
<div>
<h2 className="display text-2xl">bid history</h2>
<p className="tag mt-1">on-chain audit trail</p>
Expand Down
1 change: 1 addition & 0 deletions contracts/deployments/sepolia-ens-resolver.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"network":"sepolia","chainId":11155111,"offchainResolver":"0x4F956e6521A4B87b9f9b2D5ED191fB6134Bc8C17","signer":"0xe358F777daF973E64d0F9b2e73bc34e4C7F65c9b","gatewayUrl":"https://hackagent-nine.vercel.app/api/ens-gateway/{sender}/{data}.json"}
52 changes: 52 additions & 0 deletions contracts/script/DeployOffchainResolver.s.sol
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading