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, ".") },