buyer: TTS marketplace lifecycle (audio.synthesize.piper.v1)

Path-A buyer side: PostEscrow → supplier /v1/audio/synthesize → 202+poll
→ on-chain Submit → receipt verify, returning the audio bytes + signed
receipt to the SPA. Pairs with the supplier-side scaffolding in 3c3e009.

Files

- packages/shared/src/tx/escrow/postTtsEscrow.ts (new):
  buildPostTtsEscrowTx() mirrors postEscrow.ts but commits a TTS-shaped
  prompt_hash = sha256(canonical({text, voice, format, speed})). The
  same hash is exposed as ttsPromptHash() so the buyer SDK and supplier
  validation can pin the canonicalisation; any drift fails the
  supplier's prompt_mismatch gate. Validates body shape (text non-empty,
  voice/format in allow-list, speed ∈ [0.5,1.5]); reuses the chat live-
  cbor path which is already capability-agnostic.

- packages/shared/src/tx/index.ts: export the new TTS surface.

- buyer/src/sdk/Marketplace.ts: + submitTts() — full lifecycle mirror of
  submitPrompt with TTS-shaped POST body, /v1/audio/synthesize endpoint,
  and audio_b64 in the polled response. Receipt verification gates on
  prompt_hash via ttsPromptHash() instead of canonicalize(messages).

- buyer/src/sdk/types.ts: + SubmitTtsOptions / SubmitTtsResult.

- buyer/src/server.ts: + POST /v1/submit-tts (mirrors /v1/submit-prompt
  shape; routes to marketplace.submitTts).

- buyer/src/ui/components/PiperTTSForm.tsx: two-mode rendering. When
  advertRef + payment_lovelace are passed, posts to /v1/submit-tts and
  decodes base64 audio into a blob URL. When neither is passed, falls
  back to /v1/synth-speech (the path-B direct demo proxy).

- buyer/src/ui/pages/Dashboard.tsx: capability dispatch routes a real
  on-chain TTS supplier to the marketplace mode and the synthetic demo
  card to the direct mode (its utxo_ref="demo:piper-tts" fails the ref
  parser → advertRef is null → demo mode). Same component for both.

Tests still passing: 31 buyer + 29 supplier; both typechecks clean.
Chat path behaviourally untouched.
This commit is contained in:
jelisaveta-talbot
2026-05-06 22:00:31 +02:00
parent 3c3e009a67
commit 34a52d5d16
7 changed files with 679 additions and 29 deletions

View File

