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:
@@ -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> {
|
||||
|
||||
@@ -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().
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:
|
||||
|
||||
225
packages/shared/src/tx/escrow/postTtsEscrow.ts
Normal file
225
packages/shared/src/tx/escrow/postTtsEscrow.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user