/** * buyer/src/ui/pages/TaskHistory.tsx — full lifecycle history for this buyer. * * Two data sources joined client-side: * 1. /v1/indexer/escrows — chain state of every lifecycle this buyer * participated in (Open → Claimed → Submitted → Accepted/etc). * 2. /v1/responses — buyer-app's persistent archive (request + * response artefacts on disk + signed receipt + sha256 of response * bytes pre-committed via the on-chain Submit tx). * * Match key: lifecycle's first row (the original PostEscrow output ref) == * archive row's `escrow_ref`. Lifecycles that completed get inline response * rendering (audio player for TTS, JSON pretty-print for chat) plus a * Verify-hash button that computes sha256 over the response bytes and * compares to `receipt.response_hash`. * * The verify button only checks bytes-match-hash; it does NOT verify the * supplier's Ed25519 signature on the receipt. That requires fetching the * supplier's pub_key_hex from /capability and is deferred to a later * iteration of this page. */ import { useEffect, useState, useMemo } from "react"; import { useMarketplace } from "../state/MarketplaceContext.js"; interface IndexerEscrowRow { utxo_ref: string; buyer_pkh: string; supplier_pkh: string; advert_ref: string; capability_id: string; prompt_hash: string; payment_lovelace: string; buyer_bond_lovelace: string; supplier_bond_lovelace: string; posted_at: number; submitted_at: number | null; result_receipt_hash: string | null; state: string; created_slot: number; } interface ArchiveReceipt { prompt_hash: string; response_hash: string; model: string; prompt_tokens: number; completion_tokens: number; wallclock_ms: number; supplier_pkh: string; escrow_ref: string; } interface ArchiveRow { escrow_ref: string; posted_at: number; completed_at: number; capability_id: string; supplier_pkh: string; model: string; payment_lovelace: string; response_content_type: string; response_byte_length: number; receipt: ArchiveReceipt; receipt_signature: string; } interface Lifecycle { posted_at: number; supplier_pkh: string; capability_id: string; payment_lovelace: string; buyer_bond_lovelace: string; supplier_bond_lovelace: string; advert_ref: string; prompt_hash: string; rows: IndexerEscrowRow[]; currentState: string; /** archive entry keyed by the original PostEscrow utxo_ref. May be null * for in-flight or pre-archive lifecycles. */ archive: ArchiveRow | null; } const STATE_PRIORITY: Record = { Open: 0, Claimed: 1, Submitted: 2, Accepted: 3, Reclaimed: 3, Released: 3, }; function txHashFromRef(ref: string): string { const hash = ref.split("#")[0] ?? ""; return hash.length >= 12 ? `${hash.slice(0, 8)}…${hash.slice(-4)}` : hash; } function fmtAda(lovelace: string): string { try { const n = BigInt(lovelace); const ada = Number(n) / 1_000_000; return `${ada.toFixed(2)} AP3X`; } catch { return lovelace; } } function fmtTime(ms: number): string { if (!ms) return "—"; try { return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + " UTC"; } catch { return String(ms); } } function statePill(state: string): string { switch (state) { case "Accepted": return "bg-green-100 text-green-800"; case "Submitted": return "bg-indigo-100 text-indigo-800"; case "Claimed": return "bg-blue-100 text-blue-800"; case "Open": return "bg-yellow-100 text-yellow-800"; case "Reclaimed": return "bg-orange-100 text-orange-800"; case "Released": return "bg-red-100 text-red-800"; default: return "bg-gray-200 text-gray-700"; } } function refToUrlSafe(escrowRef: string): string { return escrowRef.replace("#", "_"); } function groupByLifecycle( rows: IndexerEscrowRow[], archive: Map, ): Lifecycle[] { const buckets = new Map(); for (const r of rows) { const key = `${r.posted_at}:${r.supplier_pkh}`; const arr = buckets.get(key); if (arr) arr.push(r); else buckets.set(key, [r]); } const out: Lifecycle[] = []; for (const [, lcRows] of buckets) { lcRows.sort((a, b) => a.created_slot - b.created_slot); const first = lcRows[0]; // original PostEscrow output ref — archive key const last = lcRows[lcRows.length - 1]; const top = lcRows.reduce((acc, r) => (STATE_PRIORITY[r.state] ?? 0) > (STATE_PRIORITY[acc.state] ?? 0) ? r : acc, last); out.push({ posted_at: last.posted_at, supplier_pkh: last.supplier_pkh, capability_id: last.capability_id, payment_lovelace: last.payment_lovelace, buyer_bond_lovelace: last.buyer_bond_lovelace, supplier_bond_lovelace: last.supplier_bond_lovelace, advert_ref: last.advert_ref, prompt_hash: last.prompt_hash, rows: lcRows, currentState: top.state, archive: archive.get(first.utxo_ref) ?? null, }); } out.sort((a, b) => b.posted_at - a.posted_at); return out; } // SubtleCrypto sha256 → 32-byte hex string. async function sha256Hex(bytes: ArrayBuffer): Promise { const buf = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** * For chat: receipt.response_hash = sha256_utf8(canonicalize({role:"assistant", * content: ...})). The archive's response.json contains EXACTLY those bytes * (the canonical form, not pretty-printed) so a sha256 over the file bytes * yields the same hex as the receipt. * * For TTS: receipt.response_hash = sha256(audio_bytes). The archive's * response.{mp3,wav,…} is the raw bytes. * * Either way, this function is a single sha256-over-bytes comparison. */ async function verifyResponseHash( escrowRef: string, expectedHash: string, ): Promise<{ ok: boolean; computed: string }> { const resp = await fetch(`/v1/responses/${refToUrlSafe(escrowRef)}/response`); if (!resp.ok) throw new Error(`fetch /response failed: ${resp.status}`); const buf = await resp.arrayBuffer(); const computed = await sha256Hex(buf); return { ok: computed === expectedHash, computed }; } export default function TaskHistory() { const marketplace = useMarketplace(); const buyerPkh = marketplace.getWalletKey().pubKeyHash; const [rows, setRows] = useState(null); const [archive, setArchive] = useState>(new Map()); const [error, setError] = useState(null); const load = async (): Promise => { if (!buyerPkh) { setError("buyer pkh missing — boot script not injected"); setRows([]); return; } setError(null); try { const [escrowsResp, archResp] = await Promise.all([ fetch(`/v1/indexer/escrows?buyer=${buyerPkh}`), fetch(`/v1/responses?limit=200`), ]); if (!escrowsResp.ok) { throw new Error(`indexer responded ${escrowsResp.status} ${escrowsResp.statusText}`); } const body = (await escrowsResp.json()) as IndexerEscrowRow[]; setRows(Array.isArray(body) ? body : []); // Archive endpoint may be 503 if the buyer-app booted without // ARCHIVE_DIR — that's not fatal; we just render lifecycles // without inline response bodies. if (archResp.ok) { const archBody = (await archResp.json()) as { responses: ArchiveRow[] }; const m = new Map(); for (const r of archBody.responses ?? []) m.set(r.escrow_ref, r); setArchive(m); } else { setArchive(new Map()); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); setRows([]); } }; useEffect(() => { void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [buyerPkh]); const lifecycles = useMemo( () => (rows === null ? [] : groupByLifecycle(rows, archive)), [rows, archive], ); return (

Tasks

{error && (
Failed to load history: {error}
)} {rows === null ? (

loading…

) : lifecycles.length === 0 ? (

No on-chain lifecycles yet for this buyer. Submit a prompt from the Dashboard to start one.

) : (
    {lifecycles.map((lc) => ( ))}
)}
); } function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element { return (
  • {fmtTime(lc.posted_at)}
    {lc.currentState}
    {lc.capability_id} {lc.archive && ( <> · model: {lc.archive.model} )} · {fmtAda(lc.payment_lovelace)} payment · {fmtAda(lc.buyer_bond_lovelace)} bond · supplier {lc.supplier_pkh.slice(0, 12)}…
    {lc.archive && }
    {lc.rows.map((r) => (
    {r.state} {txHashFromRef(r.utxo_ref)} slot {r.created_slot}
    ))}
  • ); } function ArchivePanel({ row }: { row: ArchiveRow }): JSX.Element { const isAudio = row.response_content_type.startsWith("audio/"); const responseUrl = `/v1/responses/${refToUrlSafe(row.escrow_ref)}/response`; const requestUrl = `/v1/responses/${refToUrlSafe(row.escrow_ref)}/request`; const [requestText, setRequestText] = useState(null); const [chatText, setChatText] = useState(null); const [verifyResult, setVerifyResult] = useState< | { state: "idle" } | { state: "running" } | { state: "ok"; computed: string } | { state: "mismatch"; computed: string; expected: string } | { state: "error"; message: string } >({ state: "idle" }); // Always fetch the request envelope so we can display it above the response. // It's small JSON (chat messages array OR TTS body), no streaming worry. useEffect(() => { let cancelled = false; (async () => { try { const resp = await fetch(requestUrl); if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`); const text = await resp.text(); if (!cancelled) setRequestText(text); } catch (err) { if (!cancelled) setRequestText(`(failed to load: ${err instanceof Error ? err.message : String(err)})`); } })(); return () => { cancelled = true; }; }, [requestUrl]); // For chat, fetch the JSON response so we can render the assistant text inline. // For audio, the