@@ -71,6 +71,8 @@ import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import {
buildPostEscrowTx,
buildPostTtsEscrowTx,
ttsPromptHash,
buildAcceptTx,
buildReclaimTx,
TxConstructionError,
@@ -82,6 +84,8 @@ import type {
DiscoverSuppliersOptions,
SubmitPromptOptions,
SubmitPromptResult,
SubmitTtsOptions,
SubmitTtsResult,
AcceptResultOptions,
ReclaimOptions,
TaskRecord,
@@ -517,6 +521,266 @@ export class Marketplace extends EventEmitterBase {
};
}
// ─── 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,
};
}
// ─── acceptResult / reclaim ───────────────────────────────────────────
async acceptResult(opts: AcceptResultOptions): Promise<void> {

View File

@@ -154,6 +154,37 @@ export interface SubmitPromptOptions {
max_output_tokens?: number;
}
/**
* SubmitTtsOptions — arguments to Marketplace.submitTts() for the
* `audio.synthesize.piper.v1` capability. The same `{text, voice, format,
* speed}` envelope is hashed (sha256 ∘ canonical) into the escrow datum's
* `prompt_hash` AND posted to the supplier's /v1/audio/synthesize body, so
* the supplier can verify the request matches what was committed on chain.
*/
export interface SubmitTtsOptions {
advertRef: OutputReference;
text: string;
voice: string; // alloy | echo | fable | onyx | nova | shimmer | lessac
format: string; // mp3 | wav | opus | aac | flac
speed: number; // 0.5 .. 1.5
payment_lovelace: bigint;
}
/**
* SubmitTtsResult — returned from Marketplace.submitTts() on success.
* Audio is base64 over the wire to keep the JSON poll response clean; the
* caller decodes once and renders / persists as needed.
*/
export interface SubmitTtsResult {
audio_b64: string;
format: string;
content_type: string;
byte_length: number;
receipt: Receipt;
receiptSignature: string;
escrowRef: OutputReference;
}
/**
* AcceptResultOptions — arguments to Marketplace.acceptResult().
*/

View File

@@ -419,6 +419,77 @@ export function createApp(deps: AppDeps): Express {
}
});
// ── POST /v1/submit-tts — full server-side buyer lifecycle for TTS.
// Body: { advert_ref, text, voice, format, speed, payment_lovelace }
// Returns: { audio_b64, format, content_type, byte_length, receipt,
// receipt_signature, escrow_ref }
//
// Same shape as /v1/submit-prompt but routes to Marketplace.submitTts
// instead. Audio bytes are base64'd in the JSON response so the SPA's
// PiperTTSForm can decode → blob → <audio> + Download anchor without
// negotiating a binary content-type with the SDK layer.
app.post("/v1/submit-tts", async (req: Request, res: Response) => {
if (!deps.marketplace) {
return jsonError(res, 503, "service_unavailable",
"buyer-app booted without Marketplace SDK; /v1/submit-tts disabled");
}
const body = readBody(req.body);
const rawRef = body.advert_ref;
if (typeof rawRef !== "string" || !ESCROW_REF_RE.test(rawRef)) {
return jsonError(res, 400, "advert_ref_invalid",
'body must include { "advert_ref": "<64-hex-txhash>#<index>" }');
}
const text = typeof body.text === "string" ? body.text.trim() : "";
if (text.length === 0) {
return jsonError(res, 400, "text_required",
"body.text must be a non-empty string");
}
const voice = typeof body.voice === "string" ? body.voice : "nova";
const format = typeof body.format === "string" ? body.format : "mp3";
const speedRaw = body.speed;
const speed = typeof speedRaw === "number" ? speedRaw
: typeof speedRaw === "string" ? Number(speedRaw)
: 1.0;
let payment_lovelace: bigint;
try {
payment_lovelace = BigInt(body.payment_lovelace as string | number);
} catch {
return jsonError(res, 400, "payment_lovelace_invalid",
"body must include `payment_lovelace` (numeric string)");
}
const m = ESCROW_REF_RE.exec(rawRef)!;
const advertRef = { txHash: m[1], index: Number(m[2]) };
try {
const result = await deps.marketplace.submitTts({
advertRef,
text,
voice,
format,
speed,
payment_lovelace,
});
return res.status(200).json({
audio_b64: result.audio_b64,
format: result.format,
content_type: result.content_type,
byte_length: result.byte_length,
receipt: result.receipt,
receipt_signature: result.receiptSignature,
escrow_ref: `${result.escrowRef.txHash}#${result.escrowRef.index}`,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const reason = err instanceof Error && "reason" in err
? String((err as { reason: unknown }).reason)
: "submit_tts_failed";
console.error(
`[buyer] /v1/submit-tts failed reason=${reason} message=${message}`,
err instanceof Error && err.stack ? `\n${err.stack}` : "",
);
return res.status(502).json({ error: reason, message });
}
});
app.post("/v1/accept", async (req: Request, res: Response) => {
if (!resolved) {
return jsonError(

View File

@@ -2,25 +2,32 @@
* buyer/src/ui/components/PiperTTSForm.tsx — capability-specific form
* for `audio.synthesize.piper.v1` suppliers.
*
* The form posts directly to the buyer-app's `/v1/synth-speech` proxy,
* which forwards to the openedai-speech-min PiperTTS host. Path B of the
* multi-capability story: we render a Piper-shaped UI now (text + voice +
* format + speed) without going through escrow. When the marketplace TTS-
* supplier adapter lands, this same component is reused unchanged — only
* the server endpoint swaps from /v1/synth-speech to a /v1/submit-tts.
* Two modes, controlled by whether `advertRef` is provided:
* - **Marketplace mode** (advertRef + payment_lovelace given): posts to
* `/v1/submit-tts`, which runs the full PostEscrow → supplier inference
* → on-chain Submit lifecycle. Returns base64 audio + signed receipt.
* - **Demo mode** (no advertRef): falls back to `/v1/synth-speech`, the
* direct proxy that bypasses escrow. Used only for the synthetic
* "PiperTTS demo" tile when no real on-chain TTS supplier is present.
*
* UX notes:
* - Voices and formats are hard-coded to what openedai-speech-min actually
* honours. If a future Piper deploy adds voices, expose them via a
* /v1/synth-speech-voices listing call instead of free-typing here.
* - We render a native <audio controls> on success so the user can play
* without leaving the page, plus a Download anchor whose href is the
* same blob URL (the audio buffer is materialised once and reused).
* - Blob URLs are revoked on unmount + on each new submit to avoid
* accumulating leaked memory across many demo runs.
* honours.
* - We render a native <audio controls> on success + a Download anchor.
* - Blob URLs are revoked on unmount + on each new submit.
*/
import { useEffect, useRef, useState } from "react";
import type { OutputReference } from "@marketplace/shared/chain";
export interface PiperTTSFormProps {
/** When set, the form runs the full marketplace lifecycle via
* /v1/submit-tts. When undefined, the form falls back to /v1/synth-speech
* (the path-B direct proxy). */
advertRef?: OutputReference;
/** Required iff advertRef is set; matches the supplier's advertised price. */
payment_lovelace?: bigint;
}
const VOICES = [
{ value: "nova", label: "Nova (default — feminine, warm)" },
@@ -46,7 +53,7 @@ interface AudioResult {
byteLength: number;
}
export default function PiperTTSForm(): JSX.Element {
export default function PiperTTSForm({ advertRef, payment_lovelace }: PiperTTSFormProps = {}): JSX.Element {
const [text, setText] = useState("");
const [voice, setVoice] = useState("nova");
const [format, setFormat] = useState("mp3");
@@ -78,23 +85,62 @@ export default function PiperTTSForm(): JSX.Element {
setResult(null);
try {
let blob: Blob;
if (advertRef && payment_lovelace !== undefined) {
// Marketplace mode: full escrow lifecycle. Server returns JSON with
// base64 audio + a signed receipt. We decode the audio into a blob
// here and let it flow through the same UI path as demo mode.
const resp = await fetch("/v1/submit-tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
advert_ref: `${advertRef.txHash}#${advertRef.index}`,
text: text.trim(),
voice,
format,
speed,
payment_lovelace: payment_lovelace.toString(),
}),
});
if (!resp.ok) {
let errMsg = `${resp.status} ${resp.statusText}`;
try {
const j = (await resp.json()) as { error?: string; message?: string };
if (j.error || j.message) errMsg = `${j.error ?? "error"}: ${j.message ?? ""}`;
} catch { /* keep status fallback */ }
throw new Error(errMsg);
}
const j = (await resp.json()) as {
audio_b64: string;
format: string;
content_type: string;
};
// atob → bytes → Blob. Avoids a base64-decoder dependency since the
// SPA already runs in a browser; large strings would warrant
// streaming, but a single sentence is ≤200 KB.
const bin = atob(j.audio_b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
blob = new Blob([bytes], { type: j.content_type });
} else {
// Demo mode: direct proxy to PiperTTS host (no escrow).
const resp = await fetch("/v1/synth-speech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), voice, format, speed }),
});
if (!resp.ok) {
// Try to parse JSON error; fall through to status text on non-JSON.
let errMsg = `${resp.status} ${resp.statusText}`;
try {
const j = (await resp.json()) as { error?: string; message?: string };
if (j.error || j.message) errMsg = `${j.error ?? "error"}: ${j.message ?? ""}`;
} catch {
/* keep status fallback */
}
} catch { /* keep status fallback */ }
throw new Error(errMsg);
}
const blob = await resp.blob();
blob = await resp.blob();
}
const url = URL.createObjectURL(blob);
activeBlobUrl.current = url;
setResult({ blobUrl: url, format, byteLength: blob.size });

View File

@@ -153,6 +153,12 @@ function renderCapabilityForm(
return <PromptForm advertRef={advertRef} payment_lovelace={payment_lovelace} />;
case "audio.synthesize.piper.v1":
// Real on-chain TTS supplier → marketplace mode (full escrow). The
// synthetic demo supplier has utxo_ref="demo:piper-tts" which fails
// parseUtxoRef → advertRef is null → form falls back to demo mode.
if (advertRef) {
return <PiperTTSForm advertRef={advertRef} payment_lovelace={payment_lovelace} />;
}
return <PiperTTSForm />;
default:

View File

@@ -0,0 +1,225 @@
/**
* tx/escrow/postTtsEscrow.ts — PostEscrow tx builder for the
* `audio.synthesize.piper.v1` capability.
*
* Mirrors `postEscrow.ts` (chat) but commits a TTS-shaped prompt_hash
* over the canonicalised request envelope `{text, voice, format, speed}`
* — the SAME object the supplier hashes when validating the incoming
* POST /v1/audio/synthesize body. Any mismatch on either side fails the
* supplier-side prompt_mismatch gate.
*
* Off-chain invariants (mirror chat's, with TTS-shaped body validation):
* 1. advert UTxO exists at advertRef
* 2. advert datum.status === "Active"
* 3. payment_lovelace === advert.price_lovelace
* 4. buyerKey.pubKeyHash !== advert.supplier_pkh
* 5. body.text is a non-empty string
* 6. body.voice ∈ ALLOWED_VOICES
* 7. body.format ∈ ALLOWED_FORMATS
* 8. body.speed is finite and ∈ [0.5, 1.5]
*/
import * as nodeCrypto from "crypto";
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
import type { EscrowDatum } from "../../cbor/types.js";
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
import { canonicalize } from "../../cbor/canonical.js";
import type { WalletKey, PostEscrowBuildResult } from "../types.js";
import { TxConstructionError } from "../types.js";
import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
/** TTS request envelope. The buyer-side hash and the supplier-side hash
* MUST agree on this exact set of keys, in this exact JCS-canonical
* encoding, or the supplier will reject with `prompt_mismatch`. */
export interface TtsRequest {
text: string;
voice: string;
format: string;
speed: number;
}
export const ALLOWED_TTS_VOICES = new Set([
"alloy", "echo", "fable", "onyx", "nova", "shimmer", "lessac",
]);
export const ALLOWED_TTS_FORMATS = new Set(["mp3", "wav", "opus", "aac", "flac"]);
export interface PostTtsEscrowParams {
chain: ChainProvider;
buyerKey: WalletKey;
advertRef: OutputReference;
request: TtsRequest;
payment_lovelace: bigint;
}
function sha256Utf8Hex(s: string): string {
return nodeCrypto.createHash("sha256").update(s, "utf8").digest("hex");
}
/** Hash function shared between buyer (here) and supplier (server.ts).
* Exported so unit tests on either side can pin the canonicalisation. */
export function ttsPromptHash(req: TtsRequest): string {
return sha256Utf8Hex(canonicalize({
text: req.text,
voice: req.voice,
format: req.format,
speed: req.speed,
}));
}
export async function buildPostTtsEscrowTx(
params: PostTtsEscrowParams,
): Promise<PostEscrowBuildResult> {
const { chain, buyerKey, advertRef, request, payment_lovelace } = params;
// 5. Body validation.
if (typeof request?.text !== "string" || request.text.length === 0) {
throw new TxConstructionError("text required",
"request.text must be a non-empty string");
}
if (!ALLOWED_TTS_VOICES.has(request.voice)) {
throw new TxConstructionError("voice invalid",
`voice must be one of: ${[...ALLOWED_TTS_VOICES].join(", ")}`);
}
if (!ALLOWED_TTS_FORMATS.has(request.format)) {
throw new TxConstructionError("format invalid",
`format must be one of: ${[...ALLOWED_TTS_FORMATS].join(", ")}`);
}
if (!Number.isFinite(request.speed) || request.speed < 0.5 || request.speed > 1.5) {
throw new TxConstructionError("speed out of range",
"speed must be a finite number in [0.5, 1.5]");
}
// 1. Advert UTxO must exist.
const advertUtxo = await chain.queryUtxo(advertRef);
if (advertUtxo === null) {
throw new TxConstructionError(
"advert ref not on chain",
`no UTxO at ${advertRef.txHash}#${advertRef.index}`,
);
}
if (!advertUtxo.datumHex) {
throw new TxConstructionError(
"advert datum missing",
`UTxO ${advertRef.txHash}#${advertRef.index} has no inline datum`,
);
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
// 2. Advert must be Active.
if (advertDatum.status !== "Active") {
throw new TxConstructionError(
"advert is retired",
`advert.status is ${advertDatum.status}, expected Active`,
);
}
// 3. Payment must equal advertised price.
if (payment_lovelace !== advertDatum.price_lovelace) {
throw new TxConstructionError(
"payment must equal advertised price",
`payment ${payment_lovelace} != advert.price ${advertDatum.price_lovelace}`,
);
}
// 4. Buyer cannot be supplier.
if (buyerKey.pubKeyHash === advertDatum.supplier_pkh) {
throw new TxConstructionError(
"buyer cannot be supplier",
`buyerKey pkh ${buyerKey.pubKeyHash} equals advert.supplier_pkh`,
);
}
// Time source: same convention as chat.
const tipSlot = await chain.tip();
const isLive = detectCborBackend(chain) === "live";
const postedAt = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
const deliverBy = postedAt + advertDatum.max_processing_ms + NETWORK_BUFFER_MS;
const requestSpecCanonical = canonicalize({
capability_id: advertDatum.capability_id,
max_output_tokens: advertDatum.max_output_tokens,
model: advertDatum.model,
});
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = ttsPromptHash(request);
const totalLocked =
advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace;
const escrowDatum: EscrowDatum = {
buyer_pkh: buyerKey.pubKeyHash,
supplier_pkh: advertDatum.supplier_pkh,
advert_ref: advertRef,
capability_id: advertDatum.capability_id,
request_spec_hash: requestSpecHash,
prompt_hash: promptHash,
payment_lovelace: advertDatum.price_lovelace,
buyer_bond_lovelace: advertDatum.buyer_bond_lovelace,
supplier_bond_lovelace: advertDatum.supplier_bond_lovelace,
deliver_by: deliverBy,
posted_at: postedAt,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
// Live path: reuse the chat builder. The live cbor path is already
// capability-agnostic — `messages` is `void`-marked there. We pass an
// empty array so the type checker is satisfied; the actual on-chain
// commitment is the escrowDatum (which carries our TTS prompt_hash).
if (isLive) {
const liveCborPath = "../internal/liveCbor.js";
const { buildLiveTxForEscrow } = await import(/* @vite-ignore */ liveCborPath);
return buildLiveTxForEscrow({
chain: chain as LiveOgmiosProvider,
buyerKey,
advertRef,
messages: [],
escrowDatum,
totalLocked,
deliverBy,
postedAt,
});
}
// Mock backend path (synthetic JSON-in-hex tx body).
const blueprint = loadBlueprint();
const escrowAddress = blueprint.escrowScriptAddress(0);
const body = {
type: "post-escrow",
inputs: [],
outputs: [
{
ref: { txHash: "$self", index: 0 },
address: escrowAddress,
lovelace: totalLocked,
assets: {},
datumHex: encodeEscrowDatum(escrowDatum),
scriptRef: null,
},
],
requiredSigners: [buyerKey.pubKeyHash],
validityRange: { lowerBoundMs: postedAt, upperBoundMs: deliverBy },
meta: { script_hash: blueprint.escrowScriptHash, advert_ref: advertRef },
};
const txCborHex = encodeTxBody(body);
const expectedTxHash = sha256Hex(txCborHex);
await chain.submitTx(txCborHex);
return {
txCborHex,
expectedTxHash,
escrowOutputRef: { txHash: expectedTxHash, index: 0 },
};
}

View File

@@ -22,6 +22,13 @@ export { buildRetireAdvertTx } from "./advert/retireAdvert.js";
// Escrow builders
export type { PostEscrowParams } from "./escrow/postEscrow.js";
export { buildPostEscrowTx } from "./escrow/postEscrow.js";
export type { PostTtsEscrowParams, TtsRequest } from "./escrow/postTtsEscrow.js";
export {
buildPostTtsEscrowTx,
ttsPromptHash,
ALLOWED_TTS_VOICES,
ALLOWED_TTS_FORMATS,
} from "./escrow/postTtsEscrow.js";
export type { ClaimParams } from "./escrow/claim.js";
export { buildClaimTx } from "./escrow/claim.js";
export type { SubmitParams } from "./escrow/submit.js";