Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 61 additions & 0 deletions app/api/keeperhub/heartbeat-pulse/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
87 changes: 87 additions & 0 deletions app/api/keeperhub/reputation-pulse/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
3 changes: 3 additions & 0 deletions lib/ens-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
23 changes: 17 additions & 6 deletions lib/ens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
};

Expand Down