From 514c9eb5167cbba819e4536ae132e9a31b70a787 Mon Sep 17 00:00:00 2001 From: fritzschoff Date: Wed, 29 Apr 2026 15:12:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(keeperhub):=20swap=20on-chain=20setText=20f?= =?UTF-8?q?or=20webhook-only=20pulses=20(=CE=B1=20flavor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-quote KeeperHub workflow runs were doing real Sepolia setText txs signed by PRICEWATCH_PK — ~14k gas burned per quote just to write last-seen-at and reputation-summary text records. Worse, this was the KeeperHub demo surface for the hackathon: the workflows ARE the sponsor story, deleting them is wrong. Fix: keep the workflows firing on every paid x402 quote (sponsor story intact) but replace their Web3 Write nodes with Webhook POST nodes that hit our app instead. /api/keeperhub/heartbeat-pulse - validates KEEPERHUB_WEBHOOK_SECRET bearer - writes timestamp to Redis: agent:1:last-seen + ens:dynamic:1:last-seen-at (24h TTL) - calls pushKeeperhubRun so the run is visible on /keeperhub - returns 200 with the updated lastSeenAt ISO /api/keeperhub/reputation-pulse - same auth shape - reads ReputationRegistry.feedbackCount (cheap on-chain view, no tx) - writes summary to Redis: reputation:summary:1 + ens:dynamic:1:reputation-summary (24h TTL) - calls pushKeeperhubRun, returns 200 lib/ens.ts:resolveAgentEns now prefers the Redis-backed ens:dynamic:* keys for last-seen-at + reputation-summary, falling back to the on-chain text record only if Redis is empty. Dashboard heartbeat pill stays live; on-chain text records freeze and become irrelevant. Both KeeperHub workflows already updated via update_workflow MCP call (see /tmp/swap-workflows-to-webhooks.ts). W2's CCIP-Read gateway will expose these Redis values as proper ENS text records readable from any client. This is W2-α reduced to a single record pair — the W2 plan still ships the full version. Net result: - KeeperHub fires per quote ✓ (sponsor story) - Workflow runs visible on /keeperhub ✓ - Dashboard heartbeat live ✓ - Zero gas burn ✓ Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/keeperhub/heartbeat-pulse/route.ts | 61 +++++++++++++++ app/api/keeperhub/reputation-pulse/route.ts | 87 +++++++++++++++++++++ lib/ens-constants.ts | 3 + lib/ens.ts | 23 ++++-- 4 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 app/api/keeperhub/heartbeat-pulse/route.ts create mode 100644 app/api/keeperhub/reputation-pulse/route.ts diff --git a/app/api/keeperhub/heartbeat-pulse/route.ts b/app/api/keeperhub/heartbeat-pulse/route.ts new file mode 100644 index 0000000..a4ea866 --- /dev/null +++ b/app/api/keeperhub/heartbeat-pulse/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getRedis, pushKeeperhubRun } from "@/lib/redis"; +import { AGENT_ID_DEFAULT } from "@/lib/ens-constants"; + +/// KeeperHub heartbeat workflow webhook sink. +/// +/// The workflow that previously did `setText("last-seen-at", ts)` on Sepolia +/// now does this webhook instead — same trigger (per paid x402 quote), same +/// KeeperHub run visibility, but zero gas. The dashboard reads `last-seen-at` +/// from Redis (with stale on-chain text record as fallback). +/// +/// W2's CCIP-Read gateway will expose this Redis value as a real ENS text +/// record from any client; until then, the dashboard reads it directly. + +const Body = z.object({ + ts: z.union([z.number(), z.string()]), + workflowRunId: z.string().optional(), +}); + +function checkSecret(req: NextRequest): boolean { + const auth = req.headers.get("authorization") ?? ""; + const expected = `Bearer ${process.env.KEEPERHUB_WEBHOOK_SECRET ?? process.env.INFT_ORACLE_API_KEY ?? ""}`; + return expected !== "Bearer " && auth === expected; +} + +export async function POST(req: NextRequest) { + if (!checkSecret(req)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const parsed = Body.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + } + + const tsMs = + typeof parsed.data.ts === "number" + ? parsed.data.ts + : Number.parseInt(parsed.data.ts, 10); + if (!Number.isFinite(tsMs)) { + return NextResponse.json({ error: "invalid ts" }, { status: 400 }); + } + const iso = new Date(tsMs).toISOString(); + + const r = getRedis(); + if (r) { + await r.set(`agent:${AGENT_ID_DEFAULT}:last-seen`, iso); + await r.set(`ens:dynamic:${AGENT_ID_DEFAULT}:last-seen-at`, iso, "EX", 86400); + } + + await pushKeeperhubRun({ + kind: "heartbeat", + jobId: `pulse-${tsMs}`, + workflowRunId: parsed.data.workflowRunId ?? `pulse-${tsMs}`, + txHash: null, + summary: "heartbeat pulse — Redis updated, no on-chain write", + ts: tsMs, + }); + + return NextResponse.json({ ok: true, lastSeenAt: iso }); +} diff --git a/app/api/keeperhub/reputation-pulse/route.ts b/app/api/keeperhub/reputation-pulse/route.ts new file mode 100644 index 0000000..c7681d5 --- /dev/null +++ b/app/api/keeperhub/reputation-pulse/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getRedis, pushKeeperhubRun } from "@/lib/redis"; +import { AGENT_ID_DEFAULT, SEPOLIA_REPUTATION_REGISTRY } from "@/lib/ens-constants"; +import { sepoliaPublicClient } from "@/lib/wallets"; + +const REPUTATION_ABI = [ + { + type: "function", + name: "feedbackCount", + stateMutability: "view", + inputs: [{ name: "agentId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +/// KeeperHub reputation-cache workflow webhook sink. +/// +/// The workflow that previously did `setText("reputation-summary", ...)` on +/// Sepolia now hits this webhook instead. We compute the feedback count +/// on-chain (cheap read, no tx), build the same summary string, write to +/// Redis, and surface a KeeperHub run on /keeperhub. + +const Body = z.object({ + ts: z.union([z.number(), z.string()]).optional(), + workflowRunId: z.string().optional(), + agentId: z.union([z.number(), z.string()]).optional(), +}); + +function checkSecret(req: NextRequest): boolean { + const auth = req.headers.get("authorization") ?? ""; + const expected = `Bearer ${process.env.KEEPERHUB_WEBHOOK_SECRET ?? process.env.INFT_ORACLE_API_KEY ?? ""}`; + return expected !== "Bearer " && auth === expected; +} + +export async function POST(req: NextRequest) { + if (!checkSecret(req)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const parsed = Body.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.message }, { status: 400 }); + } + const agentId = parsed.data.agentId ? Number(parsed.data.agentId) : AGENT_ID_DEFAULT; + if (!Number.isFinite(agentId) || agentId <= 0) { + return NextResponse.json({ error: "invalid agentId" }, { status: 400 }); + } + const tsMs = + parsed.data.ts !== undefined + ? typeof parsed.data.ts === "number" + ? parsed.data.ts + : Number.parseInt(parsed.data.ts, 10) + : Date.now(); + + let summary = "feedback=0"; + try { + const count = (await sepoliaPublicClient().readContract({ + address: SEPOLIA_REPUTATION_REGISTRY, + abi: REPUTATION_ABI, + functionName: "feedbackCount", + args: [BigInt(agentId)], + })) as bigint; + summary = `feedback=${count.toString()}`; + } catch (err) { + console.error( + "[keeperhub/reputation-pulse] feedbackCount read failed:", + err instanceof Error ? err.message : err, + ); + } + + const r = getRedis(); + if (r) { + await r.set(`reputation:summary:${agentId}`, summary, "EX", 600); + await r.set(`ens:dynamic:${agentId}:reputation-summary`, summary, "EX", 86400); + } + + await pushKeeperhubRun({ + kind: "reputation-cache", + jobId: `rep-pulse-${tsMs}`, + workflowRunId: parsed.data.workflowRunId ?? `rep-pulse-${tsMs}`, + txHash: null, + summary, + ts: tsMs, + }); + + return NextResponse.json({ ok: true, summary }); +} diff --git a/lib/ens-constants.ts b/lib/ens-constants.ts index 2570aa5..6861dfe 100644 --- a/lib/ens-constants.ts +++ b/lib/ens-constants.ts @@ -13,6 +13,9 @@ export const AGENT_ENS = `${AGENT_SUBNAME}.${PARENT_ENS}`; export const SEPOLIA_IDENTITY_REGISTRY: Address = "0x6aF06f682A7Ba7Db32587FDedF51B9190EF738fA"; +export const SEPOLIA_REPUTATION_REGISTRY: Address = + "0x477D6FeFCE87B627a7B2215ee62a4E21fc102BbA"; + export const AGENT_ID_DEFAULT = 1; export function ensip25Key(args: { diff --git a/lib/ens.ts b/lib/ens.ts index 9425ca0..8810ee6 100644 --- a/lib/ens.ts +++ b/lib/ens.ts @@ -81,22 +81,33 @@ export async function resolveAgentEns(opts?: { } } + // Dynamic fields (last-seen-at, reputation-summary) live in Redis now — + // KeeperHub heartbeat + reputation-cache workflows pulse those keys via + // /api/keeperhub/{heartbeat,reputation}-pulse instead of writing to chain. + // On-chain text records are used only as a fallback for cold reads. + const dynamicLastSeen = redis + ? await redis.get(`ens:dynamic:${AGENT_ID_DEFAULT}:last-seen-at`).catch(() => null) + : null; + const dynamicRepSummary = redis + ? await redis.get(`ens:dynamic:${AGENT_ID_DEFAULT}:reputation-summary`).catch(() => null) + : null; + const [ address, agentCardUrl, registration, description, url, - lastSeen, - reputationSummary, + onChainLastSeen, + onChainRepSummary, ] = await Promise.all([ readEnsAddressSafe(AGENT_ENS), readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.agentCard), readEnsTextSafe(AGENT_ENS, REGISTRATION_KEY), readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.description), readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.url), - readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.lastSeenAt), - readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.reputationSummary), + dynamicLastSeen ? Promise.resolve(null) : readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.lastSeenAt), + dynamicRepSummary ? Promise.resolve(null) : readEnsTextSafe(AGENT_ENS, ENS_TEXT_KEYS.reputationSummary), ]); const resolved: ResolvedEns = { @@ -106,8 +117,8 @@ export async function resolveAgentEns(opts?: { registrationRecord: registration, description, url, - lastSeenAt: lastSeen, - reputationSummary, + lastSeenAt: dynamicLastSeen ?? onChainLastSeen, + reputationSummary: dynamicRepSummary ?? onChainRepSummary, ensip25Key: REGISTRATION_KEY, };