diff --git a/__tests__/api/task-offers.test.ts b/__tests__/api/task-offers.test.ts new file mode 100644 index 0000000..6fa1c1b --- /dev/null +++ b/__tests__/api/task-offers.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { GET as listOffers, POST as createOffer } from "@/app/api/task-offers/route" +import { DELETE as cancelOffer, GET as getOffer } from "@/app/api/task-offers/[id]/route" +import { resetTaskOffersForTests } from "@/lib/task-market/offers" + +async function mockRequest(url: string, options?: RequestInit): Promise { + return new Request(url, options) +} + +async function mockContext(params: Record) { + return { params: Promise.resolve(params) } as any +} + +describe("Task offer API", () => { + beforeEach(() => { + resetTaskOffersForTests() + }) + + afterEach(() => { + resetTaskOffersForTests() + }) + + it("creates and lists open offers by required capability", async () => { + const deadline = Math.floor(Date.now() / 1000) + 3600 + const createReq = await mockRequest("http://localhost:3000/api/task-offers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + postedBy: "agent-alpha", + requiredCapability: "run-inference", + payload: { prompt: "summarize this" }, + reward: "0.05 XLM", + deadline, + }), + }) + + const createRes = await createOffer(createReq) + const createData = await createRes.json() + expect(createRes.status).toBe(201) + expect(createData.ok).toBe(true) + expect(createData.offerId).toMatch(/^off_/) + expect(createData.escrowTx).toMatch(/^escrow_/) + expect(createData.offer.reward).toEqual({ amount: "0.05", asset: "XLM" }) + + const listReq = await mockRequest("http://localhost:3000/api/task-offers?cap=run-inference") + const listRes = await listOffers(listReq) + const listData = await listRes.json() + expect(listRes.status).toBe(200) + expect(listData.ok).toBe(true) + expect(listData.offers).toHaveLength(1) + expect(listData.offers[0].offerId).toBe(createData.offerId) + }) + + it("returns a single offer by id", async () => { + const createRes = await createOffer(await mockRequest("http://localhost:3000/api/task-offers", { + method: "POST", + body: JSON.stringify({ + postedBy: "agent-beta", + requiredCapability: "classify", + reward: { amount: "1.5", asset: "USDC" }, + deadline: Math.floor(Date.now() / 1000) + 3600, + }), + })) + const { offerId } = await createRes.json() + + const response = await getOffer( + await mockRequest(`http://localhost:3000/api/task-offers/${offerId}`), + await mockContext({ id: offerId }), + ) + const data = await response.json() + expect(response.status).toBe(200) + expect(data.ok).toBe(true) + expect(data.offer.postedBy).toBe("agent-beta") + }) + + it("lets only the poster cancel an open offer", async () => { + const createRes = await createOffer(await mockRequest("http://localhost:3000/api/task-offers", { + method: "POST", + body: JSON.stringify({ + postedBy: "agent-owner", + requiredCapability: "translate", + reward: "0.25 XLM", + deadline: Math.floor(Date.now() / 1000) + 3600, + }), + })) + const { offerId } = await createRes.json() + + const forbidden = await cancelOffer( + await mockRequest(`http://localhost:3000/api/task-offers/${offerId}?agentId=other-agent`, { method: "DELETE" }), + await mockContext({ id: offerId }), + ) + expect(forbidden.status).toBe(403) + + const response = await cancelOffer( + await mockRequest(`http://localhost:3000/api/task-offers/${offerId}`, { + method: "DELETE", + headers: { "x-agent-id": "agent-owner" }, + }), + await mockContext({ id: offerId }), + ) + const data = await response.json() + expect(response.status).toBe(200) + expect(data.ok).toBe(true) + expect(data.offer.status).toBe("cancelled") + expect(data.refundTx).toMatch(/^refund_/) + }) + + it("rejects invalid rewards and expired deadlines", async () => { + const invalidReward = await createOffer(await mockRequest("http://localhost:3000/api/task-offers", { + method: "POST", + body: JSON.stringify({ + postedBy: "agent-alpha", + requiredCapability: "run-inference", + reward: "free", + deadline: Math.floor(Date.now() / 1000) + 3600, + }), + })) + expect(invalidReward.status).toBe(400) + + const expiredDeadline = await createOffer(await mockRequest("http://localhost:3000/api/task-offers", { + method: "POST", + body: JSON.stringify({ + postedBy: "agent-alpha", + requiredCapability: "run-inference", + reward: "0.05 XLM", + deadline: Math.floor(Date.now() / 1000) - 1, + }), + })) + expect(expiredDeadline.status).toBe(400) + }) +}) diff --git a/app/api/task-offers/[id]/route.ts b/app/api/task-offers/[id]/route.ts new file mode 100644 index 0000000..6063f79 --- /dev/null +++ b/app/api/task-offers/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server" +import { cancelTaskOffer, getTaskOffer } from "@/lib/task-market/offers" + +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ id: string }> +} + +function actorFromRequest(req: Request): string { + const url = new URL(req.url) + return String(req.headers.get("x-agent-id") ?? url.searchParams.get("agentId") ?? "").trim() +} + +export async function GET(_req: Request, context: RouteContext) { + const { id } = await context.params + const offer = getTaskOffer(decodeURIComponent(id)) + if (!offer) { + return NextResponse.json( + { ok: false, error: "Task offer not found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ) + } + + return NextResponse.json({ ok: true, offer }, { headers: { "Cache-Control": "no-store" } }) +} + +export async function DELETE(req: Request, context: RouteContext) { + const { id } = await context.params + const actorId = actorFromRequest(req) + if (!actorId) { + return NextResponse.json( + { ok: false, error: "agentId is required" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + try { + const offer = cancelTaskOffer(decodeURIComponent(id), actorId) + return NextResponse.json({ ok: true, offer, refundTx: offer.refundTx }, { headers: { "Cache-Control": "no-store" } }) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to cancel task offer" + let status = 400 + if (message.includes("not found")) { + status = 404 + } else if (message.includes("Only the poster")) { + status = 403 + } + return NextResponse.json( + { ok: false, error: message }, + { status, headers: { "Cache-Control": "no-store" } }, + ) + } +} diff --git a/app/api/task-offers/route.ts b/app/api/task-offers/route.ts new file mode 100644 index 0000000..2b4acc1 --- /dev/null +++ b/app/api/task-offers/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" +import { createTaskOffer, listTaskOffers } from "@/lib/task-market/offers" + +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +export async function GET(req: Request) { + const url = new URL(req.url) + const requiredCapability = url.searchParams.get("cap")?.trim() || undefined + const includeExpired = url.searchParams.get("includeExpired") === "1" + + return NextResponse.json( + { + ok: true, + offers: listTaskOffers({ requiredCapability, includeExpired }), + }, + { headers: { "Cache-Control": "no-store" } }, + ) +} + +export async function POST(req: Request) { + try { + const body = await req.json() + const offer = createTaskOffer({ + postedBy: String(body.postedBy ?? req.headers.get("x-agent-id") ?? ""), + requiredCapability: String(body.requiredCapability ?? ""), + payload: body.payload && typeof body.payload === "object" ? body.payload : {}, + reward: body.reward, + deadline: Number(body.deadline), + }) + + return NextResponse.json( + { ok: true, offerId: offer.offerId, escrowTx: offer.escrowTx, offer }, + { status: 201, headers: { "Cache-Control": "no-store" } }, + ) + } catch (error) { + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : "Failed to create task offer" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } +} diff --git a/lib/task-market/offers.ts b/lib/task-market/offers.ts new file mode 100644 index 0000000..bed1531 --- /dev/null +++ b/lib/task-market/offers.ts @@ -0,0 +1,162 @@ +export type TaskOfferAsset = "XLM" | "USDC" +export type TaskOfferStatus = "open" | "claimed" | "delivered" | "accepted" | "disputed" | "expired" | "cancelled" + +export interface TaskOfferReward { + amount: string + asset: TaskOfferAsset +} + +export interface TaskOffer { + offerId: string + postedBy: string + requiredCapability: string + payload: unknown + reward: TaskOfferReward + deadline: number + status: TaskOfferStatus + escrowTx: string + refundTx?: string + createdAt: string + updatedAt: string +} + +export interface CreateTaskOfferInput { + postedBy: string + requiredCapability: string + payload?: unknown + reward: string | Partial + deadline: number +} + +interface TaskOfferState { + offers: Map + sequence: number +} + +const globalState = globalThis as typeof globalThis & { + __openStellarTaskOffers__?: TaskOfferState +} + +const state: TaskOfferState = globalState.__openStellarTaskOffers__ ?? { + offers: new Map(), + sequence: 0, +} + +globalState.__openStellarTaskOffers__ ??= state + +function assertNonEmpty(value: unknown, field: string): string { + const text = typeof value === "string" ? value.trim() : "" + if (!text) throw new Error(`${field} is required`) + return text +} + +function nextOfferId(): string { + state.sequence += 1 + return `off_${Date.now().toString(36)}_${state.sequence.toString(36)}` +} + +function isTaskOfferAsset(value: unknown): value is TaskOfferAsset { + return value === "XLM" || value === "USDC" +} + +function normalizeReward(reward: string | Partial): TaskOfferReward { + if (typeof reward === "string") { + const match = /^(\d+(?:\.\d+)?)\s+(XLM|USDC)$/i.exec(reward.trim()) + if (!match) throw new Error("reward must be formatted like '0.05 XLM'") + const amount = match[1] + const asset = match[2].toUpperCase() + if (Number(amount) <= 0) throw new Error("reward amount must be > 0") + if (!isTaskOfferAsset(asset)) throw new Error("reward asset must be XLM or USDC") + return { amount, asset } + } + + if (!reward || typeof reward !== "object") { + throw new Error("reward is required") + } + + const amount = String(reward.amount ?? "").trim() + const asset = String(reward.asset ?? "").trim().toUpperCase() + if (!amount || Number(amount) <= 0 || !Number.isFinite(Number(amount))) { + throw new Error("reward amount must be > 0") + } + if (!isTaskOfferAsset(asset)) throw new Error("reward asset must be XLM or USDC") + return { amount, asset } +} + +function normalizeDeadline(deadline: number): number { + const value = Number(deadline) + if (!Number.isFinite(value)) throw new Error("deadline must be a unix timestamp") + const timestamp = value > 10_000_000_000 ? Math.floor(value / 1000) : Math.floor(value) + if (timestamp <= Math.floor(Date.now() / 1000)) throw new Error("deadline must be in the future") + return timestamp +} + +function escrowHash(prefix: "escrow" | "refund", offerId: string): string { + return `${prefix}_${offerId}_${crypto.randomUUID()}` +} + +function refreshOfferExpiry(offer: TaskOffer): TaskOffer { + if (offer.status !== "open" || offer.deadline > Math.floor(Date.now() / 1000)) { + return offer + } + const expired = { + ...offer, + status: "expired" as const, + refundTx: offer.refundTx ?? escrowHash("refund", offer.offerId), + updatedAt: new Date().toISOString(), + } + state.offers.set(offer.offerId, expired) + return expired +} + +export function resetTaskOffersForTests(): void { + state.offers.clear() + state.sequence = 0 +} + +export function createTaskOffer(input: CreateTaskOfferInput): TaskOffer { + const now = new Date().toISOString() + const offerId = nextOfferId() + const offer: TaskOffer = { + offerId, + postedBy: assertNonEmpty(input.postedBy, "postedBy"), + requiredCapability: assertNonEmpty(input.requiredCapability, "requiredCapability"), + payload: input.payload ?? {}, + reward: normalizeReward(input.reward), + deadline: normalizeDeadline(input.deadline), + status: "open", + escrowTx: escrowHash("escrow", offerId), + createdAt: now, + updatedAt: now, + } + state.offers.set(offer.offerId, offer) + return offer +} + +export function getTaskOffer(offerId: string): TaskOffer | null { + const offer = state.offers.get(offerId) + return offer ? refreshOfferExpiry(offer) : null +} + +export function listTaskOffers(filters: { requiredCapability?: string; includeExpired?: boolean } = {}): TaskOffer[] { + return [...state.offers.values()] + .map(refreshOfferExpiry) + .filter((offer) => filters.includeExpired || offer.status === "open") + .filter((offer) => !filters.requiredCapability || offer.requiredCapability === filters.requiredCapability) + .sort((a, b) => a.deadline - b.deadline || a.offerId.localeCompare(b.offerId)) +} + +export function cancelTaskOffer(offerId: string, actorId: string): TaskOffer { + const offer = getTaskOffer(offerId) + if (!offer) throw new Error("Task offer not found") + if (offer.postedBy !== actorId) throw new Error("Only the poster can cancel this offer") + if (offer.status !== "open") throw new Error("Only open offers can be cancelled") + const cancelled = { + ...offer, + status: "cancelled" as const, + refundTx: offer.refundTx ?? escrowHash("refund", offer.offerId), + updatedAt: new Date().toISOString(), + } + state.offers.set(offerId, cancelled) + return cancelled +}