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)
365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
/**
|
|
* buyer-sdk-submit-prompt-happy.test.ts — RED phase (M1-E)
|
|
*
|
|
* Category B: Marketplace.submitPrompt() happy path
|
|
*
|
|
* All tests FAIL until M1-E-green.
|
|
*
|
|
* Design notes for Catherine:
|
|
* - advertRef is resolved via chain.queryUtxo (MockChainProvider.seed)
|
|
* - PostEscrow tx is submitted via chain.submitTx (MockChainProvider records it)
|
|
* - Supplier is called via POST ${advert.endpoint_url}/v1/chat/completions
|
|
* with header X-Escrow-Ref: <txHash>#<index>
|
|
* - Receipt is verified via verifyReceipt(signed, advert.supplier_pkh → pub key)
|
|
* NOTE: verifyReceipt takes a SignedReceipt + publicKeyHex; the SDK needs to
|
|
* map supplier_pkh → pubKeyHex. In v1 the supplier's /capability endpoint
|
|
* returns the pubKey; alternatively store it in SupplierView. The test mocks
|
|
* the supplier HTTP endpoint including a /capability call to get pubKeyHex.
|
|
* - Progress events must be emitted in order: escrow_posted, supplier_called, receipt_verified
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { createHash } from "crypto";
|
|
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
|
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
|
|
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
|
|
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
|
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
|
|
import { encodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
|
|
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
|
|
import { buildReceipt } from "../../packages/shared/src/receipt/build.js";
|
|
import { signReceipt } from "../../packages/shared/src/receipt/sign.js";
|
|
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
|
|
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
|
|
import type { ChatMessage } from "../../packages/shared/src/tx/types.js";
|
|
import type { ProgressEvent } from "../../buyer/src/sdk/types.js";
|
|
import { ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
|
|
|
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
const ADVERT_TX = "b".repeat(64);
|
|
const ADVERT_REF: OutputReference = { txHash: ADVERT_TX, index: 0 };
|
|
const ADVERT_SCRIPT_ADDR = "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6";
|
|
|
|
const buyer = buildBuyerWalletKey();
|
|
const supplier = buildSupplierWalletKey();
|
|
|
|
const SAMPLE_MESSAGES: ChatMessage[] = [{ role: "user", content: "What is 2+2?" }];
|
|
const PAYMENT = 2_000_000n;
|
|
|
|
function makeActiveAdvert(): AdvertDatum {
|
|
return {
|
|
supplier_pkh: supplier.pubKeyHash,
|
|
capability_id: "llm.text.generate.v1",
|
|
model: "qwen2.5:0.5b",
|
|
max_output_tokens: 512,
|
|
max_processing_ms: 60_000,
|
|
price_lovelace: PAYMENT,
|
|
supplier_bond_lovelace: 1_000_000n,
|
|
buyer_bond_lovelace: 1_000_000n,
|
|
endpoint_url: "https://supplier.example.com",
|
|
detail_uri: "ipfs://Qm000",
|
|
detail_hash: "a".repeat(64),
|
|
advertised_at: 1_745_500_000_000,
|
|
status: "Active",
|
|
};
|
|
}
|
|
|
|
function seedAdvertUtxo(chain: MockChainProvider, datum: AdvertDatum, ref = ADVERT_REF) {
|
|
const utxo: Utxo = {
|
|
ref,
|
|
address: ADVERT_SCRIPT_ADDR,
|
|
lovelace: 2_000_000n,
|
|
assets: {},
|
|
datumHex: encodeAdvertDatum(datum),
|
|
scriptRef: null,
|
|
};
|
|
chain.seed(utxo);
|
|
}
|
|
|
|
function sha256(s: string): string {
|
|
return createHash("sha256").update(s, "utf8").digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Build a well-formed supplier HTTP response (OpenAI-compat shape + receipt).
|
|
* Signing uses the supplier fixture private key — verifyReceipt must pass.
|
|
*/
|
|
function makeSupplierResponse(escrowRef: string, messages: ChatMessage[]) {
|
|
const advert = makeActiveAdvert();
|
|
const promptHash = sha256(canonicalize(messages));
|
|
const responseContent = "4";
|
|
const responseHash = sha256(JSON.stringify({ role: "assistant", content: responseContent }));
|
|
const receipt = buildReceipt({
|
|
prompt_hash: promptHash,
|
|
response_hash: responseHash,
|
|
model: advert.model,
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
wallclock_ms: 800,
|
|
supplier_pkh: supplier.pubKeyHash,
|
|
escrow_ref: escrowRef,
|
|
});
|
|
const signed = signReceipt(receipt, supplier.privateKeyHex);
|
|
return {
|
|
id: "chatcmpl-test",
|
|
object: "chat.completion",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: responseContent },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 },
|
|
receipt: signed.receipt,
|
|
receipt_signature: signed.signature,
|
|
};
|
|
}
|
|
|
|
function jsonFetch(body: unknown, status = 200) {
|
|
return Promise.resolve(
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
}
|
|
|
|
function makeMarketplaceWithFetch(fetchImpl: ReturnType<typeof vi.fn>): Marketplace {
|
|
const chain = new MockChainProvider();
|
|
chain.advanceSlot(1_745_500_000);
|
|
seedAdvertUtxo(chain, makeActiveAdvert());
|
|
return new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchImpl as unknown as typeof fetch,
|
|
} as never);
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("Marketplace.submitPrompt() — happy path", () => {
|
|
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
let chain: MockChainProvider;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.fn();
|
|
chain = new MockChainProvider();
|
|
chain.advanceSlot(1_745_500_000);
|
|
seedAdvertUtxo(chain, makeActiveAdvert());
|
|
});
|
|
|
|
it("resolves advert via chain.queryUtxo(advertRef) before posting escrow", async () => {
|
|
const querySpy = vi.spyOn(chain, "queryUtxo");
|
|
// Fetch: supplier /v1/chat/completions — use actual X-Escrow-Ref header so receipt.escrow_ref matches the posted tx.
|
|
fetchSpy.mockImplementation((_url: unknown, opts: unknown) => {
|
|
const url = String(_url);
|
|
if (url.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? headers["x-escrow-ref"] ?? `${"f".repeat(64)}#0`;
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
expect(querySpy).toHaveBeenCalledWith(ADVERT_REF);
|
|
});
|
|
|
|
it("submits the PostEscrow tx via chain.submitTx", async () => {
|
|
const submitSpy = vi.spyOn(chain, "submitTx");
|
|
fetchSpy.mockImplementation((_url: unknown, opts: unknown) => {
|
|
const url = String(_url);
|
|
if (url.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? headers["x-escrow-ref"] ?? `${"f".repeat(64)}#0`;
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
expect(submitSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls POST ${advert.endpoint_url}/v1/chat/completions with X-Escrow-Ref header", async () => {
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
// Must include X-Escrow-Ref header
|
|
expect(headers["X-Escrow-Ref"] ?? headers["x-escrow-ref"]).toMatch(/^[0-9a-fA-F]{64}#\d+$/);
|
|
return jsonFetch(makeSupplierResponse(headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0", SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
expect(fetchSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls supplier /v1/chat/completions with OpenAI-shaped body (messages, max_tokens)", async () => {
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const body = JSON.parse((opts as { body?: string })?.body ?? "{}");
|
|
expect(body.messages).toEqual(SAMPLE_MESSAGES);
|
|
expect(typeof body.max_tokens).toBe("number");
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
return jsonFetch(makeSupplierResponse(headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0", SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
});
|
|
|
|
it("resolves with {response, receipt, receiptSignature, escrowRef} on success", async () => {
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
const result = await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
expect(result).toHaveProperty("response");
|
|
expect(result).toHaveProperty("receipt");
|
|
expect(result).toHaveProperty("receiptSignature");
|
|
expect(result).toHaveProperty("escrowRef");
|
|
expect(result.response).toBe("4");
|
|
});
|
|
|
|
it("verifies receipt.prompt_hash matches sha256(canonical(messages))", async () => {
|
|
const expectedPromptHash = sha256(canonicalize(SAMPLE_MESSAGES));
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
const result = await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
expect(result.receipt.prompt_hash).toBe(expectedPromptHash);
|
|
});
|
|
|
|
it("emits progress events in order: escrow_posted, supplier_called, receipt_verified", async () => {
|
|
const events: string[] = [];
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
mp.on("progress", (e: ProgressEvent) => events.push(e.type));
|
|
await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
expect(events).toEqual(["escrow_posted", "supplier_called", "receipt_verified"]);
|
|
});
|
|
|
|
it("records completed task to TaskHistoryStore after successful submitPrompt", async () => {
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
const history = mp.getTaskHistory();
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0].status).toBe("completed");
|
|
});
|
|
|
|
it("escrowRef in result matches what was submitted to the chain", async () => {
|
|
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
|
|
const u = String(url);
|
|
if (u.includes("/v1/chat/completions")) {
|
|
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
|
|
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
|
|
return jsonFetch(makeSupplierResponse(escrowRef, SAMPLE_MESSAGES));
|
|
}
|
|
return jsonFetch({});
|
|
});
|
|
const mp = new Marketplace({
|
|
chain,
|
|
indexerUrl: "http://indexer.test",
|
|
walletKey: buyer,
|
|
networkParams: { networkId: 0 },
|
|
_fetch: fetchSpy as unknown as typeof fetch,
|
|
} as never);
|
|
const result = await mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT });
|
|
// escrowRef must be a valid OutputReference with a real 64-char txHash
|
|
expect(result.escrowRef.txHash).toMatch(/^[0-9a-fA-F]{64}$/);
|
|
expect(typeof result.escrowRef.index).toBe("number");
|
|
});
|
|
|
|
// The ACCEPT_WINDOW_MS constant must be 600_000 ms (10 min) per ARCHITECTURE §4.3.
|
|
it("ACCEPT_WINDOW_MS is 600000 (10 minutes)", () => {
|
|
expect(ACCEPT_WINDOW_MS).toBe(600_000);
|
|
});
|
|
});
|