diff --git a/src/__tests__/bnb-marketplace-activation.test.ts b/src/__tests__/bnb-marketplace-activation.test.ts new file mode 100644 index 0000000..a0d0468 --- /dev/null +++ b/src/__tests__/bnb-marketplace-activation.test.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { activationRequestSchema, dispatchTestnetActivation } from "@/lib/bnb-marketplace-activation"; +import { getMarketplaceActivationAction } from "@/lib/bnb-marketplace-activation-flow"; +import { BNB_MARKETPLACE_AGENTS } from "@/lib/bnb-marketplace"; + +afterEach(() => vi.restoreAllMocks()); + +const FUZZ_SEED = 0x8183_8004; +const FUZZ_CASES = 128; + +function seededRandom(seed: number) { + let state = seed >>> 0; + return () => { + state += 0x6d2b79f5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +function randomString(random: () => number, alphabet: string, length: number) { + return Array.from({ length }, () => alphabet[Math.floor(random() * alphabet.length)]).join(""); +} + +function randomPositiveBigInt(random: () => number, max: bigint) { + const high = BigInt(Math.floor(random() * 0x1_0000_0000)); + const low = BigInt(Math.floor(random() * 0x1_0000_0000)); + return ((high << 32n) | low) % max + 1n; +} + +describe("BNB marketplace activation boundary", () => { + it("resumes delivery without funding a confirmed job again", () => { + expect(getMarketplaceActivationAction(false, null)).toBe("quote"); + expect(getMarketplaceActivationAction(true, null)).toBe("fund"); + expect(getMarketplaceActivationAction(true, "42")).toBe("notify"); + }); + + it("accepts only the curated slug and never a caller supplied endpoint", async () => { + expect(activationRequestSchema.safeParse({ action: "quote", slug: "studio-desk-grid", endpoint: "https://evil.example" }).success).toBe(false); + await expect(dispatchTestnetActivation({ action: "quote", slug: "missing" })).rejects.toThrow("Unknown BSC Testnet agent"); + }); + + it("proxies a quote only to the curated testnet seller and validates its wallet", async () => { + const agent = BNB_MARKETPLACE_AGENTS[1]; + const quote = { + quoteHash: `0x${"a".repeat(64)}`, + feeAmount: "1200000000000000", + wallet: agent.agentWallet, + }; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(quote), { status: 200 }), + ); + + const result = await dispatchTestnetActivation({ action: "quote", slug: agent.slug }); + expect(result.chainId).toBe(97); + expect(fetchMock).toHaveBeenCalledWith(`${agent.sellerApiBaseUrl}/quote`, expect.objectContaining({ + method: "POST", redirect: "error", cache: "no-store", + })); + }); + + it("rejects a signed quote from a wallet outside the registry identity", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + quoteHash: `0x${"b".repeat(64)}`, + feeAmount: "1", + wallet: "0x0000000000000000000000000000000000000001", + }), { status: 200 })); + + await expect(dispatchTestnetActivation({ action: "quote", slug: BNB_MARKETPLACE_AGENTS[0].slug })) + .rejects.toThrow("Quote wallet does not match registry identity"); + }); + + it("rejects a quote above the curated testnet fee cap", async () => { + const agent = BNB_MARKETPLACE_AGENTS[0]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + quoteHash: `0x${"d".repeat(64)}`, + feeAmount: (BigInt(agent.maxFeeAmount) + 1n).toString(), + wallet: agent.agentWallet, + }), { status: 200 })); + + await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })) + .rejects.toThrow("Quote exceeds the curated testnet fee cap"); + }); + + it("rejects and cancels a streamed seller response above the byte limit", async () => { + const encoder = new TextEncoder(); + const chunks = ["x".repeat(60_000), "y".repeat(40_001), "z".repeat(60_000)]; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk === undefined) controller.close(); + else controller.enqueue(encoder.encode(chunk)); + }, + cancel() { + cancelled = true; + }, + }); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(body, { status: 200, headers: { "content-length": "1" } }), + ); + + await expect(dispatchTestnetActivation({ action: "quote", slug: BNB_MARKETPLACE_AGENTS[0].slug })) + .rejects.toThrow("Agent response exceeded 100 KB"); + expect(cancelled).toBe(true); + }); + + it("notifies delivery only with a funded job id and quote hash", async () => { + const agent = BNB_MARKETPLACE_AGENTS[3]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ status: "accepted", receipt: { jobId: "42" } }), { status: 200 }), + ); + const quoteHash = `0x${"c".repeat(64)}` as `0x${string}`; + + await dispatchTestnetActivation({ action: "notify_funded", slug: agent.slug, jobId: "42", quoteHash }); + expect(fetchMock).toHaveBeenCalledWith(`${agent.sellerApiBaseUrl}/notify_funded`, expect.objectContaining({ + body: JSON.stringify({ jobId: "42", quoteHash, dryRun: false, amount: "1" }), + })); + }); + + it("property: rejects caller-controlled endpoint fields and unknown seller slugs", async () => { + const random = seededRandom(FUZZ_SEED); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + for (let index = 0; index < FUZZ_CASES; index++) { + const host = randomString(random, "abcdefghijklmnopqrstuvwxyz0123456789", 12); + const endpoint = `https://${host}.example/api/${index}`; + const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; + const payload = index % 2 === 0 + ? { action: "quote", slug: agent.slug, endpoint } + : { action: "notify_funded", slug: agent.slug, jobId: String(index + 1), quoteHash: `0x${"a".repeat(64)}`, endpoint }; + + expect( + activationRequestSchema.safeParse(payload).success, + `seed=${FUZZ_SEED} case=${index} endpoint=${endpoint}`, + ).toBe(false); + + const unknownSlug = `fuzz-${randomString(random, "abcdefghijklmnopqrstuvwxyz0123456789-", 24)}`; + await expect(dispatchTestnetActivation({ action: "quote", slug: unknownSlug })) + .rejects.toThrow("Unknown BSC Testnet agent"); + } + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("property: rejects malformed quote hashes and job identifiers", () => { + const random = seededRandom(FUZZ_SEED ^ 0x51a7); + const validSlug = BNB_MARKETPLACE_AGENTS[0].slug; + + for (let index = 0; index < FUZZ_CASES; index++) { + const invalidHash = index % 3 === 0 + ? `0x${randomString(random, "0123456789abcdef", 63)}` + : index % 3 === 1 + ? `0x${randomString(random, "0123456789abcdef", 65)}` + : `0x${randomString(random, "ghijklmnopqrstuvwxyz", 64)}`; + const invalidJobId = index % 4 === 0 + ? "0x1" + : index % 4 === 1 + ? "-1" + : index % 4 === 2 + ? "1e6" + : randomString(random, "0123456789", 79 + (index % 32)); + + expect( + activationRequestSchema.safeParse({ action: "notify_funded", slug: validSlug, jobId: "1", quoteHash: invalidHash }).success, + `seed=${FUZZ_SEED ^ 0x51a7} case=${index} hash=${invalidHash}`, + ).toBe(false); + expect( + activationRequestSchema.safeParse({ action: "notify_funded", slug: validSlug, jobId: invalidJobId, quoteHash: `0x${"b".repeat(64)}` }).success, + `seed=${FUZZ_SEED ^ 0x51a7} case=${index} jobId=${invalidJobId}`, + ).toBe(false); + } + }); + + it("property: accepts positive fees at or below each curated cap", async () => { + const random = seededRandom(FUZZ_SEED ^ 0xfee); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + for (let index = 0; index < FUZZ_CASES; index++) { + const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; + const feeAmount = randomPositiveBigInt(random, BigInt(agent.maxFeeAmount)).toString(); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + quoteHash: `0x${"c".repeat(64)}`, + feeAmount, + wallet: agent.agentWallet, + }), { status: 200 })); + + const result = await dispatchTestnetActivation({ action: "quote", slug: agent.slug }); + expect(result.quote?.feeAmount, `seed=${FUZZ_SEED ^ 0xfee} case=${index}`).toBe(feeAmount); + } + }); + + it("property: rejects over-cap and oversized fee strings", async () => { + const random = seededRandom(FUZZ_SEED ^ 0xbadfee); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + for (let index = 0; index < FUZZ_CASES; index++) { + const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; + const feeAmount = index % 2 === 0 + ? (BigInt(agent.maxFeeAmount) + 1n + BigInt(index)).toString() + : randomString(random, "123456789", 79 + (index % 82)); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + quoteHash: `0x${"d".repeat(64)}`, + feeAmount, + wallet: agent.agentWallet, + }), { status: 200 })); + + await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })) + .rejects.toThrow(); + } + }); + + it("property: rejects malformed quote hashes returned by a seller", async () => { + const random = seededRandom(FUZZ_SEED ^ 0x4a5a); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + for (let index = 0; index < FUZZ_CASES; index++) { + const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; + const quoteHash = index % 3 === 0 + ? `0x${randomString(random, "0123456789abcdef", 63)}` + : index % 3 === 1 + ? `0x${randomString(random, "0123456789abcdef", 65)}` + : randomString(random, "0123456789abcdef", 64); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + quoteHash, + feeAmount: "1", + wallet: agent.agentWallet, + }), { status: 200 })); + + await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })) + .rejects.toThrow(); + } + }); + + it("property: every funded job resumes at delivery instead of funding", () => { + const random = seededRandom(FUZZ_SEED ^ 0xf00d); + + for (let index = 0; index < FUZZ_CASES; index++) { + const jobId = randomPositiveBigInt(random, 2n ** 255n).toString(); + expect( + getMarketplaceActivationAction(true, jobId), + `seed=${FUZZ_SEED ^ 0xf00d} case=${index} jobId=${jobId}`, + ).toBe("notify"); + } + }); +}); diff --git a/src/__tests__/bnb-marketplace-registry.test.ts b/src/__tests__/bnb-marketplace-registry.test.ts new file mode 100644 index 0000000..8689779 --- /dev/null +++ b/src/__tests__/bnb-marketplace-registry.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BNB_MARKETPLACE_AGENTS } from "@/lib/bnb-marketplace"; +import { loadBnbMarketplaceAgent } from "@/lib/bnb-marketplace-registry"; + +afterEach(() => vi.restoreAllMocks()); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +describe("BNB marketplace registry loader", () => { + it("marks an exact registry identity and agent card live", async () => { + const agent = BNB_MARKETPLACE_AGENTS[0]; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ + success: true, + data: { + name: agent.name, + token_id: String(agent.agentId), + chain_id: 97, + contract_address: agent.registryAddress, + agent_wallet: agent.agentWallet, + description: "Live registry description", + created_tx_hash: `0x${"1".repeat(64)}`, + updated_at: "2026-08-23T00:00:00Z", + }, + })) + .mockResolvedValueOnce(jsonResponse({ name: agent.name, url: agent.sellerApiBaseUrl.replace("/api/seller", "/seller") })); + + const loaded = await loadBnbMarketplaceAgent(agent); + expect(loaded.registryStatus).toBe("live"); + expect(loaded.endpointStatus).toBe("live"); + expect(loaded.summary).toBe("Live registry description"); + }); + + it("fails closed when the registry wallet does not match", async () => { + const agent = BNB_MARKETPLACE_AGENTS[0]; + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(jsonResponse({ + success: true, + data: { + name: agent.name, + token_id: String(agent.agentId), + chain_id: 97, + contract_address: agent.registryAddress, + agent_wallet: "0x0000000000000000000000000000000000000001", + description: agent.summary, + created_tx_hash: `0x${"2".repeat(64)}`, + updated_at: null, + }, + })) + .mockResolvedValueOnce(jsonResponse({ name: agent.name, url: agent.sellerApiBaseUrl.replace("/api/seller", "/seller") })); + + const loaded = await loadBnbMarketplaceAgent(agent); + expect(loaded.registryStatus).toBe("unavailable"); + expect(loaded.endpointStatus).toBe("unavailable"); + expect(loaded.createdTxHash).toBeNull(); + }); + + it("fails closed and cancels an oversized streamed registry response", async () => { + const agent = BNB_MARKETPLACE_AGENTS[0]; + const encoder = new TextEncoder(); + const chunks = ["x".repeat(60_000), "y".repeat(40_001), "z".repeat(60_000)]; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk === undefined) controller.close(); + else controller.enqueue(encoder.encode(chunk)); + }, + cancel() { + cancelled = true; + }, + }); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(body, { status: 200, headers: { "content-length": "1" } })) + .mockResolvedValueOnce(jsonResponse({ name: agent.name, url: agent.sellerApiBaseUrl.replace("/api/seller", "/seller") })); + + const loaded = await loadBnbMarketplaceAgent(agent); + expect(loaded.registryStatus).toBe("unavailable"); + expect(loaded.endpointStatus).toBe("unavailable"); + expect(cancelled).toBe(true); + }); +}); diff --git a/src/__tests__/bnb-marketplace.test.ts b/src/__tests__/bnb-marketplace.test.ts index 612774e..9b2c636 100644 --- a/src/__tests__/bnb-marketplace.test.ts +++ b/src/__tests__/bnb-marketplace.test.ts @@ -6,7 +6,7 @@ import { getBnbMarketplaceAgent, } from "@/lib/bnb-marketplace"; -describe("BNB agent marketplace fixtures", () => { +describe("BNB agent marketplace", () => { it("keeps all four official categories first-class", () => { expect(BNB_AGENT_CATEGORIES.map((category) => category.name)).toEqual([ "Rebalancing", @@ -17,18 +17,29 @@ describe("BNB agent marketplace fixtures", () => { expect(new Set(BNB_AGENT_CATEGORIES.map((category) => category.slug)).size).toBe(4); }); - it("provides a bounded demo agent for every category", () => { + it("provides one bounded BSC Testnet agent for every category", () => { for (const category of BNB_AGENT_CATEGORIES) { const agents = BNB_MARKETPLACE_AGENTS.filter((agent) => agent.category === category.slug); expect(agents).toHaveLength(1); expect(agents[0].cannotDo).toBeTruthy(); - expect(agents[0].dailyLimitUsd).toBeGreaterThan(0); + expect(agents[0].chainId).toBe(97); + expect(agents[0].agentId).toBeGreaterThan(0); + expect(agents[0].agentCardUrl).toMatch(/^https:\/\//); + expect(BigInt(agents[0].maxFeeAmount)).toBeGreaterThan(0n); } }); it("resolves category and agent routes by slug", () => { expect(getBnbAgentCategory("grid-trading")?.name).toBe("Grid Trading"); - expect(getBnbMarketplaceAgent("healthguard")?.name).toBe("HealthGuard"); + expect(getBnbMarketplaceAgent("studio-desk-risk")?.name).toBe("Studio Desk Risk Protection"); expect(getBnbMarketplaceAgent("missing")).toBeUndefined(); }); + + it("does not include a mainnet or caller-controlled activation target", () => { + for (const agent of BNB_MARKETPLACE_AGENTS) { + expect(agent.chainId).toBe(97); + expect(agent.sellerApiBaseUrl).toMatch(/^https:\/\/13\.53\.65\.58\.sslip\.io\/api\/seller\/\d+$/); + expect(agent.registryStatus).toBe("checking"); + } + }); }); diff --git a/src/__tests__/bounded-response.test.ts b/src/__tests__/bounded-response.test.ts new file mode 100644 index 0000000..cedbe97 --- /dev/null +++ b/src/__tests__/bounded-response.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { readBoundedResponseText, readCreatorResponseText } from "@/lib/creator-response"; + +describe("bounded response reader", () => { + it("preserves a legitimate streamed response under the byte limit", async () => { + const response = new Response(JSON.stringify({ status: "accepted", message: "測試" })); + + await expect(readBoundedResponseText(response, 100, "too large")).resolves.toEqual({ + ok: true, + text: JSON.stringify({ status: "accepted", message: "測試" }), + }); + }); + + it("rejects a declared oversized response without consuming the body", async () => { + const response = new Response("{}", { headers: { "content-length": "101" } }); + + await expect(readBoundedResponseText(response, 100, "too large")).resolves.toEqual({ + ok: false, + text: "", + error: "too large", + }); + expect(response.bodyUsed).toBe(false); + }); + + it("cancels a streamed response that exceeds the limit despite a misleading header", async () => { + const encoder = new TextEncoder(); + const chunks = ["12345", "67890", "should-not-be-buffered"]; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk === undefined) controller.close(); + else controller.enqueue(encoder.encode(chunk)); + }, + cancel() { + cancelled = true; + }, + }); + const response = new Response(body, { headers: { "content-length": "1" } }); + + await expect(readBoundedResponseText(response, 9, "too large")).resolves.toEqual({ + ok: false, + text: "", + error: "too large", + }); + expect(cancelled).toBe(true); + }); + + it("keeps the creator response compatibility wrapper", async () => { + const response = new Response("oversized", { headers: { "content-length": "10" } }); + + await expect(readCreatorResponseText(response, 9)).resolves.toEqual({ + ok: false, + text: "", + error: "Creator response exceeds 9 bytes", + }); + }); +}); diff --git a/src/app/api/bnb-marketplace/activate/route.ts b/src/app/api/bnb-marketplace/activate/route.ts new file mode 100644 index 0000000..0e6447a --- /dev/null +++ b/src/app/api/bnb-marketplace/activate/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { activationRequestSchema, dispatchTestnetActivation } from "@/lib/bnb-marketplace-activation"; + +export async function POST(request: Request) { + let raw: unknown; + try { raw = await request.json(); } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const parsed = activationRequestSchema.safeParse(raw); + if (!parsed.success) return NextResponse.json({ error: "Invalid activation request" }, { status: 400 }); + + try { + return NextResponse.json(await dispatchTestnetActivation(parsed.data)); + } catch (error) { + const message = error instanceof Error ? error.message : "Activation failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/bnb-marketplace/agents/route.ts b/src/app/api/bnb-marketplace/agents/route.ts new file mode 100644 index 0000000..b326479 --- /dev/null +++ b/src/app/api/bnb-marketplace/agents/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { loadBnbMarketplaceAgents } from "@/lib/bnb-marketplace-registry"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const agents = await loadBnbMarketplaceAgents(); + const complete = agents.every((agent) => agent.registryStatus === "live" && agent.endpointStatus === "live"); + return NextResponse.json( + { agents, network: { name: "BNB Smart Chain Testnet", chainId: 97 }, complete, checkedAt: new Date().toISOString() }, + { status: complete ? 200 : 503 }, + ); +} diff --git a/src/app/market/[slug]/setup/page.tsx b/src/app/market/[slug]/setup/page.tsx index a08f124..1790758 100644 --- a/src/app/market/[slug]/setup/page.tsx +++ b/src/app/market/[slug]/setup/page.tsx @@ -16,8 +16,8 @@ export default async function PermissionSetupPage({ params }: { params: Promise< diff --git a/src/components/market/AgentDetail.tsx b/src/components/market/AgentDetail.tsx index b3a54f6..c7fe1e1 100644 --- a/src/components/market/AgentDetail.tsx +++ b/src/components/market/AgentDetail.tsx @@ -1,63 +1,64 @@ +"use client"; + import Link from "next/link"; import { getBnbAgentCategory, type BnbMarketplaceAgent } from "@/lib/bnb-marketplace"; +import { useBnbMarketplaceAgents } from "@/hooks/useBnbMarketplaceAgents"; import styles from "./Marketplace.module.css"; -function money(value: number) { - return `$${value.toLocaleString()}`; -} - export function AgentDetail({ agent }: { agent: BnbMarketplaceAgent }) { - const category = getBnbAgentCategory(agent.category); + const { agents } = useBnbMarketplaceAgents(); + const liveAgent = agents.find((item) => item.slug === agent.slug) ?? agent; + const category = getBnbAgentCategory(liveAgent.category); return ( <>
- ← Back to market + ← Back to market
- AGENT DETAIL / DEMO - REGISTRY NOT CONNECTED + AGENT DETAIL / BSC TESTNET + {liveAgent.registryStatus === "live" ? "ONCHAIN IDENTITY" : "REGISTRY UNAVAILABLE"}
- {agent.initials} + {liveAgent.initials}
{category?.name} -

{agent.name}

-

{agent.protocol}

+

{liveAgent.name}

+

{liveAgent.protocol}

-

{agent.summary}

+

{liveAgent.summary}

-
What it does{agent.task}

Only inside the task you choose.

-
Example limit{money(agent.dailyLimitUsd)} / day

You choose the final amount.

-
EvidenceDemo fixture

Live registry evidence is still required.

+
What it does{liveAgent.task}

One bounded job on chain 97.

+
Quoted fee{liveAgent.feeLabel}

Read the live quote before funding.

+
IdentityERC-8004 #{liveAgent.agentId}

{liveAgent.endpointStatus === "live" ? "Registry and endpoint responded." : "Activation is disabled until checks pass."}

What you control

- {agent.controls.map((control, index) => ( + {liveAgent.controls.map((control, index) => (
{String(index + 1).padStart(2, "0")}{control}Owner controlled
))} -
—{agent.cannotDo}Blocked
+
—{liveAgent.cannotDo}Blocked
); diff --git a/src/components/market/MarketDirectory.tsx b/src/components/market/MarketDirectory.tsx index 0296b2c..6e96659 100644 --- a/src/components/market/MarketDirectory.tsx +++ b/src/components/market/MarketDirectory.tsx @@ -5,48 +5,45 @@ import Link from "next/link"; import { Search } from "lucide-react"; import { BNB_AGENT_CATEGORIES, - BNB_MARKETPLACE_AGENTS, getBnbAgentCategory, type BnbAgentCategorySlug, } from "@/lib/bnb-marketplace"; +import { useBnbMarketplaceAgents } from "@/hooks/useBnbMarketplaceAgents"; import styles from "./Marketplace.module.css"; type MarketDirectoryProps = { initialCategory?: string; }; -function money(value: number) { - return `$${value.toLocaleString()}`; -} - export function MarketDirectory({ initialCategory }: MarketDirectoryProps) { + const { agents, complete } = useBnbMarketplaceAgents(); const validInitialCategory = getBnbAgentCategory(initialCategory ?? "")?.slug; const [category, setCategory] = useState(validInitialCategory ?? "all"); const [query, setQuery] = useState(""); const visibleAgents = useMemo(() => { const normalized = query.trim().toLowerCase(); - return BNB_MARKETPLACE_AGENTS.filter((agent) => { + return agents.filter((agent) => { const agentCategory = getBnbAgentCategory(agent.category); const inCategory = category === "all" || agent.category === category; const matchesQuery = !normalized || [agent.name, agent.summary, agent.task, agent.protocol, agentCategory?.name ?? ""] .some((value) => value.toLowerCase().includes(normalized)); return inCategory && matchesQuery; }); - }, [category, query]); + }, [agents, category, query]); const [selectedSlug, setSelectedSlug] = useState(validInitialCategory - ? BNB_MARKETPLACE_AGENTS.find((agent) => agent.category === validInitialCategory)?.slug ?? BNB_MARKETPLACE_AGENTS[0].slug - : BNB_MARKETPLACE_AGENTS[0].slug); + ? agents.find((agent) => agent.category === validInitialCategory)?.slug ?? agents[0].slug + : agents[0].slug); - const selected = visibleAgents.find((agent) => agent.slug === selectedSlug) ?? visibleAgents[0] ?? BNB_MARKETPLACE_AGENTS[0]; + const selected = visibleAgents.find((agent) => agent.slug === selectedSlug) ?? visibleAgents[0] ?? agents[0]; const selectedCategory = getBnbAgentCategory(selected.category); function chooseCategory(nextCategory: BnbAgentCategorySlug | "all") { setCategory(nextCategory); const firstAgent = nextCategory === "all" - ? BNB_MARKETPLACE_AGENTS[0] - : BNB_MARKETPLACE_AGENTS.find((agent) => agent.category === nextCategory); + ? agents[0] + : agents.find((agent) => agent.category === nextCategory); if (firstAgent) setSelectedSlug(firstAgent.slug); } @@ -54,8 +51,8 @@ export function MarketDirectory({ initialCategory }: MarketDirectoryProps) { <>
- SELECTED AGENT / DEMO - ● CONCEPT DATA + SELECTED AGENT / BSC TESTNET + ● {selected.registryStatus === "live" ? "ONCHAIN IDENTITY" : "CHECKING REGISTRY"}
@@ -68,15 +65,15 @@ export function MarketDirectory({ initialCategory }: MarketDirectoryProps) {

{selected.summary}

-
+
- EXAMPLE SETTINGS - CHANGE ANYTIME + TESTNET JOB + CHAIN 97 ONLY
Can do{selected.task}
-
Daily limit{money(selected.dailyLimitUsd)}
-
Ends after{selected.defaultDurationDays} days
+
Service fee{selected.feeLabel}
+
IdentityERC-8004 #{selected.agentId}

Cannot: {selected.cannotDo}

@@ -86,14 +83,14 @@ export function MarketDirectory({ initialCategory }: MarketDirectoryProps) { Categories
- You keep the keys. - Live permission controls are not connected in this demo. + You approve every testnet transaction. + No mainnet transaction or server-held key.
- AGENT MARKET / DEMO FIXTURES + AGENT MARKET / {complete ? "LIVE TESTNET DATA" : "REGISTRY CHECK"}

Pick one job
to automate.

{visibleAgents.map((agent) => ( @@ -146,14 +143,14 @@ export function MarketDirectory({ initialCategory }: MarketDirectoryProps) { > {agent.initials}{agent.name} {agent.task} - {agent.risk} + {agent.endpointStatus === "live" ? "Live" : "Checking"} View → ))} {visibleAgents.length === 0 && (
- No demo agents found + No testnet agents found

Clear the search or choose another category.

)} diff --git a/src/components/market/Marketplace.module.css b/src/components/market/Marketplace.module.css index cde8e01..4df1576 100644 --- a/src/components/market/Marketplace.module.css +++ b/src/components/market/Marketplace.module.css @@ -977,6 +977,38 @@ border-left-color: #0ecb81; } +.errorNote { + margin: 0 0 14px; + padding: 12px; + border-left: 3px solid #f6465d; + background: var(--market-base); + color: #ff8796; + font-size: 9px; + line-height: 1.5; +} + +.receiptData { + max-height: 210px; + overflow: auto; + margin: 0 0 14px; + padding: 12px; + border: 1px solid var(--market-line); + background: var(--market-base); + color: #aeb6c2; + font-family: var(--font-mono), ui-monospace, Menlo, monospace; + font-size: 8px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.primaryButton:disabled { + border-color: var(--market-line-strong); + background: var(--market-line); + color: var(--market-muted); + cursor: not-allowed; +} + @media (prefers-reduced-motion: reduce) { .connectButton, .primaryButton, diff --git a/src/components/market/MarketplaceHeader.tsx b/src/components/market/MarketplaceHeader.tsx index 00be7a8..b6e55f6 100644 --- a/src/components/market/MarketplaceHeader.tsx +++ b/src/components/market/MarketplaceHeader.tsx @@ -25,7 +25,7 @@ export function MarketplaceHeader() {
- BNB SMART CHAIN + BSC TESTNET · 97 {ready && authenticated ? ( - ); - })} - - -
- - - + ACTIVATION / BSC TESTNET + CHAIN ID 97
+

Hire one testnet job.

+

The seller provides a signed quote. Your wallet then approves five exact testnet calls before delivery begins.

+ +
+
FIXED JOBEXPIRES IN 1 HOUR
+
+
Task{liveAgent.task}
+
Listed fee{liveAgent.feeLabel}
+
Identity#{liveAgent.agentId}
+
+

Cannot: {liveAgent.cannotDo}

+
+ +
+

Activation progress

+ {steps.map((step, index) => ( +
+ {String(index + 1).padStart(2, "0")} + {step.label} + {step.hash ? ( + {step.state} ↗ + ) : {step.state}} +
+ ))} +
); diff --git a/src/hooks/useBnbMarketplaceAgents.ts b/src/hooks/useBnbMarketplaceAgents.ts new file mode 100644 index 0000000..20b2007 --- /dev/null +++ b/src/hooks/useBnbMarketplaceAgents.ts @@ -0,0 +1,24 @@ +"use client"; + +import useSWR from "swr"; +import { BNB_MARKETPLACE_AGENTS, type BnbMarketplaceAgent } from "@/lib/bnb-marketplace"; + +type AgentsResponse = { agents: BnbMarketplaceAgent[]; complete: boolean; checkedAt: string }; + +async function fetcher(url: string): Promise { + const response = await fetch(url); + const body = await response.json(); + if (!response.ok && !Array.isArray(body?.agents)) throw new Error("Registry unavailable"); + return body; +} + +export function useBnbMarketplaceAgents() { + const { data, error, isLoading } = useSWR("/api/bnb-marketplace/agents", fetcher, { + refreshInterval: 60_000, + revalidateOnFocus: false, + }); + return { + agents: data?.agents ?? [...BNB_MARKETPLACE_AGENTS], complete: data?.complete ?? false, + checkedAt: data?.checkedAt ?? null, isLoading, error, + }; +} diff --git a/src/lib/bnb-marketplace-activation-flow.ts b/src/lib/bnb-marketplace-activation-flow.ts new file mode 100644 index 0000000..ee7e8c4 --- /dev/null +++ b/src/lib/bnb-marketplace-activation-flow.ts @@ -0,0 +1,9 @@ +export type MarketplaceActivationAction = "quote" | "fund" | "notify"; + +export function getMarketplaceActivationAction( + hasQuote: boolean, + fundedJobId: string | null, +): MarketplaceActivationAction { + if (!hasQuote) return "quote"; + return fundedJobId ? "notify" : "fund"; +} diff --git a/src/lib/bnb-marketplace-activation.ts b/src/lib/bnb-marketplace-activation.ts new file mode 100644 index 0000000..8d1527a --- /dev/null +++ b/src/lib/bnb-marketplace-activation.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { getBnbMarketplaceAgent } from "@/lib/bnb-marketplace"; +import { readBoundedResponseText } from "@/lib/creator-response"; + +const quoteHashSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); +export const activationRequestSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("quote"), slug: z.string().min(1).max(80) }).strict(), + z.object({ + action: z.literal("notify_funded"), slug: z.string().min(1).max(80), + jobId: z.string().regex(/^\d{1,78}$/), quoteHash: quoteHashSchema, + }).strict(), +]); + +const quoteSchema = z.object({ + quoteHash: quoteHashSchema, + feeAmount: z.string().min(1).max(78).regex(/^\d+$/), + wallet: z.string().regex(/^0x[a-fA-F0-9]{40}$/), +}).passthrough(); + +function sameAddress(a: string, b: string) { + return a.toLowerCase() === b.toLowerCase(); +} + +async function postJson(url: string, body: unknown) { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(body), + redirect: "error", + signal: AbortSignal.timeout(12_000), + cache: "no-store", + }); + const read = await readBoundedResponseText(response, 100_000, "Agent response exceeded 100 KB"); + if (!read.ok) throw new Error(read.error); + let parsed: unknown; + try { parsed = JSON.parse(read.text); } catch { throw new Error("Agent returned invalid JSON"); } + if (!response.ok) throw new Error(`Agent request failed (${response.status})`); + return parsed; +} + +export async function dispatchTestnetActivation(input: z.infer) { + const agent = getBnbMarketplaceAgent(input.slug); + if (!agent || agent.chainId !== 97) throw new Error("Unknown BSC Testnet agent"); + + if (input.action === "quote") { + const quote = quoteSchema.parse(await postJson(`${agent.sellerApiBaseUrl}/quote`, {})); + if (!sameAddress(quote.wallet, agent.agentWallet)) throw new Error("Quote wallet does not match registry identity"); + const feeAmount = BigInt(quote.feeAmount); + if (feeAmount <= 0n || feeAmount > BigInt(agent.maxFeeAmount)) throw new Error("Quote exceeds the curated testnet fee cap"); + return { action: input.action, agentId: agent.agentId, chainId: agent.chainId, quote }; + } + + const result = await postJson(`${agent.sellerApiBaseUrl}/notify_funded`, { + jobId: input.jobId, quoteHash: input.quoteHash, dryRun: false, amount: "1", + }); + if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Agent returned an invalid receipt"); + return { action: input.action, agentId: agent.agentId, chainId: agent.chainId, result }; +} diff --git a/src/lib/bnb-marketplace-chain.ts b/src/lib/bnb-marketplace-chain.ts new file mode 100644 index 0000000..1f36d0d --- /dev/null +++ b/src/lib/bnb-marketplace-chain.ts @@ -0,0 +1,19 @@ +import { parseAbi } from "viem"; +import { bscTestnet } from "viem/chains"; + +export const BNB_MARKETPLACE_CHAIN = bscTestnet; +export const BNB_MARKETPLACE_CONTRACTS = { + commerce: "0xa206c0517B6371C6638CD9e4a42Cc9f02A33B0DE", + router: "0xD7d36D66d2F1B608A0F943f722D27e3744f66F25", + policy: "0xd6a4217588f6b1f5657a92a3e94e6422ad771cea", + paymentToken: "0xc70B8741B8B07A6d61E54fd4B20f22Fa648E5565", +} as const; + +export const bnbMarketplaceCommerceAbi = parseAbi([ + "function createJob(address provider,address evaluator,uint256 expiredAt,string description,address hook) returns(uint256)", + "function setBudget(uint256 jobId,uint256 amount,bytes optParams)", + "function fund(uint256 jobId,uint256 expectedBudget,bytes optParams)", + "event JobCreated(uint256 indexed jobId,address indexed client,address indexed provider,address evaluator,uint256 expiredAt,address hook)", +]); +export const bnbMarketplaceRouterAbi = parseAbi(["function registerJob(uint256 jobId,address policy)"]); +export const bnbMarketplaceTokenAbi = parseAbi(["function approve(address spender,uint256 amount) returns(bool)"]); diff --git a/src/lib/bnb-marketplace-registry.ts b/src/lib/bnb-marketplace-registry.ts new file mode 100644 index 0000000..577f6fd --- /dev/null +++ b/src/lib/bnb-marketplace-registry.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { BNB_MARKETPLACE_AGENTS, type BnbMarketplaceAgent } from "@/lib/bnb-marketplace"; +import { readBoundedResponseText } from "@/lib/creator-response"; + +const addressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/); +const hashSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); +const registryResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + name: z.string().min(1), token_id: z.string(), chain_id: z.number(), contract_address: addressSchema, + agent_wallet: addressSchema, description: z.string().min(1), created_tx_hash: hashSchema, updated_at: z.string().nullable(), + }), +}); +const agentCardSchema = z.object({ name: z.string().min(1), url: z.string().url() }); + +function sameAddress(a: string, b: string) { + return a.toLowerCase() === b.toLowerCase(); +} + +async function checkedJson(url: string, init?: RequestInit) { + const response = await fetch(url, { ...init, redirect: "error", signal: AbortSignal.timeout(8_000), next: { revalidate: 60 } }); + if (!response.ok) throw new Error(`Upstream returned ${response.status}`); + const read = await readBoundedResponseText(response, 100_000, "Upstream response too large"); + if (!read.ok) throw new Error(read.error); + return JSON.parse(read.text) as unknown; +} + +export async function loadBnbMarketplaceAgent(agent: BnbMarketplaceAgent): Promise { + try { + const headers: HeadersInit = {}; + const apiKey = process.env.API_8004SCAN_KEY?.trim(); + if (apiKey) headers["X-API-Key"] = apiKey; + const [registryRaw, cardRaw] = await Promise.all([ + checkedJson(`https://8004scan.io/api/v1/public/agents/${agent.chainId}/${agent.agentId}`, { headers }), + checkedJson(agent.agentCardUrl), + ]); + const registry = registryResponseSchema.parse(registryRaw).data; + const card = agentCardSchema.parse(cardRaw); + if ( + registry.chain_id !== agent.chainId || registry.token_id !== String(agent.agentId) || + !sameAddress(registry.contract_address, agent.registryAddress) || !sameAddress(registry.agent_wallet, agent.agentWallet) || + card.name !== agent.name || new URL(card.url).origin !== new URL(agent.agentCardUrl).origin + ) throw new Error("Registry identity mismatch"); + + return { + ...agent, name: registry.name, summary: registry.description, registryStatus: "live", endpointStatus: "live", + registryUpdatedAt: registry.updated_at, createdTxHash: registry.created_tx_hash as `0x${string}`, + }; + } catch { + return { ...agent, registryStatus: "unavailable", endpointStatus: "unavailable", registryUpdatedAt: null, createdTxHash: null }; + } +} + +export async function loadBnbMarketplaceAgents() { + return Promise.all(BNB_MARKETPLACE_AGENTS.map(loadBnbMarketplaceAgent)); +} diff --git a/src/lib/bnb-marketplace.ts b/src/lib/bnb-marketplace.ts index 161d900..5a117ad 100644 --- a/src/lib/bnb-marketplace.ts +++ b/src/lib/bnb-marketplace.ts @@ -1,31 +1,16 @@ +export const BNB_TESTNET_CHAIN_ID = 97 as const; +export const BNB_TESTNET_REGISTRY = "0x8004A818BFB912233c491871b3d84c89A494BD9e" as const; +export const BNB_TESTNET_EXPLORER = "https://testnet.bscscan.com" as const; + export const BNB_AGENT_CATEGORIES = [ - { - slug: "rebalancing", - name: "Rebalancing", - action: "Manage liquidity", - description: "Keep a liquidity position inside a price range you choose.", - }, - { - slug: "grid-trading", - name: "Grid Trading", - action: "Run grid trades", - description: "Place buy and sell orders inside a price band you choose.", - }, - { - slug: "yield-optimisation", - name: "Yield Optimisation", - action: "Improve yield", - description: "Route funds toward stronger available returns.", - }, - { - slug: "health-factor-monitoring", - name: "Health Factor Monitoring", - action: "Protect borrowing", - description: "Watch a loan and respond before liquidation risk rises.", - }, + { slug: "rebalancing", name: "Rebalancing", action: "Manage liquidity", description: "Keep a PancakeSwap V3 position inside explicit bounds." }, + { slug: "grid-trading", name: "Grid Trading", action: "Run grid trades", description: "Run one bounded grid step on a fixed PancakeSwap pair." }, + { slug: "yield-optimisation", name: "Yield Optimisation", action: "Improve yield", description: "Allocate a bounded amount to one verified Venus venue." }, + { slug: "health-factor-monitoring", name: "Health Factor Monitoring", action: "Protect borrowing", description: "Read Venus liquidity and shortfall before a bounded repayment." }, ] as const; export type BnbAgentCategorySlug = (typeof BNB_AGENT_CATEGORIES)[number]["slug"]; +export type RegistryStatus = "checking" | "live" | "unavailable"; export type BnbMarketplaceAgent = { slug: string; @@ -37,71 +22,67 @@ export type BnbMarketplaceAgent = { task: string; cannotDo: string; risk: "Low" | "Medium"; - dailyLimitUsd: number; - defaultDurationDays: number; controls: readonly string[]; + chainId: typeof BNB_TESTNET_CHAIN_ID; + agentId: number; + registryAddress: typeof BNB_TESTNET_REGISTRY; + agentWallet: `0x${string}`; + agentCardUrl: string; + sellerApiBaseUrl: string; + selector: string; + feeLabel: string; + maxFeeAmount: string; + registryStatus: RegistryStatus; + endpointStatus: RegistryStatus; + registryUpdatedAt: string | null; + createdTxHash: `0x${string}` | null; }; -/** - * UI fixtures only. These records are deliberately not presented as live, - * verified, or registered BSC agents until a real registry source is wired in. - */ +/** Curated BSC Testnet identities. Runtime checks control activation. */ export const BNB_MARKETPLACE_AGENTS: readonly BnbMarketplaceAgent[] = [ { - slug: "rangekeeper", - initials: "RK", - name: "RangeKeeper", - category: "rebalancing", - protocol: "PancakeSwap v3", - summary: "Keeps your liquidity position inside your chosen price range.", - task: "Manage one liquidity position", - cannotDo: "Swap tokens or use another protocol.", - risk: "Medium", - dailyLimitUsd: 5000, - defaultDurationDays: 30, - controls: ["Rebalance the selected position", "Pause automatically at the daily limit", "Stop when permission expires"], + slug: "studio-desk-rebalancer", initials: "RB", name: "Studio Desk Rebalancer", category: "rebalancing", protocol: "PancakeSwap V3", + summary: "Proposes a bounded LP range update using the current pool tick and position state.", task: "Prepare one bounded range rebalance", + cannotDo: "Use another position, pair, policy, or chain.", risk: "Medium", + controls: ["Read the current pool tick", "Read the selected position", "Return typed rebalance bounds"], + chainId: BNB_TESTNET_CHAIN_ID, agentId: 1880, registryAddress: BNB_TESTNET_REGISTRY, + agentWallet: "0x76849Ba8246Ea53FB417c295028c2AA9C6af7eD7", + agentCardUrl: "https://13.53.65.58.sslip.io/seller/1880/.well-known/agent-card.json", + sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1880", selector: "rebalancePancakeV3", feeLabel: "0.001 $U", maxFeeAmount: "1000000000000000", + registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, { - slug: "gridpilot", - initials: "GP", - name: "GridPilot", - category: "grid-trading", - protocol: "PancakeSwap", - summary: "Places grid orders inside the price band and budget you approve.", - task: "Manage one grid strategy", - cannotDo: "Trade outside the selected pair or price band.", - risk: "Medium", - dailyLimitUsd: 2500, - defaultDurationDays: 14, - controls: ["Place bounded grid orders", "Cancel open grid orders", "Stop when permission expires"], + slug: "studio-desk-grid", initials: "GD", name: "Studio Desk Grid", category: "grid-trading", protocol: "PancakeSwap", + summary: "Runs one grid step with a fixed pair, inventory ceiling, cooldown, and minimum output.", task: "Run one bounded grid step", + cannotDo: "Change the pair or exceed the approved inventory ceiling.", risk: "Medium", + controls: ["Use the fixed USDT/WBNB pair", "Respect the inventory ceiling", "Enforce cooldown and minimum output"], + chainId: BNB_TESTNET_CHAIN_ID, agentId: 1881, registryAddress: BNB_TESTNET_REGISTRY, + agentWallet: "0x2eD7Dc63681D912948C215DCdE4c4FCe9A3F19F8", + agentCardUrl: "https://13.53.65.58.sslip.io/seller/1881/.well-known/agent-card.json", + sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1881", selector: "gridStep", feeLabel: "0.0012 $U", maxFeeAmount: "1200000000000000", + registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, { - slug: "yieldscout", - initials: "YS", - name: "YieldScout", - category: "yield-optimisation", - protocol: "BNB Chain DeFi", - summary: "Compares supported pools and routes funds within your approved set.", - task: "Route funds across approved pools", - cannotDo: "Use an unapproved protocol or exceed the daily limit.", - risk: "Medium", - dailyLimitUsd: 7500, - defaultDurationDays: 30, - controls: ["Compare approved yield sources", "Move funds within the allowlist", "Stop when permission expires"], + slug: "studio-desk-yield", initials: "YD", name: "Studio Desk Bounded Yield", category: "yield-optimisation", protocol: "Venus Protocol", + summary: "Allocates a bounded amount to one allowlisted Venus testnet venue.", task: "Allocate to one verified yield venue", + cannotDo: "Use another venue or exceed the quoted amount.", risk: "Medium", + controls: ["Use one verified Venus venue", "Bound the deposit amount", "Bound the withdrawal amount"], + chainId: BNB_TESTNET_CHAIN_ID, agentId: 1882, registryAddress: BNB_TESTNET_REGISTRY, + agentWallet: "0x1D7118d1A4A87411FB275046Cb156eCe8e7BD190", + agentCardUrl: "https://13.53.65.58.sslip.io/seller/1882/.well-known/agent-card.json", + sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1882", selector: "allocateYield", feeLabel: "0.0009 $U", maxFeeAmount: "900000000000000", + registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, { - slug: "healthguard", - initials: "HG", - name: "HealthGuard", - category: "health-factor-monitoring", - protocol: "Venus Protocol", - summary: "Watches a lending position and acts before its health factor becomes unsafe.", - task: "Protect one lending position", - cannotDo: "Borrow more funds or move collateral elsewhere.", - risk: "Low", - dailyLimitUsd: 1000, - defaultDurationDays: 30, - controls: ["Monitor the selected position", "Repay within the approved limit", "Stop when permission expires"], + slug: "studio-desk-risk", initials: "HF", name: "Studio Desk Risk Protection", category: "health-factor-monitoring", protocol: "Venus Protocol", + summary: "Reads Venus liquidity and shortfall, then proposes one bounded repayment.", task: "Protect one Venus borrowing position", + cannotDo: "Borrow more, change the borrower, or exceed the repayment cap.", risk: "Low", + controls: ["Read account liquidity", "Read account shortfall", "Bound repayBorrowBehalf"], + chainId: BNB_TESTNET_CHAIN_ID, agentId: 1883, registryAddress: BNB_TESTNET_REGISTRY, + agentWallet: "0x185BF63291A90eaf9D1b119Cc2c4b70df058BBa0", + agentCardUrl: "https://13.53.65.58.sslip.io/seller/1883/.well-known/agent-card.json", + sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1883", selector: "protectRepayVenus", feeLabel: "0.0011 $U", maxFeeAmount: "1100000000000000", + registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, ] as const; @@ -109,6 +90,10 @@ export function getBnbMarketplaceAgent(slug: string) { return BNB_MARKETPLACE_AGENTS.find((agent) => agent.slug === slug); } +export function getBnbMarketplaceAgentById(agentId: number) { + return BNB_MARKETPLACE_AGENTS.find((agent) => agent.agentId === agentId); +} + export function getBnbAgentCategory(slug: string) { return BNB_AGENT_CATEGORIES.find((category) => category.slug === slug); } diff --git a/src/lib/creator-response.ts b/src/lib/creator-response.ts index 37e6748..d581d6f 100644 --- a/src/lib/creator-response.ts +++ b/src/lib/creator-response.ts @@ -1,32 +1,25 @@ export const CREATOR_RESPONSE_MAX_BYTES = 1_048_576; -export type CreatorResponseRead = +export type BoundedResponseRead = | { ok: true; text: string } | { ok: false; text: ''; error: string }; -export async function readCreatorResponseText( +export async function readBoundedResponseText( response: Response, - maxBytes = CREATOR_RESPONSE_MAX_BYTES, -): Promise { + maxBytes: number, + error: string, +): Promise { const contentLength = Number(response.headers.get('content-length') ?? 0); if (contentLength > maxBytes) { - return { - ok: false, - text: '', - error: `Creator response exceeds ${maxBytes} bytes`, - }; + return { ok: false, text: '', error }; } if (!response.body) { const text = await response.text(); - if (Buffer.byteLength(text, 'utf8') <= maxBytes) { + if (new TextEncoder().encode(text).byteLength <= maxBytes) { return { ok: true, text }; } - return { - ok: false, - text: '', - error: `Creator response exceeds ${maxBytes} bytes`, - }; + return { ok: false, text: '', error }; } const reader = response.body.getReader(); @@ -40,12 +33,8 @@ export async function readCreatorResponseText( if (done) break; bytesRead += value.byteLength; if (bytesRead > maxBytes) { - await reader.cancel(); - return { - ok: false, - text: '', - error: `Creator response exceeds ${maxBytes} bytes`, - }; + await reader.cancel().catch(() => undefined); + return { ok: false, text: '', error }; } chunks.push(decoder.decode(value, { stream: true })); } @@ -55,3 +44,16 @@ export async function readCreatorResponseText( reader.releaseLock(); } } + +export type CreatorResponseRead = BoundedResponseRead; + +export async function readCreatorResponseText( + response: Response, + maxBytes = CREATOR_RESPONSE_MAX_BYTES, +): Promise { + return readBoundedResponseText( + response, + maxBytes, + `Creator response exceeds ${maxBytes} bytes`, + ); +}