From 5d2397d79bc95275249727ff370b51fe6c68f4d7 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 8 Jun 2026 17:36:06 +0200 Subject: [PATCH] fix(buyer): endChat must not query the spent Open escrow UTxO End chat 502'd with "escrow ref not on chain": endChat queried the original Open escrow ref, but the supplier's Claim already spent it (Open->Claimed), so queryUtxo returned null before the supplier was ever called. Now endChat uses the supplier endpoint cached by the buyer-app at startChat, resolves supplier identity from /capability for verification, and Accepts the Submitted UTxO the supplier returns. Adds supplierBaseUrl to EndChatOptions; the /v1/chat/end route passes it from chatRouteState (404 if the session is gone). Co-Authored-By: Claude Opus 4.8 (1M context) --- buyer/src/sdk/Marketplace.ts | 40 +++++++++++++++++++++--------------- buyer/src/sdk/types.ts | 4 ++++ buyer/src/server.ts | 12 ++++++++++- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/buyer/src/sdk/Marketplace.ts b/buyer/src/sdk/Marketplace.ts index ce3e96e..835fd51 100644 --- a/buyer/src/sdk/Marketplace.ts +++ b/buyer/src/sdk/Marketplace.ts @@ -885,22 +885,15 @@ export class Marketplace extends EventEmitterBase { * Submitted escrow — which is when the user is actually charged. */ async endChat(opts: EndChatOptions): Promise { - const { escrowRef, sessionNonce, transcript } = opts; + const { escrowRef, sessionNonce, transcript, supplierBaseUrl } = 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 }); + // The original Open escrow UTxO has ALREADY been spent by the supplier's + // Claim (Open→Claimed) by the time End runs, so we must NOT queryUtxo it + // (that was the cause of the "escrow ref not on chain" 502). Use the + // supplier endpoint the buyer-app cached from startChat, and resolve + // supplier identity (pkh, model) from /capability for verification. + const supplierHttp = new HttpClient({ baseUrl: supplierBaseUrl, fetch: this.fetchImpl }); let endRes; try { endRes = await supplierHttp.postJson("/v1/chat/end", {}, { headers: { "X-Escrow-Ref": escrowRefStr } }); @@ -931,8 +924,23 @@ export class Marketplace extends EventEmitterBase { const receipt = endBody.receipt; const receiptSignature = endBody.receipt_signature; + // ── Resolve supplier identity from /capability for verification ─────── + let capSupplierPkh = ""; + let capModel = ""; + try { + const capRes = await supplierHttp.getJson("/capability"); + if (capRes.body && typeof capRes.body === "object") { + capSupplierPkh = (capRes.body as { supplier_pkh?: string }).supplier_pkh ?? ""; + capModel = (capRes.body as { model?: string }).model ?? ""; + } + } catch { + /* best-effort; the escrow_ref + prompt_hash + signature checks below, + plus the on-chain Accept (which routes funds per the Submitted datum), + still hold even if /capability is briefly unavailable */ + } + // ── Verify receipt ─────────────────────────────────────────────────── - if (receipt.supplier_pkh !== escrowDatum.supplier_pkh) { + if (capSupplierPkh && receipt.supplier_pkh !== capSupplierPkh) { throw new ReceiptVerificationError("wrong_supplier"); } if (receipt.escrow_ref !== escrowRefStr) { @@ -942,7 +950,7 @@ export class Marketplace extends EventEmitterBase { if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) { throw new ReceiptVerificationError("prompt_hash_mismatch"); } - if (receipt.model !== advertDatum.model) { + if (capModel && receipt.model !== capModel) { throw new ReceiptVerificationError("request_spec_hash_mismatch"); } if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) { diff --git a/buyer/src/sdk/types.ts b/buyer/src/sdk/types.ts index 4a0e149..17ca788 100644 --- a/buyer/src/sdk/types.ts +++ b/buyer/src/sdk/types.ts @@ -213,6 +213,10 @@ export interface StartChatResult { export interface EndChatOptions { escrowRef: OutputReference; sessionNonce: string; + /** Supplier endpoint_url cached by the buyer-app from startChat. Required + * because the Open escrow UTxO is already spent (Claimed) by End time, so we + * resolve the supplier via its cached URL + /capability rather than chain. */ + supplierBaseUrl: string; /** The browser's local transcript mirror, used to verify response_hash. */ transcript?: import("@marketplace/shared/tx").ChatMessage[]; } diff --git a/buyer/src/server.ts b/buyer/src/server.ts index 8953750..065bfc9 100644 --- a/buyer/src/server.ts +++ b/buyer/src/server.ts @@ -881,10 +881,20 @@ export function createApp(deps: AppDeps): Express { const transcript = Array.isArray(body.transcript) ? (body.transcript as ChatMessage[]) : undefined; + const state = chatRouteState.get(rawRef); + if (!state) { + return jsonError(res, 404, "chat_session_not_found", + "no active chat session for this escrow (the buyer-app may have restarted); please start a new chat"); + } const m = ESCROW_REF_RE.exec(rawRef)!; const escrowRef = { txHash: m[1], index: Number(m[2]) }; try { - const result = await deps.marketplace.endChat({ escrowRef, sessionNonce, transcript }); + const result = await deps.marketplace.endChat({ + escrowRef, + sessionNonce, + transcript, + supplierBaseUrl: state.supplierBaseUrl, + }); chatRouteState.delete(rawRef); return res.status(200).json({ status: "accepted",