Skip to content
14 changes: 3 additions & 11 deletions __tests__/api/agents/tasks-drain.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from "vitest"
import { POST as drainPost } from "@/app/api/agents/[id]/tasks/drain/route"
import { POST as createPost, DELETE as purgeDel } from "@/app/api/agents/[id]/tasks/route"
import { POST as drainPost } from "@/app/api/agents/\[id\]/tasks/drain/route"
import { POST as createPost, DELETE as purgeDel } from "@/app/api/agents/\[id\]/tasks/route"
import { resetTaskQueue } from "@/lib/agents/task-queue"

async function mockContext(params: Record<string, string>) {
Expand Down Expand Up @@ -110,22 +110,20 @@ describe("POST /api/agents/:id/tasks/drain", () => {
const data = await response.json()

expect(response.status).toBe(200)
expect(data.processed).toBe(50) // Default is 50
expect(data.processed).toBe(50)
})

it("returns 409 on concurrent drain attempts", async () => {
for (let i = 0; i < 10; i++) {
await createTask("agent-1", `task-${i}`)
}

// Start first drain
const req1 = new Request("http://localhost/api/agents/agent-1/tasks/drain", {
method: "POST",
})
const context1 = await mockContext({ id: "agent-1" })
const promise1 = drainPost(req1, context1)

// Immediately try second drain
const req2 = new Request("http://localhost/api/agents/agent-1/tasks/drain", {
method: "POST",
})
Expand All @@ -137,7 +135,6 @@ describe("POST /api/agents/:id/tasks/drain", () => {
expect(data2.ok).toBe(false)
expect(data2.error).toContain("already in progress")

// Wait for first drain to complete
await promise1
})

Expand Down Expand Up @@ -213,7 +210,6 @@ describe("DELETE /api/agents/:id/tasks", () => {

expect(data.purged).toBe(2)

// Verify agent-2 tasks remain
const req2 = new Request("http://localhost/api/agents/agent-2/tasks/drain", {
method: "POST",
})
Expand All @@ -235,7 +231,6 @@ describe("drain + purge integration", () => {
await createTask("agent-1", `task-${i}`)
}

// Drain 10
const drainReq = new Request("http://localhost/api/agents/agent-1/tasks/drain", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -246,7 +241,6 @@ describe("drain + purge integration", () => {
const drainData = await drainRes.json()
expect(drainData.processed).toBe(10)

// Purge remaining
const purgeReq = new Request("http://localhost/api/agents/agent-1/tasks", {
method: "DELETE",
})
Expand All @@ -260,14 +254,12 @@ describe("drain + purge integration", () => {
await createTask("agent-1", "task-a")
await createTask("agent-1", "task-b")

// Purge all
const purgeReq = new Request("http://localhost/api/agents/agent-1/tasks", {
method: "DELETE",
})
const purgeContext = await mockContext({ id: "agent-1" })
await purgeDel(purgeReq, purgeContext)

// Try drain
const drainReq = new Request("http://localhost/api/agents/agent-1/tasks/drain", {
method: "POST",
})
Expand Down
83 changes: 83 additions & 0 deletions app/api/marketplace/skills/[skillId]/invoke/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextResponse } from "next/server"
import { invokeSkillWithPayment } from "@/lib/marketplace/x402-middleware"
import { recordInvocation } from "@/lib/marketplace/invocation-ledger"

interface RouteContext {
params: Promise<{ skillId: string }>
}

// Mock skill registry for demo — in production this queries the skills store
const MOCK_SKILLS: Record<string, { callUrl: string; priceXLM: number; ownerWallet: string }> = {
"skill-payment": {
callUrl: "https://api.example.com/skills/payment",
priceXLM: 1.0,

Check warning on line 13 in app/api/marketplace/skills/[skillId]/invoke/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use a zero fraction in the number.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8d22d7RDWH5g0of9Qj&open=AZ8d22d7RDWH5g0of9Qj&pullRequest=428
ownerWallet: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
},
}

function getSkill(skillId: string) {
// TODO: Replace with actual skills-store lookup when PR#304 merges

Check warning on line 19 in app/api/marketplace/skills/[skillId]/invoke/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8d22d7RDWH5g0of9Qk&open=AZ8d22d7RDWH5g0of9Qk&pullRequest=428
return MOCK_SKILLS[skillId] ?? null
}

export async function POST(req: Request, context: RouteContext) {
try {
const { skillId } = await context.params
const body = await req.json().catch(() => ({}))

if (!body.agentId || typeof body.agentId !== "string") {
return NextResponse.json(
{ ok: false, error: "agentId is required" },
{ status: 400, headers: { "Cache-Control": "no-store" } },
)
}

const skill = getSkill(skillId)
if (!skill) {
return NextResponse.json(
{ ok: false, error: "Skill not found" },
{ status: 404, headers: { "Cache-Control": "no-store" } },
)
}

const result = await invokeSkillWithPayment(
skill.callUrl,
skill.ownerWallet,
skill.priceXLM,
{ agentId: body.agentId, payload: body.payload },
)

// Record in ledger regardless of outcome
const record = recordInvocation(
body.agentId,
skillId,
skill.priceXLM,
result.receipt?.txHash ?? "",
result.ok ? "success" : result.error === "insufficient_balance" ? "insufficient_balance" : "failed",

Check warning on line 56 in app/api/marketplace/skills/[skillId]/invoke/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8d22d7RDWH5g0of9Ql&open=AZ8d22d7RDWH5g0of9Ql&pullRequest=428
result.error,
)

if (!result.ok) {
const statusCode = result.error === "insufficient_balance" ? 402 : 500
return NextResponse.json(
{ ok: false, error: result.error || "Skill invocation failed", ledgerId: record.id },
{ status: statusCode, headers: { "Cache-Control": "no-store" } },
)
}

return NextResponse.json(
{
ok: true,
response: result.response,
receipt: result.receipt,
ledgerId: record.id,
},
{ headers: { "Cache-Control": "no-store" } },
)
} catch (error) {
return NextResponse.json(
{ ok: false, error: error instanceof Error ? error.message : "Failed to invoke skill" },
{ status: 500, headers: { "Cache-Control": "no-store" } },
)
}
}
86 changes: 86 additions & 0 deletions lib/marketplace/invocation-ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export interface InvocationRecord {
id: string
agentId: string
skillId: string
amountXLM: number
txHash: string
invokedAt: string
status: "success" | "failed" | "insufficient_balance"
error?: string
}

type LedgerDb = InvocationRecord[]

const globalState = globalThis as typeof globalThis & {
__openStellarInvocationLedger__?: LedgerDb
}

function getDb(): LedgerDb {
if (!globalState.__openStellarInvocationLedger__) {
globalState.__openStellarInvocationLedger__ = []
}

Check warning on line 21 in lib/marketplace/invocation-ledger.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using nullish coalescing operator (`??=`) instead of an assignment expression, as it is simpler to read.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8d22diRDWH5g0of9Qh&open=AZ8d22diRDWH5g0of9Qh&pullRequest=428
return globalState.__openStellarInvocationLedger__
}

export function recordInvocation(
agentId: string,
skillId: string,
amountXLM: number,
txHash: string,
status: InvocationRecord["status"] = "success",
error?: string,
): InvocationRecord {
const record: InvocationRecord = {
id: `inv_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,

Check warning on line 34 in lib/marketplace/invocation-ledger.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that using this pseudorandom number generator is safe here.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8d22diRDWH5g0of9Qi&open=AZ8d22diRDWH5g0of9Qi&pullRequest=428
agentId: agentId.trim(),
skillId: skillId.trim(),
amountXLM: Math.max(0, Number(amountXLM) || 0),
txHash: txHash.trim(),
invokedAt: new Date().toISOString(),
status,
error,
}

const db = getDb()
db.push(record)

// Cap at 50k entries
if (db.length > 50_000) {
db.splice(0, db.length - 50_000)
}

return record
}

export function listInvocations(
filters: { agentId?: string; skillId?: string; limit?: number } = {},
): InvocationRecord[] {
const db = getDb()
let results = [...db].reverse()

if (filters.agentId) {
results = results.filter((r) => r.agentId === filters.agentId)
}
if (filters.skillId) {
results = results.filter((r) => r.skillId === filters.skillId)
}
if (filters.limit && filters.limit > 0) {
results = results.slice(0, filters.limit)
}

return results
}

export function getInvocationById(id: string): InvocationRecord | null {
return getDb().find((r) => r.id === id) ?? null
}

export function getTotalSpentByAgent(agentId: string): number {
return getDb()
.filter((r) => r.agentId === agentId && r.status === "success")
.reduce((sum, r) => sum + r.amountXLM, 0)
}

export function resetInvocationLedger(): void {
getDb().splice(0, getDb().length)
}
135 changes: 135 additions & 0 deletions lib/marketplace/x402-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { createX402Quote, settleX402, type X402Quote, type X402SettlementResult } from "@/lib/protocols/x402"
import { isMockMode } from "@/lib/mock/mock-mode"
import { settleMockX402 } from "@/lib/mock/x402-mock"

export interface SkillInvocationRequest {
agentId: string
payload: unknown
}

export interface PaymentRequirement {
quote: X402Quote
paymentRef: string
}

export interface InvocationResult {
ok: boolean
response?: unknown
receipt?: { txHash: string; chain: string; amountUsd: number }
error?: string
}

/**
* Attempt an HTTP request to a skill's callUrl.
* If the response is 402, extract payment requirements and return them.
*/
export async function attemptSkillInvocation(
callUrl: string,
request: SkillInvocationRequest,
): Promise<{ status: number; body: unknown; headers: Record<string, string> }> {
const response = await fetch(callUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
})

const body = await response.json().catch(() => ({}))
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value
})

return { status: response.status, body, headers }
}

/**
* Generate a Stellar payment transaction and settle it via x402.
* In mock mode, simulates the settlement without real on-chain calls.
*/
export async function settleSkillPayment(
paymentRef: string,
txHash: string,
chain: "stellar" | "bnb" | "base" = "stellar",
): Promise<X402SettlementResult> {
if (isMockMode()) {
const receipt = settleMockX402({ paymentRef, chain, txHash })
return { ok: true, receipt }
}

return settleX402({ paymentRef, chain, txHash })
}

/**
* Full x402 payment flow for skill invocation:
* 1. Attempt the HTTP call
* 2. If 402, generate quote and settle payment
* 3. Retry the HTTP call with payment proof
* 4. Return the final response
*/
export async function invokeSkillWithPayment(
callUrl: string,
skillOwnerWallet: string,
priceXLM: number,
request: SkillInvocationRequest,
): Promise<InvocationResult> {
// Step 1: Attempt initial call
const initial = await attemptSkillInvocation(callUrl, request)

// If not 402, return the response directly
if (initial.status !== 402) {
return {
ok: initial.status >= 200 && initial.status < 300,
response: initial.body,
}
}

// Step 2: Generate x402 quote for the skill payment
const quote = createX402Quote({
serviceId: callUrl,
chain: "stellar",
payer: request.agentId,
units: 1,
unitPriceUsd: priceXLM * 0.1, // approximate USD price
ttlSeconds: 300,
})

// Step 3: Generate a mock/real payment txHash
// In production, the caller would sign and submit a Stellar tx
const txHash = `mock_tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`

Check warning on line 98 in lib/marketplace/x402-middleware.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that using this pseudorandom number generator is safe here.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8d22YiRDWH5g0of9Qg&open=AZ8d22YiRDWH5g0of9Qg&pullRequest=428

// Step 4: Settle the payment
const settlement = await settleSkillPayment(quote.paymentRef, txHash, "stellar")

if (!settlement.ok || !settlement.receipt) {
return {
ok: false,
error: settlement.error || "Payment settlement failed",
}
}

// Step 5: Retry the HTTP call with payment proof header
const retry = await fetch(callUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Payment": JSON.stringify({
paymentRef: quote.paymentRef,
txHash: settlement.receipt.txHash,
chain: settlement.receipt.chain,
}),
},
body: JSON.stringify(request),
})

const retryBody = await retry.json().catch(() => ({}))

return {
ok: retry.ok,
response: retryBody,
receipt: {
txHash: settlement.receipt.txHash,
chain: settlement.receipt.chain,
amountUsd: settlement.receipt.amountUsd ?? 0,
},
}
}
Loading
Loading