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, };