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:
@@ -885,22 +885,15 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
* Submitted escrow — which is when the user is actually charged.
|
* Submitted escrow — which is when the user is actually charged.
|
||||||
*/
|
*/
|
||||||
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
|
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
|
||||||
const { escrowRef, sessionNonce, transcript } = opts;
|
const { escrowRef, sessionNonce, transcript, supplierBaseUrl } = opts;
|
||||||
const escrowRefStr = refToString(escrowRef);
|
const escrowRefStr = refToString(escrowRef);
|
||||||
|
|
||||||
// Resolve escrow → advert for endpoint_url, supplier_pkh, model, pub key.
|
// The original Open escrow UTxO has ALREADY been spent by the supplier's
|
||||||
const escrowUtxo = await this.chain.queryUtxo(escrowRef);
|
// Claim (Open→Claimed) by the time End runs, so we must NOT queryUtxo it
|
||||||
if (!escrowUtxo || !escrowUtxo.datumHex) {
|
// (that was the cause of the "escrow ref not on chain" 502). Use the
|
||||||
throw new TxConstructionError("escrow ref not on chain", `no escrow UTxO at ${escrowRefStr}`);
|
// supplier endpoint the buyer-app cached from startChat, and resolve
|
||||||
}
|
// supplier identity (pkh, model) from /capability for verification.
|
||||||
const escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
|
const supplierHttp = new HttpClient({ baseUrl: supplierBaseUrl, fetch: this.fetchImpl });
|
||||||
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 });
|
|
||||||
let endRes;
|
let endRes;
|
||||||
try {
|
try {
|
||||||
endRes = await supplierHttp.postJson("/v1/chat/end", {}, { headers: { "X-Escrow-Ref": escrowRefStr } });
|
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 receipt = endBody.receipt;
|
||||||
const receiptSignature = endBody.receipt_signature;
|
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 ───────────────────────────────────────────────────
|
// ── Verify receipt ───────────────────────────────────────────────────
|
||||||
if (receipt.supplier_pkh !== escrowDatum.supplier_pkh) {
|
if (capSupplierPkh && receipt.supplier_pkh !== capSupplierPkh) {
|
||||||
throw new ReceiptVerificationError("wrong_supplier");
|
throw new ReceiptVerificationError("wrong_supplier");
|
||||||
}
|
}
|
||||||
if (receipt.escrow_ref !== escrowRefStr) {
|
if (receipt.escrow_ref !== escrowRefStr) {
|
||||||
@@ -942,7 +950,7 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) {
|
if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) {
|
||||||
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
||||||
}
|
}
|
||||||
if (receipt.model !== advertDatum.model) {
|
if (capModel && receipt.model !== capModel) {
|
||||||
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
||||||
}
|
}
|
||||||
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) {
|
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) {
|
||||||
|
|||||||
@@ -213,6 +213,10 @@ export interface StartChatResult {
|
|||||||
export interface EndChatOptions {
|
export interface EndChatOptions {
|
||||||
escrowRef: OutputReference;
|
escrowRef: OutputReference;
|
||||||
sessionNonce: string;
|
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. */
|
/** The browser's local transcript mirror, used to verify response_hash. */
|
||||||
transcript?: import("@marketplace/shared/tx").ChatMessage[];
|
transcript?: import("@marketplace/shared/tx").ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -881,10 +881,20 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
const transcript = Array.isArray(body.transcript)
|
const transcript = Array.isArray(body.transcript)
|
||||||
? (body.transcript as ChatMessage[])
|
? (body.transcript as ChatMessage[])
|
||||||
: undefined;
|
: 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 m = ESCROW_REF_RE.exec(rawRef)!;
|
||||||
const escrowRef = { txHash: m[1], index: Number(m[2]) };
|
const escrowRef = { txHash: m[1], index: Number(m[2]) };
|
||||||
try {
|
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);
|
chatRouteState.delete(rawRef);
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
|
|||||||
Reference in New Issue
Block a user