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) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-08 17:36:06 +02:00
parent 5ddc47f1f4
commit 5d2397d79b
3 changed files with 39 additions and 17 deletions

View File

@@ -885,22 +885,15 @@ export class Marketplace extends EventEmitterBase {
* Submitted escrow — which is when the user is actually charged.
*/
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
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) {

View File

@@ -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[];
}

View File

@@ -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",