Skip to content
Open
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
131 changes: 131 additions & 0 deletions __tests__/api/task-offers.test.ts
Original file line number Diff line number Diff line change
@@ -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<Request> {
return new Request(url, options)
}

async function mockContext(params: Record<string, string>) {
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)
})
})
55 changes: 55 additions & 0 deletions app/api/task-offers/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
}
42 changes: 42 additions & 0 deletions app/api/task-offers/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
}
162 changes: 162 additions & 0 deletions lib/task-market/offers.ts
Original file line number Diff line number Diff line change
@@ -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<TaskOfferReward>
deadline: number
}

interface TaskOfferState {
offers: Map<string, TaskOffer>
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>): 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
}
Loading