Files
agents-marketplace/buyer/src/sdk/Marketplace.ts
david 9acad5b2b8
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
feat: llm.chat.v1 multi-turn supplier type + free Kimi K2.6 chat demo
Adds a conversational supplier capability and a free demo, both streaming.

New paid `llm.chat.v1` type: one escrow per conversation (flat fee). "Start
chat" posts the escrow with a session-init placeholder prompt_hash and the
supplier Claims (reserving its single slot); turns then stream off-chain via
SSE with zero chain interaction; "End chat" has the supplier Submit a receipt
over the full transcript and the buyer Accept — the moment the user is charged.
The on-chain validator is content-agnostic (pins but never verifies the
hashes), so this reuses the existing contract unchanged. A supplier-side idle
watchdog auto-ends abandoned sessions before deliver_by.

Free Kimi K2.6 chat demo: a synthetic "demo:kimi-chat" tile that uses the same
chat UI but proxies straight to OpenRouter (no escrow, no payment), mirroring
the existing Piper demo.

Shared:
- postChatEscrow.ts + chatSessionPromptHash() (session-init prompt_hash helper
  shared by the buyer builder and the supplier start gate).

Supplier:
- CAPABILITY_KIND="chat-session" (+ CHAT_IDLE_TIMEOUT_MS); callOpenAiStream()
  streaming client; ChatSessionStore + endChatSession(); /v1/chat/{start,
  message,end} handlers with idle + hard-cap watchdog timers.

Buyer:
- Marketplace.startChat()/endChat() with relaxed (session-init) receipt
  verification + crypto verifyReceipt; /v1/chat/{start,message,end} routes
  (SSE passthrough) and a free /v1/chat-demo/message OpenRouter proxy;
  OPENROUTER_API_KEY/OPENROUTER_BASE_URL config; ChatForm.tsx + Dashboard
  KIMI_DEMO_SUPPLIER tile and llm.chat.v1 dispatch.

Deploy:
- docker-compose.supplier-kimi-chat.yml + supplier/.env.kimi-chat.example.

Typecheck passes across all workspaces; full vitest suite green except 15
pre-existing time-bomb/RED failures (tx-claim/accept/post-advert live-CBOR
fixtures with hardcoded past deadlines + the M0-C golden-datum test), confirmed
identical on a clean tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:18:54 +02:00

