Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.
Workspace layout (pnpm + TypeScript):
contracts/marketplace/ Aiken validators (advert + escrow) + plutus.json
packages/shared/ Tx-builders (lucid-evolution), CBOR codecs,
chain providers (mock / read-only / live Ogmios)
supplier/ OpenAI-compatible HTTP node (Ollama by default;
vLLM / llama.cpp / OpenRouter / OpenClaw all
swap-in via OLLAMA_URL); 202+poll chat handler
buyer/ Express + React SPA; server-side runs the full
chain lifecycle via /v1/submit-prompt and
/v1/accept; SPA never holds the buyer key
indexer/ Chain-follower (extends apex-dashboard pattern),
SSE event stream + REST queries
indexer-ui/ Operator dashboard (events + suppliers + escrow
lookup)
tests/ 1098 vitest unit + integration tests (10 skipped
under M1-F-D-defer for UPLC test plumbing)
deploy/testnet/ Per-service docker-compose for the Hetzner stack
docs/EXECUTIVE_SUMMARY.* Marp deck (PDF + PPTX) — 11 slides, mermaid
diagrams pre-rendered for portable export
docs/CLOUDFLARE_WAF_BYPASS.md CF Skip-rule runbook for /v1/* paths
docs/ARCHITECTURE.md canonical spec
Live demo (testnet, 2026-05-06):
https://mp-buyers.vector.testnet.apexfusion.org (buyer SPA)
https://mp-suppliers.vector.testnet.apexfusion.org (supplier API)
https://mp-indexer.vector.testnet.apexfusion.org (indexer + UI)
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
/**
|
|
* buyer-sdk-discover-suppliers.test.ts — RED phase (M1-E)
|
|
*
|
|
* Category A: Marketplace.discoverSuppliers()
|
|
*
|
|
* All tests are expected to FAIL until M1-E-green because Marketplace.discoverSuppliers()
|
|
* currently throws "not implemented — M1-E-green".
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
|
|
import { IndexerError } from "../../buyer/src/sdk/types.js";
|
|
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
|
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function makeMarketplace(fetchImpl: typeof globalThis.fetch): Marketplace {
|
|
return new Marketplace({
|
|
chain: new MockChainProvider(),
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buildBuyerWalletKey(),
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchImpl as unknown as typeof fetch,
|
|
} as never);
|
|
}
|
|
|
|
function makeSampleSupplier(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
utxo_ref: "a".repeat(64) + "#0",
|
|
supplier_pkh: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01",
|
|
capability_id: "llm.text.generate.v1",
|
|
model: "qwen2.5:0.5b",
|
|
max_output_tokens: 512,
|
|
max_processing_ms: 60_000,
|
|
price_lovelace: "2000000",
|
|
supplier_bond_lovelace: "1000000",
|
|
buyer_bond_lovelace: "1000000",
|
|
endpoint_url: "https://supplier.example.com",
|
|
detail_uri: "ipfs://Qm000",
|
|
detail_hash: "a".repeat(64),
|
|
advertised_at: 1_745_500_000_000,
|
|
status: "free",
|
|
advert_status: "Active",
|
|
current_escrow_ref: null,
|
|
last_seen_iso: "2026-04-24T00:00:00.000Z",
|
|
created_slot: 1000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return Promise.resolve(
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("Marketplace.discoverSuppliers()", () => {
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.fn();
|
|
});
|
|
|
|
it("calls GET ${indexerUrl}/suppliers with no extra params when called with no options", async () => {
|
|
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await mp.discoverSuppliers();
|
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
|
|
expect(url).toBe("http://indexer.test/suppliers");
|
|
});
|
|
|
|
it("returns a list shaped as SupplierView[]", async () => {
|
|
const sample = makeSampleSupplier();
|
|
fetchSpy.mockReturnValue(jsonResponse([sample]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
const result = await mp.discoverSuppliers();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toMatchObject({
|
|
supplier_pkh: sample.supplier_pkh,
|
|
capability_id: sample.capability_id,
|
|
price_lovelace: sample.price_lovelace,
|
|
});
|
|
});
|
|
|
|
it("adds ?capability_id=... query param when filtering by capability_id", async () => {
|
|
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await mp.discoverSuppliers({ capability_id: "llm.text.generate.v1" });
|
|
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
|
|
expect(url).toContain("capability_id=llm.text.generate.v1");
|
|
});
|
|
|
|
it("adds ?sort=price query param when sort:'price' is requested", async () => {
|
|
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await mp.discoverSuppliers({ sort: "price" });
|
|
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
|
|
expect(url).toContain("sort=price");
|
|
});
|
|
|
|
it("combines capability_id and sort params correctly", async () => {
|
|
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await mp.discoverSuppliers({ capability_id: "llm.text.generate.v1", sort: "price" });
|
|
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
|
|
expect(url).toContain("capability_id=llm.text.generate.v1");
|
|
expect(url).toContain("sort=price");
|
|
});
|
|
|
|
it("returns [] when the indexer returns an empty list", async () => {
|
|
fetchSpy.mockReturnValue(jsonResponse([]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
const result = await mp.discoverSuppliers();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("throws IndexerError with status code when indexer returns 5xx", async () => {
|
|
fetchSpy.mockReturnValue(
|
|
Promise.resolve(new Response(JSON.stringify({ error: "internal" }), { status: 503 }))
|
|
);
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await expect(mp.discoverSuppliers()).rejects.toSatisfy(
|
|
(e: unknown) => e instanceof IndexerError && e.status === 503
|
|
);
|
|
});
|
|
|
|
it("throws IndexerError when indexer returns 500", async () => {
|
|
fetchSpy.mockReturnValue(
|
|
Promise.resolve(new Response("server error", { status: 500 }))
|
|
);
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await expect(mp.discoverSuppliers()).rejects.toBeInstanceOf(IndexerError);
|
|
});
|
|
|
|
it("throws IndexerError when indexer returns malformed JSON", async () => {
|
|
fetchSpy.mockReturnValue(
|
|
Promise.resolve(new Response("not json!!", { status: 200 }))
|
|
);
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
await expect(mp.discoverSuppliers()).rejects.toBeInstanceOf(IndexerError);
|
|
});
|
|
|
|
it("returns multiple suppliers when indexer returns multiple entries", async () => {
|
|
const s1 = makeSampleSupplier({ supplier_pkh: "a".repeat(56) });
|
|
const s2 = makeSampleSupplier({ supplier_pkh: "b".repeat(56) });
|
|
fetchSpy.mockReturnValue(jsonResponse([s1, s2]));
|
|
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
|
|
const result = await mp.discoverSuppliers();
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it("does not make any network calls during Marketplace construction", () => {
|
|
const mp = new Marketplace({
|
|
chain: new MockChainProvider(),
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buildBuyerWalletKey(),
|
|
networkParams: { networkId: 0 },
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
void mp; // suppress unused
|
|
});
|
|
});
|