diff --git a/app/api/agents/[id]/badges/route.ts b/app/api/agents/[id]/badges/route.ts new file mode 100644 index 0000000..7ee6da2 --- /dev/null +++ b/app/api/agents/[id]/badges/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import { getAllBadges } from "@/lib/agents/badges" + +type RouteContext = { params: Promise<{ id: string }> } + +export async function GET(_request: Request, context: RouteContext) { + try { + const { id } = await context.params + const badges = getAllBadges(id) + + return NextResponse.json( + { + badges, + count: badges.length, + }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ) + } catch (error) { + return NextResponse.json( + { + badges: [], + count: 0, + error: error instanceof Error ? error.message : "Failed to load badges", + }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a76e0c..6a90d40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { PassportCard, type PassportState } from "./components/PassportCard"; +import { AgentBadgeGrid } from "./components/AgentBadgeGrid"; import { Badge, Button, Card, Mono, cx } from "./components/primitives"; import { ArrowRight, @@ -163,6 +164,7 @@ export default function App() { The proof is built in your browser. Owner key & balance never leave this page — only the proof and its four public inputs are sent on-chain.

+
diff --git a/frontend/src/components/AgentBadgeGrid.tsx b/frontend/src/components/AgentBadgeGrid.tsx new file mode 100644 index 0000000..567ebf5 --- /dev/null +++ b/frontend/src/components/AgentBadgeGrid.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { Coins, ShieldCheck, Stamp, Check, Key } from "./icons"; +import { Card, cx } from "./primitives"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +type BadgeType = "first_task" | "quest_master" | "level_10" | "veteran" | "top_earner"; + +interface AgentBadge { + agentId: string; + type: BadgeType; + title: string; + description: string; + icon: string; + awardedAt: string; +} + +const iconByType = { + first_task: Check, + quest_master: Stamp, + level_10: ShieldCheck, + veteran: Key, + top_earner: Coins, +} satisfies Record; + +const toneByType = { + first_task: "text-cyan bg-cyan/10 border-cyan/20", + quest_master: "text-violet-soft bg-violet/10 border-violet/20", + level_10: "text-verified bg-verified/10 border-verified/20", + veteran: "text-amber bg-amber/10 border-amber/20", + top_earner: "text-fg bg-black/[0.04] border-black/10", +} satisfies Record; + +export function AgentBadgeGrid({ agentId }: { agentId?: string }) { + const [badges, setBadges] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!agentId) { + setBadges([]); + return; + } + + let cancelled = false; + setLoading(true); + + fetch(`/api/agents/${encodeURIComponent(agentId)}/badges`, { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) return { badges: [] as AgentBadge[] }; + return response.json() as Promise<{ badges: AgentBadge[] }>; + }) + .then((payload) => { + if (!cancelled) setBadges(payload.badges ?? []); + }) + .catch(() => { + if (!cancelled) setBadges([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [agentId]); + + return ( + +
+
+

Agent detail

+

Badges

+
+ {agentId ? {badges.length} unlocked : null} +
+ + {!agentId ? ( +

+ Generate a passport to load the agent profile and any milestone badges. +

+ ) : ( + <> +
+ {loading + ? Array.from({ length: 5 }).map((_, index) => ( +
+ )) + : badges.map((badge) => { + const Icon = iconByType[badge.type]; + return ( + + + + + +
+

{badge.title}

+

{badge.description}

+
+
+
+ ); + })} +
+ + {!loading && badges.length === 0 ? ( +

No badges unlocked yet for this agent.

+ ) : null} + + )} + + ); +} diff --git a/lib/agents/badges.ts b/lib/agents/badges.ts new file mode 100644 index 0000000..c26a0cf --- /dev/null +++ b/lib/agents/badges.ts @@ -0,0 +1,302 @@ +import fs from "node:fs" +import path from "node:path" + +export type BadgeType = + | "first_task" + | "quest_master" + | "level_10" + | "veteran" + | "top_earner" + +export interface Badge { + agentId: string + type: BadgeType + title: string + description: string + icon: string + awardedAt: string +} + +interface AgentStats { + registeredAt: string | null + completedTasks: number + completedQuests: number + level: number + xp: number | null +} + +const DATA_DIR = path.join(process.cwd(), ".data") +const BADGES_DIR = path.join(DATA_DIR, "badges") + +const BADGE_DEFINITIONS: Record> = { + first_task: { + type: "first_task", + title: "First Task", + description: "Awarded after completing the first task.", + icon: "check", + }, + quest_master: { + type: "quest_master", + title: "Quest Master", + description: "Awarded after completing 5 quests.", + icon: "stamp", + }, + level_10: { + type: "level_10", + title: "Level 10", + description: "Awarded on reaching level 10.", + icon: "shield", + }, + veteran: { + type: "veteran", + title: "Veteran", + description: "Awarded 30 days after registration.", + icon: "clock", + }, + top_earner: { + type: "top_earner", + title: "Top Earner", + description: "Awarded for reaching the top 10% on the XP leaderboard.", + icon: "coins", + }, +} + +function badgeFilePath(agentId: string): string { + return path.join(BADGES_DIR, `${normalizeAgentId(agentId)}.json`) +} + +function normalizeAgentId(agentId: string): string { + const trimmed = agentId.trim() + if (!trimmed) throw new Error("agentId must not be empty") + return trimmed.slice(0, 200) +} + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }) +} + +function readJsonFile(filePath: string): T | null { + if (!fs.existsSync(filePath)) return null + return JSON.parse(fs.readFileSync(filePath, "utf8")) as T +} + +function readJsonCollection(dirPath: string): unknown[] { + if (!fs.existsSync(dirPath)) return [] + const entries = fs.readdirSync(dirPath) + return entries + .filter((entry) => entry.endsWith(".json")) + .map((entry) => readJsonFile(path.join(dirPath, entry))) + .filter((entry): entry is unknown => entry !== null) +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null +} + +function readStringField(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = record[key] + if (typeof value === "string" && value.trim()) return value + } + return null +} + +function readNumberField(record: Record, keys: string[]): number | null { + for (const key of keys) { + const value = record[key] + if (typeof value === "number" && Number.isFinite(value)) return value + } + return null +} + +function readAgentRecord(agentId: string): Record | null { + const direct = readJsonFile>(path.join(DATA_DIR, "agents", `${agentId}.json`)) + if (direct) return direct + + const aggregate = readJsonFile(path.join(DATA_DIR, "agents.json")) + if (Array.isArray(aggregate)) { + return aggregate + .map(asRecord) + .find((entry) => entry && readStringField(entry, ["id", "agentId"]) === agentId) ?? null + } + + const aggregateRecord = asRecord(aggregate) + if (aggregateRecord) { + const nested = asRecord(aggregateRecord[agentId]) + if (nested) return nested + const agents = aggregateRecord.agents + if (Array.isArray(agents)) { + return agents + .map(asRecord) + .find((entry) => entry && readStringField(entry, ["id", "agentId"]) === agentId) ?? null + } + } + + return null +} + +function getCompletedTasksFromFiles(agentId: string): number { + const tasks = readJsonCollection(path.join(DATA_DIR, "tasks")) + let count = 0 + for (const task of tasks) { + const record = asRecord(task) + if (!record) continue + const owner = readStringField(record, ["agentId", "assigneeId", "completedBy"]) + const status = readStringField(record, ["status"]) + const completed = record.completed + if (owner === agentId && (status === "completed" || completed === true)) count += 1 + } + return count +} + +function getCompletedQuestsFromFiles(agentId: string): number { + const quests = readJsonCollection(path.join(DATA_DIR, "quests")) + let count = 0 + for (const quest of quests) { + const record = asRecord(quest) + if (!record) continue + + const owner = readStringField(record, ["agentId", "completedBy"]) + const status = readStringField(record, ["status"]) + if (owner === agentId && status === "completed") { + count += 1 + continue + } + + const completedBy = record.completedBy + if (Array.isArray(completedBy) && completedBy.includes(agentId)) { + count += 1 + } + } + return count +} + +function getAgentStats(agentId: string): AgentStats { + const record = readAgentRecord(agentId) ?? {} + const registeredAt = readStringField(record, ["registeredAt", "createdAt", "joinedAt"]) + const completedTasks = readNumberField(record, ["completedTasks", "tasksCompleted"]) ?? getCompletedTasksFromFiles(agentId) + const completedQuests = + readNumberField(record, ["completedQuests", "questsCompleted"]) ?? getCompletedQuestsFromFiles(agentId) + const level = readNumberField(record, ["level"]) ?? 0 + const xp = readNumberField(record, ["xp", "totalXp"]) + + return { registeredAt, completedTasks, completedQuests, level, xp } +} + +function readLeaderboardEntries(): Array<{ agentId: string; xp: number }> { + const filesToCheck = [ + path.join(DATA_DIR, "xp", "leaderboard.json"), + path.join(DATA_DIR, "leaderboard.json"), + path.join(DATA_DIR, "xp", "agents.json"), + ] + + for (const filePath of filesToCheck) { + const json = readJsonFile(filePath) + const entries = extractLeaderboardEntries(json) + if (entries.length > 0) return entries + } + + return [] +} + +function extractLeaderboardEntries(input: unknown): Array<{ agentId: string; xp: number }> { + if (Array.isArray(input)) { + return input + .map(asRecord) + .flatMap((record) => { + if (!record) return [] + const agentId = readStringField(record, ["agentId", "id"]) + const xp = readNumberField(record, ["xp", "totalXp"]) + return agentId && xp !== null ? [{ agentId, xp }] : [] + }) + } + + const record = asRecord(input) + if (!record) return [] + + if (Array.isArray(record.leaderboard)) return extractLeaderboardEntries(record.leaderboard) + if (Array.isArray(record.agents)) return extractLeaderboardEntries(record.agents) + + return Object.entries(record).flatMap(([key, value]) => { + const nested = asRecord(value) + if (!nested) return [] + const agentId = readStringField(nested, ["agentId", "id"]) ?? key + const xp = readNumberField(nested, ["xp", "totalXp"]) + return xp !== null ? [{ agentId, xp }] : [] + }) +} + +function isTopEarner(agentId: string, stats: AgentStats): boolean { + const leaderboard = readLeaderboardEntries() + if (leaderboard.length === 0) return false + + const sorted = [...leaderboard].sort((a, b) => b.xp - a.xp) + const topCount = Math.max(1, Math.ceil(sorted.length * 0.1)) + return sorted.slice(0, topCount).some((entry) => entry.agentId === agentId) + || (stats.xp !== null && sorted.slice(0, topCount).some((entry) => entry.agentId === agentId && entry.xp === stats.xp)) +} + +function shouldAward(type: BadgeType, agentId: string, stats: AgentStats): boolean { + switch (type) { + case "first_task": + return stats.completedTasks >= 1 + case "quest_master": + return stats.completedQuests >= 5 + case "level_10": + return stats.level >= 10 + case "veteran": + return stats.registeredAt !== null + && Date.now() - new Date(stats.registeredAt).getTime() >= 30 * 24 * 60 * 60 * 1000 + case "top_earner": + return isTopEarner(agentId, stats) + } +} + +function writeBadges(agentId: string, badges: Badge[]): void { + ensureDir(BADGES_DIR) + fs.writeFileSync(badgeFilePath(agentId), JSON.stringify(badges, null, 2)) +} + +export function getBadges(agentId: string): Badge[] { + const cleanId = normalizeAgentId(agentId) + return readJsonFile(badgeFilePath(cleanId)) ?? [] +} + +export function checkAndAwardBadges(agentId: string): Badge[] { + const cleanId = normalizeAgentId(agentId) + const existing = getBadges(cleanId) + const awardedTypes = new Set(existing.map((badge) => badge.type)) + const stats = getAgentStats(cleanId) + const now = new Date().toISOString() + + const newlyAwarded = (Object.keys(BADGE_DEFINITIONS) as BadgeType[]) + .filter((type) => !awardedTypes.has(type) && shouldAward(type, cleanId, stats)) + .map((type) => ({ + agentId: cleanId, + awardedAt: now, + ...BADGE_DEFINITIONS[type], + })) + + if (newlyAwarded.length > 0) { + writeBadges(cleanId, [...existing, ...newlyAwarded]) + } + + return newlyAwarded +} + +export function getAllBadges(agentId: string): Badge[] { + checkAndAwardBadges(agentId) + return getBadges(agentId) +} + +export function resetBadgeStore(agentId?: string): void { + if (agentId) { + const filePath = badgeFilePath(normalizeAgentId(agentId)) + if (fs.existsSync(filePath)) fs.rmSync(filePath) + return + } + + if (fs.existsSync(BADGES_DIR)) fs.rmSync(BADGES_DIR, { recursive: true, force: true }) +} diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 1a4d203..a0f517f 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -222,9 +222,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -242,9 +239,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -262,9 +256,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -282,9 +273,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -302,9 +290,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -322,9 +307,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1741,9 +1723,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1765,9 +1744,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1789,9 +1765,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1813,9 +1786,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/sdk/src/__fixtures__/verify-proof.json b/sdk/src/__fixtures__/verify-proof.json new file mode 100644 index 0000000..9f4939a --- /dev/null +++ b/sdk/src/__fixtures__/verify-proof.json @@ -0,0 +1,30 @@ +{ + "proof": { + "pi_a": [ + "3041182677467871293195478910345193305677302330800699573375772582752526497254", + "21036851682629672269813166398476452391427341110154184630081083006192465643561" + ], + "pi_b": [ + [ + "21004423047790407274196457744874233527149306066889724223380071683544333692127", + "2273711813418516565494382962483981712454697991129472755097734928076417404601" + ], + [ + "11941267680172341700998221940604876031002769274283128037090182806511051078183", + "12537400421715869904316973298990802706273240271696333050658439102079026762521" + ] + ], + "pi_c": [ + "14017923646367446932394364635757800288304702235488807278130434555112888421819", + "6433145435762139345009245394048102269566528255351514557280798792997720159280" + ], + "protocol": "groth16", + "curve": "bn128" + }, + "publicInputs": [ + "0x6c8e54da15f2c1dd4862d76e1cf2d1408df5d9001c172a0600e8ceaaf227fca", + "0x2adfb605cf2fb6779aa04e1e900c841436903d781eb9166fcdbf1c55b5140b14", + "0x2a", + "0x1dcd6500" + ] +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index a3c009c..a704b2a 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -25,6 +25,10 @@ export { type SorobanProof, type Artifact, } from "./prover.js"; +export { + buildVerifyCall, + ProofEncodingError, +} from "./verify.js"; // Re-export the typed contract client + types generated from the deployed contract. export { @@ -36,4 +40,3 @@ export { } from "../bindings/src/index.js"; export { PassportClient } from "./PassportClient.js"; - diff --git a/sdk/src/verify.test.ts b/sdk/src/verify.test.ts new file mode 100644 index 0000000..55d22ef --- /dev/null +++ b/sdk/src/verify.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { Networks, Operation, xdr } from "@stellar/stellar-sdk"; +import fixture from "./__fixtures__/verify-proof.json"; +import { buildVerifyCall, ProofEncodingError } from "./verify"; +import type { Groth16Proof } from "snarkjs"; + +const CONTRACT_ID = "CDNSZUNEWFCGSPWLPDSWTENR2WPHKC34RGZQG7RJA54OPGTZGVVRFYBA"; + +describe("buildVerifyCall", () => { + it("builds a ready-to-sign Soroban transaction from a known proof fixture", async () => { + const tx = await buildVerifyCall( + fixture.proof as Groth16Proof, + fixture.publicInputs, + CONTRACT_ID, + Networks.TESTNET, + ); + const envelope = xdr.TransactionEnvelope.fromXDR(tx.toXDR(), "base64"); + const operation = envelope.v1().tx().operations()[0]; + + expect(tx.networkPassphrase).toBe(Networks.TESTNET); + expect(tx.operations).toHaveLength(1); + expect(operation.body().switch().name).toBe("invokeHostFunction"); + + const invoke = Operation.fromXDRObject(operation).func; + expect(invoke.switch().name).toBe("hostFunctionTypeInvokeContract"); + + const args = invoke.invokeContract().args(); + expect(args).toHaveLength(2); + expect(args[0].switch()).toEqual(xdr.ScValType.scvMap()); + expect(args[1].switch()).toEqual(xdr.ScValType.scvVec()); + + const proofEntries = args[0].map()?.map((entry) => + Buffer.from(entry.val().bytes() ?? []).toString("hex"), + ); + expect(proofEntries).toEqual([ + "06b93f96ed20999901cc48454c3c679c7dba1cce9d8705938400f1b7268b75e62e826fa485e93ba4d9b087df52b68f551116c8224bc212144a2ec513d4768829", + "0506e0126ea65f0682a5518398abc386396b5760d35a7348dac5450c91160eb92e7015079ae46f073a41d6bf9a1c7df6b282a74397d973d685a0b38ca6102cdf1bb7eacb941ed9efe0a2b3e784953b3726acb9f322f8da095e0e2b8857ce93191a66849b4354139a76be8d621516c2702a9f8b329caa583a03278dd7201bfa27", + "1efddd1616f866a6ca2d9564042072fe552160f544665c12f5c6a952ec934dbb0e3908022f9ad683338d0f2f3589441e7bc594e2a5b23e63d75741795fadf430", + ]); + }); + + it.each([Networks.TESTNET, Networks.PUBLIC])( + "works with %s network passphrase", + async (networkPassphrase) => { + const tx = await buildVerifyCall( + fixture.proof as Groth16Proof, + fixture.publicInputs, + CONTRACT_ID, + networkPassphrase, + ); + + expect(tx.networkPassphrase).toBe(networkPassphrase); + }, + ); + + it("throws ProofEncodingError for malformed proofs", async () => { + const malformedProof: Groth16Proof = { + pi_a: ["1"], + pi_b: [["2", "3"], ["4", "5"]], + pi_c: ["6", "7"], + curve: "bn128", + protocol: "groth16", + }; + + await expect( + buildVerifyCall(malformedProof, fixture.publicInputs, CONTRACT_ID, Networks.TESTNET), + ).rejects.toBeInstanceOf(ProofEncodingError); + }); +}); diff --git a/sdk/src/verify.ts b/sdk/src/verify.ts new file mode 100644 index 0000000..667c16b --- /dev/null +++ b/sdk/src/verify.ts @@ -0,0 +1,154 @@ +import { + Account, + BASE_FEE, + Contract, + TransactionBuilder, + nativeToScVal, + type Transaction, +} from "@stellar/stellar-sdk"; +import { NULL_ACCOUNT } from "@stellar/stellar-sdk/contract"; +import type { Groth16Proof } from "snarkjs"; +const VERIFY_METHOD = "verify_and_register"; +const FIELD_HEX_BYTES = 32; +const G1_COORDINATES = 2; +const G2_ROWS = 2; +const G2_COORDINATES = 2; + +/** + * Thrown when a Groth16 proof cannot be encoded into the Soroban call format. + */ +export class ProofEncodingError extends Error { + readonly cause?: unknown; + + constructor(message: string, options?: { cause?: unknown }) { + super(message); + this.name = "ProofEncodingError"; + this.cause = options?.cause; + } +} + +function normalizeHexInput(value: string): bigint { + const trimmed = value.trim(); + if (!trimmed) throw new ProofEncodingError("public input must not be empty"); + const hex = trimmed.startsWith("0x") || trimmed.startsWith("0X") ? trimmed : `0x${trimmed}`; + try { + return BigInt(hex); + } catch (cause) { + throw new ProofEncodingError(`invalid public input hex: ${value}`, { cause }); + } +} + +function encodeFieldElement(value: string, name: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new ProofEncodingError(`${name} must be a non-empty string`); + } + + let encoded: string; + try { + encoded = BigInt(value).toString(16); + } catch (cause) { + throw new ProofEncodingError(`invalid field element for ${name}`, { cause }); + } + + if (encoded.startsWith("-")) { + throw new ProofEncodingError(`${name} must not be negative`); + } + if (encoded.length > FIELD_HEX_BYTES * 2) { + throw new ProofEncodingError(`${name} exceeds 32 bytes`); + } + + return encoded.padStart(FIELD_HEX_BYTES * 2, "0"); +} + +function encodeG1(point: unknown, name: "pi_a" | "pi_c"): Buffer { + if (!Array.isArray(point) || point.length < G1_COORDINATES) { + throw new ProofEncodingError(`proof.${name} must contain 2 coordinates`); + } + + const hex = point + .slice(0, G1_COORDINATES) + .map((coordinate, index) => encodeFieldElement(coordinate, `proof.${name}[${index}]`)) + .join(""); + + return Buffer.from(hex, "hex"); +} + +function encodeG2(point: unknown): Buffer { + if (!Array.isArray(point) || point.length < G2_ROWS) { + throw new ProofEncodingError("proof.pi_b must contain 2 rows"); + } + + const rows = point.slice(0, G2_ROWS); + for (const [rowIndex, row] of rows.entries()) { + if (!Array.isArray(row) || row.length < G2_COORDINATES) { + throw new ProofEncodingError(`proof.pi_b[${rowIndex}] must contain 2 coordinates`); + } + } + + const hex = [ + encodeFieldElement(rows[0][1], "proof.pi_b[0][1]"), + encodeFieldElement(rows[0][0], "proof.pi_b[0][0]"), + encodeFieldElement(rows[1][1], "proof.pi_b[1][1]"), + encodeFieldElement(rows[1][0], "proof.pi_b[1][0]"), + ].join(""); + + return Buffer.from(hex, "hex"); +} + +function encodeProof(proof: Groth16Proof): { a: Buffer; b: Buffer; c: Buffer } { + if (!proof || typeof proof !== "object") { + throw new ProofEncodingError("proof must be an object"); + } + + return { + a: encodeG1(proof.pi_a, "pi_a"), + b: encodeG2(proof.pi_b), + c: encodeG1(proof.pi_c, "pi_c"), + }; +} + +function encodeVerifyArgs( + proof: Groth16Proof, + publicInputs: string[], +): ReturnType[] { + const normalizedPublicInputs = publicInputs.map(normalizeHexInput); + + try { + const encodedProof = encodeProof(proof); + return [ + nativeToScVal(encodedProof), + nativeToScVal(normalizedPublicInputs), + ]; + } catch (cause) { + if (cause instanceof ProofEncodingError) throw cause; + throw new ProofEncodingError("failed to encode proof for Soroban verify call", { cause }); + } +} + +/** + * Build an unsigned Soroban verification transaction for the validator contract. + * + * @param proof - Raw `snarkjs` Groth16 proof with `pi_a`, `pi_b`, and `pi_c` coordinates. + * @param publicInputs - Public input field elements as hex strings, with or without a `0x` prefix. + * @param contractId - Target validator contract ID. + * @param networkPassphrase - Stellar network passphrase, e.g. testnet or mainnet. + * @returns A Soroban contract invocation transaction ready to be signed. + */ +export async function buildVerifyCall( + proof: Groth16Proof, + publicInputs: string[], + contractId: string, + networkPassphrase: string, +): Promise { + const args = encodeVerifyArgs(proof, publicInputs); + const contract = new Contract(contractId); + const source = new Account(NULL_ACCOUNT, "0"); + + return new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(contract.call(VERIFY_METHOD, ...args)) + .setTimeout(0) + .build(); +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json index ab409d1..d1aaf82 100644 --- a/sdk/tsconfig.json +++ b/sdk/tsconfig.json @@ -7,6 +7,7 @@ "declaration": true, "outDir": "./dist", "rootDir": ".", + "resolveJsonModule": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/sdk/vitest.config.ts b/sdk/vitest.config.ts new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/sdk/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/tests/lib/agents/badges-route.test.ts b/tests/lib/agents/badges-route.test.ts new file mode 100644 index 0000000..58b8dc7 --- /dev/null +++ b/tests/lib/agents/badges-route.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { GET } from "@/app/api/agents/[id]/badges/route" +import { resetBadgeStore } from "@/lib/agents/badges" + +vi.mock("next/server", () => { + return { + NextResponse: { + json: (body: unknown, init?: { status?: number; headers?: Record }) => { + const headers = new Headers(init?.headers) + return { + status: init?.status ?? 200, + headers, + json: async () => body, + } as unknown as Response + }, + }, + } +}) + +const DATA_DIR = path.join(process.cwd(), ".data") + +function writeJson(filePath: string, data: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +describe("GET /api/agents/[id]/badges", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z")) + fs.rmSync(DATA_DIR, { recursive: true, force: true }) + resetBadgeStore() + }) + + afterEach(() => { + vi.useRealTimers() + fs.rmSync(DATA_DIR, { recursive: true, force: true }) + resetBadgeStore() + }) + + it("returns badges and count", async () => { + writeJson(path.join(DATA_DIR, "agents", "agent-9.json"), { + id: "agent-9", + registeredAt: "2026-04-01T00:00:00.000Z", + completedTasks: 1, + completedQuests: 5, + level: 10, + xp: 999, + }) + writeJson(path.join(DATA_DIR, "xp", "leaderboard.json"), [{ agentId: "agent-9", xp: 999 }]) + + const response = await GET(new Request("http://localhost/api/agents/agent-9/badges"), { + params: Promise.resolve({ id: "agent-9" }), + }) + + expect(response.status).toBe(200) + expect(response.headers.get("Cache-Control")).toBe("no-store") + + const body = await response.json() as { badges: Array<{ type: string }>; count: number } + expect(body.count).toBe(5) + expect(body.badges.map((badge) => badge.type).sort()).toEqual([ + "first_task", + "level_10", + "quest_master", + "top_earner", + "veteran", + ]) + }) +}) diff --git a/tests/lib/agents/badges.test.ts b/tests/lib/agents/badges.test.ts new file mode 100644 index 0000000..7a4dd57 --- /dev/null +++ b/tests/lib/agents/badges.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + checkAndAwardBadges, + getBadges, + resetBadgeStore, +} from "@/lib/agents/badges" + +const DATA_DIR = path.join(process.cwd(), ".data") +const AGENTS_DIR = path.join(DATA_DIR, "agents") +const TASKS_DIR = path.join(DATA_DIR, "tasks") +const QUESTS_DIR = path.join(DATA_DIR, "quests") +const XP_DIR = path.join(DATA_DIR, "xp") + +function writeJson(filePath: string, data: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +function agentFile(agentId: string): string { + return path.join(AGENTS_DIR, `${agentId}.json`) +} + +describe("badges", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z")) + fs.rmSync(DATA_DIR, { recursive: true, force: true }) + resetBadgeStore() + }) + + afterEach(() => { + vi.useRealTimers() + fs.rmSync(DATA_DIR, { recursive: true, force: true }) + resetBadgeStore() + }) + + it("awards first_task once after one completed task", () => { + writeJson(agentFile("agent-1"), { + id: "agent-1", + registeredAt: "2026-06-15T00:00:00.000Z", + level: 1, + completedTasks: 1, + completedQuests: 0, + xp: 10, + }) + + const first = checkAndAwardBadges("agent-1") + const second = checkAndAwardBadges("agent-1") + + expect(first.map((badge) => badge.type)).toEqual(["first_task"]) + expect(second).toEqual([]) + expect(getBadges("agent-1")).toHaveLength(1) + }) + + it("awards quest_master, level_10, veteran, and top_earner when conditions are met", () => { + writeJson(agentFile("agent-elite"), { + id: "agent-elite", + registeredAt: "2026-05-01T00:00:00.000Z", + level: 12, + completedTasks: 4, + completedQuests: 5, + xp: 900, + }) + writeJson(agentFile("agent-mid"), { id: "agent-mid", registeredAt: "2026-06-10T00:00:00.000Z", level: 7, xp: 300 }) + writeJson(agentFile("agent-low"), { id: "agent-low", registeredAt: "2026-06-11T00:00:00.000Z", level: 4, xp: 100 }) + writeJson(path.join(XP_DIR, "leaderboard.json"), [ + { agentId: "agent-elite", xp: 900 }, + { agentId: "agent-mid", xp: 300 }, + { agentId: "agent-low", xp: 100 }, + { agentId: "agent-x", xp: 90 }, + { agentId: "agent-y", xp: 80 }, + { agentId: "agent-z", xp: 70 }, + { agentId: "agent-a", xp: 60 }, + { agentId: "agent-b", xp: 50 }, + { agentId: "agent-c", xp: 40 }, + { agentId: "agent-d", xp: 30 }, + ]) + + const awarded = checkAndAwardBadges("agent-elite") + + expect(awarded.map((badge) => badge.type).sort()).toEqual([ + "first_task", + "level_10", + "quest_master", + "top_earner", + "veteran", + ]) + }) + + it("derives task and quest counts from data files when agent stats are absent", () => { + writeJson(agentFile("agent-2"), { + id: "agent-2", + registeredAt: "2026-05-20T00:00:00.000Z", + level: 10, + xp: 120, + }) + writeJson(path.join(TASKS_DIR, "task-1.json"), { id: "task-1", agentId: "agent-2", status: "completed" }) + for (let index = 1; index <= 5; index += 1) { + writeJson(path.join(QUESTS_DIR, `quest-${index}.json`), { + id: `quest-${index}`, + completedBy: ["agent-2"], + }) + } + + const awarded = checkAndAwardBadges("agent-2") + + expect(awarded.map((badge) => badge.type).sort()).toEqual([ + "first_task", + "level_10", + "quest_master", + "veteran", + ]) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index a00a3ff..2953916 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import path from "node:path" export default defineConfig({ test: { include: ["tests/**/*.test.ts"], + fileParallelism: false, }, resolve: { alias: { "@": path.resolve(__dirname, ".") },