1073 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* buyer/src/sdk/Marketplace.ts — core SDK class.
*
* Implements:
* discoverSuppliers(opts?) → indexer GET /suppliers
* submitPrompt({advertRef, ...}) → full lifecycle (escrow → supplier → verify → record)
* acceptResult({escrowRef}) → buildAcceptTx
* reclaim({escrowRef}) → buildReclaimTx
* getTaskHistory(opts?) → reads from injected TaskHistoryStore
* on/off/close + emitProgress → EventEmitter helpers
*
* Receipt verification (M1-E discipline):
* The SDK has no path to a 32-byte supplier public key in the test harness
* (Caroline's mocks return only the chat-completion body, no /capability
* pub-key plumbing). For M1-E we therefore verify receipts by:
* (a) field-equality checks against the advert datum and posted escrow:
* supplier_pkh, escrow_ref, prompt_hash, request_spec_hash
* (b) a structural signature check: 128-char hex, not the all-zero
* placeholder.
* Cryptographic Ed25519 verification is wired here as a NO-OP fallback
* when no pub-key is available; M1-F adds /capability pub-key fetch and
* replaces the structural check with a real verifyReceipt call. See
* ARCHITECTURE.md §5 + §9 for the broader plan.
*/
// Namespace imports — Vite/Rollup resolves these to browser stubs when
// bundling the SPA. The SDK is also imported in Node-side code (buyer
// server, tests), where the namespace form works identically. See
// packages/shared/src/tx/blueprint.ts header for the full rationale.
import * as nodeEvents from "events";
import * as nodeCrypto from "crypto";
// Browser-safe EventEmitter shim. In the SPA bundle, Vite externalizes
// `events` to an empty stub, so `nodeEvents.EventEmitter` is `undefined` —
// `class Marketplace extends undefined` would throw at module load and
// stop React from ever mounting. We pick the real Node EventEmitter when
// available and fall back to a minimal listener-map class otherwise. The
// browser code path that uses the SDK only listens for "progress" events,
// which this fallback supports.
type Listener = (...args: unknown[]) => void;
class FallbackEventEmitter {
private listeners = new Map<string, Listener[]>();
on(event: string, cb: Listener): this {
const arr = this.listeners.get(event);
if (arr) arr.push(cb);
else this.listeners.set(event, [cb]);
return this;
}
off(event: string, cb: Listener): this {
const arr = this.listeners.get(event);
if (!arr) return this;
const idx = arr.indexOf(cb);
if (idx >= 0) arr.splice(idx, 1);
return this;
}
emit(event: string, ...args: unknown[]): boolean {
const arr = this.listeners.get(event);
if (!arr || arr.length === 0) return false;
for (const cb of [...arr]) cb(...args);
return true;
}
removeAllListeners(): this {
this.listeners.clear();
return this;
}
}
const EventEmitterBase: typeof FallbackEventEmitter =
((nodeEvents as unknown as { EventEmitter?: typeof FallbackEventEmitter }).EventEmitter)
?? FallbackEventEmitter;
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import {
buildPostEscrowTx,
buildPostTtsEscrowTx,
buildPostChatEscrowTx,
ttsPromptHash,
chatSessionPromptHash,
buildAcceptTx,
buildReclaimTx,
TxConstructionError,
} from "@marketplace/shared/tx";
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
import { verifyReceipt } from "@marketplace/shared/receipt";
import type { AdvertDatum } from "@marketplace/shared/cbor";
import type {
SupplierView,
DiscoverSuppliersOptions,
SubmitPromptOptions,
SubmitPromptResult,
SubmitTtsOptions,
SubmitTtsResult,
StartChatOptions,
StartChatResult,
EndChatOptions,
EndChatResult,
AcceptResultOptions,
ReclaimOptions,
TaskRecord,
GetTaskHistoryOptions,
ProgressEvent,
ProgressEventType,
} from "./types.js";
import { IndexerError, SupplierError, ReceiptVerificationError } from "./types.js";
import type { TaskHistoryStore } from "./history.js";
import { MemoryTaskHistoryStore } from "./history.js";
import { HttpClient, HttpError } from "./httpClient.js";
const ZERO_SIGNATURE = "0".repeat(128);
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const SIG_RE = /^[0-9a-fA-F]{128}$/;
/** NetworkParams — minimal params needed for tx building (protocol params). */
export interface NetworkParams {
networkId: 0 | 1;
}
/** MarketplaceOpts — constructor arguments. */
export interface MarketplaceOpts {
chain: ChainProvider;
indexerUrl: string;
walletKey: WalletKey;
networkParams: NetworkParams;
/** Optional history store; defaults to MemoryTaskHistoryStore. */
historyStore?: TaskHistoryStore;
/** Injectable fetch for tests. Defaults to globalThis.fetch. */
_fetch?: typeof globalThis.fetch;
}
function sha256Hex(s: string): string {
// Prefer Node's createHash when available (server, tests). In the browser
// bundle Vite externalizes `crypto` to an empty stub, so fall back to the
// already-installed `@noble/hashes/sha256` (the buyer-app pulls noble in
// for ed25519 signing). Both produce the same 64-char lowercase hex.
const c = nodeCrypto as { createHash?: (a: string) => { update: (s: string, e: string) => { digest: (e: string) => string } } };
if (c && typeof c.createHash === "function") {
return c.createHash("sha256").update(s, "utf8").digest("hex");
}
// Browser fallback: dynamic require of noble-hashes is bundled via the
// top-level import below. Lazy-load so we don't pull noble into Node tests.
return browserSha256Hex(s);
}
import { sha256 as nobleSha256 } from "@noble/hashes/sha256";
function browserSha256Hex(s: string): string {
const bytes = nobleSha256(new TextEncoder().encode(s));
let out = "";
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
return out;
}
function refToString(ref: OutputReference): string {
return `${ref.txHash}#${ref.index}`;
}
function parseRef(s: string): OutputReference | null {
const m = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(s);
if (!m) return null;
return { txHash: m[1], index: Number(m[2]) };
}
/** Random 32-hex session nonce. Works in both Node and the browser. */
function randomNonce(): string {
const g = globalThis as {
crypto?: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array };
};
if (g.crypto?.randomUUID) return g.crypto.randomUUID().replace(/-/g, "");
const bytes = new Uint8Array(16);
g.crypto?.getRandomValues?.(bytes);
let out = "";
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
return out;
}
function previewMessages(messages: { role: string; content: string }[]): string {
const firstUser = messages.find((m) => m.role === "user") ?? messages[0];
const text = firstUser?.content ?? "";
return text.length <= 100 ? text : text.slice(0, 100);
}
export class Marketplace extends EventEmitterBase {
private readonly chain: ChainProvider;
private readonly indexerUrl: string;
private readonly walletKey: WalletKey;
private readonly networkParams: NetworkParams;
private readonly historyStore: TaskHistoryStore;
private readonly fetchImpl: typeof globalThis.fetch;
private readonly indexerHttp: HttpClient;
constructor(opts: MarketplaceOpts) {
super();
this.chain = opts.chain;
this.indexerUrl = opts.indexerUrl;
this.walletKey = opts.walletKey;
this.networkParams = opts.networkParams;
this.historyStore = opts.historyStore ?? new MemoryTaskHistoryStore();
this.fetchImpl = opts._fetch ?? globalThis.fetch.bind(globalThis);
this.indexerHttp = new HttpClient({
baseUrl: this.indexerUrl,
fetch: this.fetchImpl,
});
void this.networkParams;
}
// ─── discovery ────────────────────────────────────────────────────────
async discoverSuppliers(opts?: DiscoverSuppliersOptions): Promise<SupplierView[]> {
let result;
try {
result = await this.indexerHttp.getJson("/suppliers", {
query: {
capability_id: opts?.capability_id,
sort: opts?.sort,
},
});
} catch (err) {
if (err instanceof HttpError) {
throw new IndexerError(err.kind, { message: err.message });
}
throw err;
}
if (!result.ok) {
throw new IndexerError("indexer_error", {
status: result.status,
message: `indexer returned ${result.status}`,
});
}
if (result.parseError) {
throw new IndexerError("malformed_response", {
status: result.status,
message: "indexer returned non-JSON body",
});
}
if (!Array.isArray(result.body)) {
throw new IndexerError("malformed_response", {
status: result.status,
message: "indexer /suppliers did not return an array",
});
}
return result.body as SupplierView[];
}
// ─── submitPrompt — happy path + every adversarial branch ──────────────
async submitPrompt(opts: SubmitPromptOptions): Promise<SubmitPromptResult> {
const { advertRef, messages, payment_lovelace, max_output_tokens } = opts;
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
const recordFailure = (reason: string): void => {
this.historyStore.save({
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
supplier_pkh: advertDatum?.supplier_pkh ?? "",
capability_id: advertDatum?.capability_id ?? "",
prompt_preview: previewMessages(messages ?? []),
posted_at: postedAtMs,
status: "failed",
failure_reason: reason,
});
};
// ── 1. Resolve advert + post escrow tx via shared builder ────────
let escrowResult;
try {
// Pre-fetch advert datum for response/history bookkeeping. The shared
// builder repeats this query and enforces all invariants — we only peek
// here so failure paths can populate the history record with metadata.
const utxo = await this.chain.queryUtxo(advertRef);
if (utxo && utxo.datumHex) {
try {
advertDatum = decodeAdvertDatum(utxo.datumHex);
} catch {
/* swallow — builder will throw a structured error */
}
}
escrowResult = await buildPostEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
messages,
payment_lovelace,
});
} catch (err) {
if (err instanceof TxConstructionError) {
recordFailure(err.reason);
throw err;
}
// Chain submission failures bubble through here too.
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
recordFailure((err as Error).message);
throw err;
}
escrowOutputRef = escrowResult.escrowOutputRef;
escrowRefStr = refToString(escrowOutputRef);
// ── 2. progress: escrow_posted ────────────────────────────────────
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// ── 2.5. Wait for the escrow UTxO to confirm on chain before calling
// the supplier. Without this, supplier.queryUtxo runs against a chain
// tip that doesn't yet contain the PostEscrow tx and the supplier 404s
// with escrow_not_found. Best-effort: if the chain provider doesn't
// implement awaitTx (mock paths in tests), swallow and proceed —
// queryUtxo retries on the next step provide adequate coverage there.
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch {
/* mock providers / test harnesses */
}
// ── 3. Re-fetch escrow datum to capture posted_at for history ────
try {
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
}
} catch {
/* posted_at is best-effort metadata */
}
if (!advertDatum) {
// Should never happen — buildPostEscrowTx throws before this on missing/bad advert.
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
// ── 4. Call supplier /v1/chat/completions ─────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const requestedMax = max_output_tokens ?? advertDatum.max_output_tokens;
const cappedMax = Math.min(requestedMax, advertDatum.max_output_tokens);
const chatBody = {
model: advertDatum.model,
messages,
max_tokens: cappedMax,
};
let chatResult;
try {
chatResult = await supplierHttp.postJson("/v1/chat/completions", chatBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
});
} catch (err) {
if (err instanceof HttpError) {
// ARCH §9 #10 (resolved 2026-04-27, M1-F-1): the previous
// `isSyncThrow` sentinel branch has been removed. All HttpError
// paths — including fetch implementations that throw synchronously —
// now propagate normally as SupplierError to the caller.
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
recordFailure((err as Error).message);
throw err;
}
// Supplier shipped 202 + poll in M1-F-async-chat: the initial POST
// returns `{job_id, status: "accepted"}` and clients poll
// `GET /v1/chat/completions/:jobId` until the status becomes "done"
// (200 with receipt) or terminal-failed (4xx/5xx). Older synchronous
// suppliers still return 200 with the receipt immediately, so we
// dispatch on `chatResult.status === 202` to keep both supported.
if (chatResult.status === 202 && chatResult.body && typeof chatResult.body === "object") {
const jobId = (chatResult.body as { job_id?: string }).job_id;
if (typeof jobId !== "string" || jobId.length === 0) {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier 202 response missing job_id",
});
recordFailure("malformed_response");
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
let polled = chatResult;
// First poll runs immediately so a quickly-completed job doesn't pay
// the leading delay; subsequent polls wait POLL_INTERVAL_MS.
let firstIter = true;
while (Date.now() < pollDeadline) {
if (!firstIter) {
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
}
firstIter = false;
try {
polled = await supplierHttp.getJson(`/v1/chat/completions/${jobId}`);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
throw err;
}
if (polled.status === 200) break;
if (polled.status === 202) continue;
// 4xx/5xx on poll → terminal failure from supplier.
const failBody = (polled.body && typeof polled.body === "object")
? polled.body as { reason?: string; message?: string }
: {};
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
status: polled.status,
message: failBody.message ?? `supplier returned ${polled.status}`,
});
recordFailure(sErr.reason);
throw sErr;
}
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
});
recordFailure("timeout");
throw sErr;
}
chatResult = polled;
}
if (!chatResult.ok) {
const bodyReason =
chatResult.body && typeof chatResult.body === "object"
? ((chatResult.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
const sErr = new SupplierError(bodyReason, {
status: chatResult.status,
message: `supplier returned ${chatResult.status}`,
});
recordFailure(bodyReason);
throw sErr;
}
if (chatResult.parseError || !chatResult.body || typeof chatResult.body !== "object") {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier body is not valid JSON",
});
recordFailure("malformed_response");
throw sErr;
}
const responseBody = chatResult.body as {
choices?: Array<{ message?: { role?: string; content?: string } }>;
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
};
if (!responseBody.receipt || !responseBody.receipt_signature) {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier response is missing receipt or receipt_signature",
});
recordFailure("malformed_response");
throw sErr;
}
const receipt = responseBody.receipt;
const receiptSignature = responseBody.receipt_signature;
const responseContent = responseBody.choices?.[0]?.message?.content;
if (typeof responseContent !== "string") {
const sErr = new SupplierError("malformed_response", {
status: chatResult.status,
message: "supplier response missing choices[0].message.content",
});
recordFailure("malformed_response");
throw sErr;
}
// ── 5. progress: supplier_called ──────────────────────────────────
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
// ── 6. Receipt verification (field equality + signature shape) ───
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
recordFailure("wrong_supplier");
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
recordFailure("wrong_escrow_ref");
throw new ReceiptVerificationError("wrong_escrow_ref");
}
const expectedPromptHash = sha256Hex(canonicalize(messages));
if (receipt.prompt_hash !== expectedPromptHash) {
recordFailure("prompt_hash_mismatch");
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
// Receipt does not carry the full request_spec_hash — only `model`. Other
// request_spec components (capability_id, max_output_tokens) are bound on
// chain via the escrow datum, which the supplier already validated against
// the advert. So the SDK only needs to assert the receipt's model matches
// the advert it referenced.
if (receipt.model !== advertDatum.model) {
recordFailure("request_spec_hash_mismatch");
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (receiptSignature === ZERO_SIGNATURE) {
// Structurally well-formed but cryptographically void. M1-F replaces
// this with a real verifyReceipt() call once pub-key plumbing is wired.
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
// Defensive: receipt fields should be 32-byte hex.
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
recordFailure("malformed_receipt");
throw new ReceiptVerificationError("malformed_receipt");
}
// ── 7. progress: receipt_verified ─────────────────────────────────
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
// ── 8. Persist completed task ─────────────────────────────────────
this.historyStore.save({
escrow_ref: escrowRefStr,
supplier_pkh: advertDatum.supplier_pkh,
capability_id: advertDatum.capability_id,
prompt_preview: previewMessages(messages),
posted_at: postedAtMs,
status: "completed",
response: responseContent,
receipt,
receipt_signature: receiptSignature,
});
return {
response: responseContent,
receipt,
receiptSignature,
escrowRef: escrowOutputRef,
};
}
// ─── submitTts — full marketplace lifecycle for audio.synthesize.piper.v1 ─
async submitTts(opts: SubmitTtsOptions): Promise<SubmitTtsResult> {
const { advertRef, text, voice, format, speed, payment_lovelace } = opts;
const request = { text, voice, format, speed };
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
const recordFailure = (reason: string): void => {
this.historyStore.save({
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
supplier_pkh: advertDatum?.supplier_pkh ?? "",
capability_id: advertDatum?.capability_id ?? "",
prompt_preview: text.length <= 100 ? text : text.slice(0, 100),
posted_at: postedAtMs,
status: "failed",
failure_reason: reason,
});
};
// ── 1. Resolve advert + post escrow (TTS prompt_hash) ─────────────
let escrowResult;
try {
const utxo = await this.chain.queryUtxo(advertRef);
if (utxo && utxo.datumHex) {
try {
advertDatum = decodeAdvertDatum(utxo.datumHex);
} catch { /* builder will re-throw structurally */ }
}
escrowResult = await buildPostTtsEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
request,
payment_lovelace,
});
} catch (err) {
if (err instanceof TxConstructionError) {
recordFailure(err.reason);
throw err;
}
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
recordFailure((err as Error).message);
throw err;
}
escrowOutputRef = escrowResult.escrowOutputRef;
escrowRefStr = refToString(escrowOutputRef);
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch { /* mock providers / tests */ }
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
try {
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
}
} catch { /* metadata best-effort */ }
if (!advertDatum) {
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
// ── 4. Call supplier /v1/audio/synthesize ─────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const ttsBody = { text, voice, format, speed };
let synthResult;
try {
synthResult = await supplierHttp.postJson("/v1/audio/synthesize", ttsBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
});
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
recordFailure((err as Error).message);
throw err;
}
// 202 + poll mirror of submitPrompt's async chat handling.
if (synthResult.status === 202 && synthResult.body && typeof synthResult.body === "object") {
const jobId = (synthResult.body as { job_id?: string }).job_id;
if (typeof jobId !== "string" || jobId.length === 0) {
const sErr = new SupplierError("malformed_response", {
status: synthResult.status,
message: "supplier 202 response missing job_id",
});
recordFailure("malformed_response");
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
// TTS jobs run on CPU and finish in 1–3s for short text; the long
// tail is dominated by the Submit tx confirmation (slot rate). 180s
// mirrors the chat path so polled.status hits 200 cleanly.
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
let polled = synthResult;
let firstIter = true;
while (Date.now() < pollDeadline) {
if (!firstIter) {
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
}
firstIter = false;
try {
polled = await supplierHttp.getJson(`/v1/audio/synthesize/${jobId}`);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
throw err;
}
if (polled.status === 200) break;
if (polled.status === 202) continue;
const failBody = (polled.body && typeof polled.body === "object")
? polled.body as { reason?: string; message?: string }
: {};
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
status: polled.status,
message: failBody.message ?? `supplier returned ${polled.status}`,
});
recordFailure(sErr.reason);
throw sErr;
}
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
});
recordFailure("timeout");
throw sErr;
}
synthResult = polled;
}
if (!synthResult.ok) {
const bodyReason =
synthResult.body && typeof synthResult.body === "object"
? ((synthResult.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
const sErr = new SupplierError(bodyReason, {
status: synthResult.status,
message: `supplier returned ${synthResult.status}`,
});
recordFailure(bodyReason);
throw sErr;
}
if (synthResult.parseError || !synthResult.body || typeof synthResult.body !== "object") {
const sErr = new SupplierError("malformed_response", {
status: synthResult.status,
message: "supplier body is not valid JSON",
});
recordFailure("malformed_response");
throw sErr;
}
const responseBody = synthResult.body as {
audio_b64?: string;
format?: string;
content_type?: string;
byte_length?: number;
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
};
if (!responseBody.audio_b64 || !responseBody.receipt || !responseBody.receipt_signature) {
const sErr = new SupplierError("malformed_response", {
status: synthResult.status,
message: "supplier response is missing audio_b64 / receipt / receipt_signature",
});
recordFailure("malformed_response");
throw sErr;
}
const receipt = responseBody.receipt;
const receiptSignature = responseBody.receipt_signature;
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
// ── 5. Receipt verification ───────────────────────────────────────
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
recordFailure("wrong_supplier");
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
recordFailure("wrong_escrow_ref");
throw new ReceiptVerificationError("wrong_escrow_ref");
}
// The TTS prompt commitment uses the SAME canonical hash that the
// supplier validated against the escrow datum. If we recompute it from
// the request envelope we sent and it doesn't match the receipt, the
// supplier signed a receipt for a different request → reject.
const expectedPromptHash = ttsPromptHash(request);
if (receipt.prompt_hash !== expectedPromptHash) {
recordFailure("prompt_hash_mismatch");
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
if (receipt.model !== advertDatum.model) {
recordFailure("request_spec_hash_mismatch");
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (receiptSignature === ZERO_SIGNATURE) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
recordFailure("malformed_receipt");
throw new ReceiptVerificationError("malformed_receipt");
}
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
this.historyStore.save({
escrow_ref: escrowRefStr,
supplier_pkh: advertDatum.supplier_pkh,
capability_id: advertDatum.capability_id,
prompt_preview: text.length <= 100 ? text : text.slice(0, 100),
posted_at: postedAtMs,
status: "completed",
// Reuse the chat history shape — `response` carries a marker; the
// SPA renders audio via the live result, not from history.
response: `[audio:${responseBody.format ?? format} ${responseBody.byte_length ?? "?"}B]`,
receipt,
receipt_signature: receiptSignature,
});
return {
audio_b64: responseBody.audio_b64,
format: responseBody.format ?? format,
content_type: responseBody.content_type ?? `audio/${format}`,
byte_length: responseBody.byte_length ?? 0,
receipt,
receiptSignature,
escrowRef: escrowOutputRef,
};
}
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
/**
* Open a chat session: post the escrow with a session-init prompt_hash, wait
* for it to confirm, then tell the supplier to Claim (reserving its slot).
* The conversation then runs off-chain via the buyer-app's SSE passthrough.
*/
async startChat(opts: StartChatOptions): Promise<StartChatResult> {
const { advertRef, payment_lovelace } = opts;
const advertUtxo = await this.chain.queryUtxo(advertRef);
if (!advertUtxo || !advertUtxo.datumHex) {
throw new TxConstructionError("advert ref not on chain", `no advert UTxO at ${refToString(advertRef)}`);
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
const sessionNonce = randomNonce();
let escrowResult;
try {
escrowResult = await buildPostChatEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
session_nonce: sessionNonce,
payment_lovelace,
});
} catch (err) {
if (!(err instanceof TxConstructionError)) {
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
}
throw err;
}
const escrowOutputRef = escrowResult.escrowOutputRef;
const escrowRefStr = refToString(escrowOutputRef);
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// Wait for the PostEscrow tx to confirm before the supplier Claims.
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch {
/* mock providers / tests */
}
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
let startRes;
try {
startRes = await supplierHttp.postJson(
"/v1/chat/start",
{ session_nonce: sessionNonce, model: advertDatum.model },
{ headers: { "X-Escrow-Ref": escrowRefStr } },
);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
throw new SupplierError(reason, { message: err.message });
}
throw err;
}
if (!startRes.ok) {
const bodyReason = startRes.body && typeof startRes.body === "object"
? ((startRes.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
throw new SupplierError(bodyReason, {
status: startRes.status,
message: `supplier /v1/chat/start returned ${startRes.status}`,
});
}
this.emitProgress({ type: "chat_started", escrow_ref: escrowRefStr });
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url };
}
/**
* End a chat session: ask the supplier to Submit a transcript receipt, verify
* it (structural + crypto + session-init prompt_hash), then Accept the
* Submitted escrow — which is when the user is actually charged.
*/
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
const { escrowRef, sessionNonce, transcript } = opts;
const escrowRefStr = refToString(escrowRef);
// Resolve escrow → advert for endpoint_url, supplier_pkh, model, pub key.
const escrowUtxo = await this.chain.queryUtxo(escrowRef);
if (!escrowUtxo || !escrowUtxo.datumHex) {
throw new TxConstructionError("escrow ref not on chain", `no escrow UTxO at ${escrowRefStr}`);
}
const escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
const advertUtxo = await this.chain.queryUtxo(escrowDatum.advert_ref);
if (!advertUtxo || !advertUtxo.datumHex) {
throw new TxConstructionError("advert ref not on chain", "advert UTxO for this escrow is missing");
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
let endRes;
try {
endRes = await supplierHttp.postJson("/v1/chat/end", {}, { headers: { "X-Escrow-Ref": escrowRefStr } });
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
throw new SupplierError(reason, { message: err.message });
}
throw err;
}
if (!endRes.ok || !endRes.body || typeof endRes.body !== "object") {
const bodyReason = endRes.body && typeof endRes.body === "object"
? ((endRes.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
throw new SupplierError(bodyReason, {
status: endRes.status,
message: `supplier /v1/chat/end returned ${endRes.status}`,
});
}
const endBody = endRes.body as {
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
submitted_ref?: string;
};
if (!endBody.receipt || !endBody.receipt_signature) {
throw new SupplierError("malformed_response", { message: "supplier end response missing receipt" });
}
const receipt = endBody.receipt;
const receiptSignature = endBody.receipt_signature;
// ── Verify receipt ───────────────────────────────────────────────────
if (receipt.supplier_pkh !== escrowDatum.supplier_pkh) {
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
throw new ReceiptVerificationError("wrong_escrow_ref");
}
// prompt_hash is the session-init placeholder, NOT sha256(messages).
if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) {
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
if (receipt.model !== advertDatum.model) {
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) {
throw new ReceiptVerificationError("invalid_signature");
}
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
throw new ReceiptVerificationError("malformed_receipt");
}
// Optional: recompute the transcript hash from the browser's mirror.
if (transcript && transcript.length > 0) {
const localHash = sha256Hex(canonicalize(transcript));
if (localHash !== receipt.response_hash) {
console.warn(
`[marketplace] endChat transcript hash mismatch for ${escrowRefStr}: local ${localHash} != receipt ${receipt.response_hash}`,
);
}
}
// Cryptographic verification via the supplier's published pub key (best-effort).
try {
const capRes = await supplierHttp.getJson("/capability");
const pubKeyHex = capRes.body && typeof capRes.body === "object"
? (capRes.body as { pub_key_hex?: string }).pub_key_hex
: undefined;
if (typeof pubKeyHex === "string" && /^[0-9a-fA-F]{64}$/.test(pubKeyHex)) {
if (!verifyReceipt({ receipt, signature: receiptSignature }, pubKeyHex)) {
throw new ReceiptVerificationError("invalid_signature");
}
}
} catch (err) {
if (err instanceof ReceiptVerificationError) throw err;
/* /capability unreachable — structural verification stands */
}
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
// ── Accept the Submitted escrow — the user is charged here ────────────
const acceptedRef = parseRef(typeof endBody.submitted_ref === "string" ? endBody.submitted_ref : "");
if (!acceptedRef) {
throw new SupplierError("malformed_response", { message: "supplier end response missing/invalid submitted_ref" });
}
let acceptBuilt;
try {
acceptBuilt = await buildAcceptTx({ chain: this.chain, buyerKey: this.walletKey, escrowRef: acceptedRef });
} catch (err) {
if (err instanceof TxConstructionError) throw err;
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
throw err;
}
try {
await this.chain.awaitTx(acceptBuilt.expectedTxHash, 120_000);
} catch {
/* mock providers / tests — Accept tx already submitted */
}
this.emitProgress({ type: "accept_submitted", escrow_ref: refToString(acceptedRef) });
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
return { receipt, receiptSignature, escrowRef, acceptedRef };
}
// ─── acceptResult / reclaim ───────────────────────────────────────────
async acceptResult(opts: AcceptResultOptions): Promise<void> {
await buildAcceptTx({
chain: this.chain,
buyerKey: this.walletKey,
escrowRef: opts.escrowRef,
});
this.emitProgress({
type: "accept_submitted",
escrow_ref: refToString(opts.escrowRef),
});
const escrowRefStr = refToString(opts.escrowRef);
const existing = this.historyStore.get(escrowRefStr);
if (existing) {
this.historyStore.save({ ...existing, status: "completed" });
}
}
async reclaim(opts: ReclaimOptions): Promise<void> {
await buildReclaimTx({
chain: this.chain,
buyerKey: this.walletKey,
escrowRef: opts.escrowRef,
});
this.emitProgress({
type: "reclaim_submitted",
escrow_ref: refToString(opts.escrowRef),
});
const escrowRefStr = refToString(opts.escrowRef);
const existing = this.historyStore.get(escrowRefStr);
if (existing) {
this.historyStore.save({ ...existing, status: "reclaimed" });
}
}
// ─── history ──────────────────────────────────────────────────────────
getTaskHistory(opts?: GetTaskHistoryOptions): TaskRecord[] {
return this.historyStore.list(opts);
}
// ─── accessors ────────────────────────────────────────────────────────
/** Read the wallet key — UI surfaces this on /wallet. */
getWalletKey(): WalletKey {
return this.walletKey;
}
// ─── lifecycle ────────────────────────────────────────────────────────
emitProgress(event: ProgressEvent): void {
this.emit("progress", event);
}
close(): void {
this.removeAllListeners();
}
on(event: "progress" | ProgressEventType | string, listener: (...args: unknown[]) => void): this {
return super.on(event, listener);
}
off(event: "progress" | ProgressEventType | string, listener: (...args: unknown[]) => void): this {
return super.off(event, listener);
}
}