Files
agents-marketplace/tests/unit/buyer-sdk-submit-prompt-reject.test.ts

560 lines
24 KiB
TypeScript

/**
* buyer-sdk-submit-prompt-reject.test.ts — RED phase (M1-E)
*
* Category C: Marketplace.submitPrompt() rejection / error paths (~20 tests)
*
* All tests FAIL until M1-E-green.
*
* Each test seeds appropriate chain state and verifies the SDK throws the
* right error class with the right `.reason` string.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHash } from "crypto";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import { HttpError } from "../../buyer/src/sdk/httpClient.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
import {
ReceiptVerificationError,
SupplierError,
} from "../../buyer/src/sdk/types.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
import { encodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
import { buildReceipt } from "../../packages/shared/src/receipt/build.js";
import { signReceipt } from "../../packages/shared/src/receipt/sign.js";
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
import type { ChatMessage } from "../../packages/shared/src/tx/types.js";
import type { ProgressEvent } from "../../buyer/src/sdk/types.js";
import { BOUNDED_INPUT_DETAIL_MARKER } from "../../packages/shared/src/tx/inputBound.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const ADVERT_TX = "b".repeat(64);
const ADVERT_REF: OutputReference = { txHash: ADVERT_TX, index: 0 };
const ADVERT_SCRIPT_ADDR = "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6";
const buyer = buildBuyerWalletKey();
const supplier = buildSupplierWalletKey();
const SAMPLE_MESSAGES: ChatMessage[] = [{ role: "user", content: "What is 2+2?" }];
const PAYMENT = 2_000_000n;
function sha256(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
function makeActiveAdvert(overrides: Partial<AdvertDatum> = {}): AdvertDatum {
return {
supplier_pkh: supplier.pubKeyHash,
capability_id: "llm.text.generate.v1",
model: "qwen2.5:0.5b",
max_output_tokens: 512,
max_processing_ms: 60_000,
price_lovelace: PAYMENT,
supplier_bond_lovelace: 1_000_000n,
buyer_bond_lovelace: 1_000_000n,
endpoint_url: "https://supplier.example.com",
detail_uri: "ipfs://Qm000",
detail_hash: "a".repeat(64),
advertised_at: 1_745_500_000_000,
status: "Active",
...overrides,
};
}
function seedAdvertUtxo(chain: MockChainProvider, datum: AdvertDatum, ref = ADVERT_REF) {
const utxo: Utxo = {
ref,
address: ADVERT_SCRIPT_ADDR,
lovelace: 2_000_000n,
assets: {},
datumHex: encodeAdvertDatum(datum),
scriptRef: null,
};
chain.seed(utxo);
}
function makeValidSupplierResponse(escrowRef: string, messages: ChatMessage[]) {
const advert = makeActiveAdvert();
const promptHash = sha256(canonicalize(messages));
const responseContent = "4";
const responseHash = sha256(JSON.stringify({ role: "assistant", content: responseContent }));
const receipt = buildReceipt({
prompt_hash: promptHash,
response_hash: responseHash,
model: advert.model,
prompt_tokens: 12,
completion_tokens: 4,
wallclock_ms: 800,
supplier_pkh: supplier.pubKeyHash,
escrow_ref: escrowRef,
});
const signed = signReceipt(receipt, supplier.privateKeyHex);
return {
choices: [{ message: { role: "assistant", content: responseContent }, finish_reason: "stop" }],
usage: { prompt_tokens: 12, completion_tokens: 4 },
receipt: signed.receipt,
receipt_signature: signed.signature,
};
}
function jsonFetch(body: unknown, status = 200) {
return Promise.resolve(
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } })
);
}
function makeMarketplace(chain: MockChainProvider, fetchImpl: ReturnType<typeof vi.fn>): Marketplace {
return new Marketplace({
chain,
indexerUrl: "http://indexer.test",
walletKey: buyer,
networkParams: { networkId: 0 },
_fetch: fetchImpl as unknown as typeof fetch,
} as never);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Marketplace.submitPrompt() — rejection paths", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
let chain: MockChainProvider;
beforeEach(() => {
fetchSpy = vi.fn();
chain = new MockChainProvider();
chain.advanceSlot(1_745_500_000);
});
// 1. advert UTxO not found
it("throws TxConstructionError('advert ref not on chain') when advert UTxO does not exist", async () => {
// Do NOT seed any utxo
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof TxConstructionError && e.reason === "advert ref not on chain"
);
});
// 2. advert.status === "Retired"
it("throws TxConstructionError('advert is retired') when advert status is Retired", async () => {
seedAdvertUtxo(chain, makeActiveAdvert({ status: "Retired" }));
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof TxConstructionError && e.reason === "advert is retired"
);
});
// 3. payment !== advert.price
it("throws TxConstructionError('payment must equal advertised price') when payment differs", async () => {
seedAdvertUtxo(chain, makeActiveAdvert({ price_lovelace: 3_000_000n }));
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT }) // 2M != 3M
).rejects.toSatisfy(
(e: unknown) => e instanceof TxConstructionError && e.reason === "payment must equal advertised price"
);
});
// 4. buyer pkh === supplier pkh
it("throws TxConstructionError('buyer cannot be supplier') when buyer pkh equals supplier pkh", async () => {
// Seed advert whose supplier_pkh equals buyer's pubKeyHash
seedAdvertUtxo(chain, makeActiveAdvert({ supplier_pkh: buyer.pubKeyHash }));
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof TxConstructionError && e.reason === "buyer cannot be supplier"
);
});
// 5. empty messages
it("throws TxConstructionError('messages required') when messages array is empty", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: [], payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof TxConstructionError && e.reason === "messages required"
);
});
// 6. supplier returns 4xx
it("throws SupplierError with status 400 when supplier returns 4xx", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
return jsonFetch({ error: "bad request", reason: "capability_mismatch" }, 400);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof SupplierError && (e.status === 400 || e.status === 422)
);
});
// 7. supplier returns 5xx
it("throws SupplierError when supplier returns 503", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
return jsonFetch({ error: "service unavailable", reason: "chain_submit_failed" }, 503);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toBeInstanceOf(SupplierError);
});
// 8. supplier returns malformed body (no receipt field)
it("throws SupplierError(reason='malformed_response') when supplier body has no receipt", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
return jsonFetch({ choices: [{ message: { role: "assistant", content: "hi" } }] }); // no receipt
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof SupplierError && e.reason === "malformed_response"
);
});
// 9. receipt prompt_hash mismatch
it("throws ReceiptVerificationError('prompt_hash_mismatch') when receipt.prompt_hash doesn't match messages", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
const resp = makeValidSupplierResponse(escrowRef, SAMPLE_MESSAGES);
// Tamper the prompt_hash
resp.receipt = { ...resp.receipt, prompt_hash: "f".repeat(64) };
return jsonFetch(resp);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof ReceiptVerificationError && e.reason === "prompt_hash_mismatch"
);
});
// 10. receipt request_spec_hash mismatch
it("throws ReceiptVerificationError('request_spec_hash_mismatch') when receipt request_spec doesn't match advert", async () => {
// The SDK computes request_spec_hash = sha256(canonical({capability_id, max_output_tokens, model}))
// and must verify the escrow datum's request_spec_hash was bound correctly.
// In the mock we tamper by using a supplier that advertises a different model.
// The escrow datum has the correct request_spec_hash; the receipt should bind to it.
// Since the SDK verifies request_spec_hash from the escrow datum, we seed
// an escrow UTxO (after escrow is posted) with a wrong request_spec_hash.
// Simpler approach: return a receipt with a wrong model.
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
// Build a receipt with a wrong request_spec_hash-equivalent (model mismatch)
const wrongSpecHash = sha256(canonicalize({
capability_id: "llm.text.generate.v1",
max_output_tokens: 512,
model: "different-model", // mismatch
}));
const advert = makeActiveAdvert();
const promptHash = sha256(canonicalize(SAMPLE_MESSAGES));
const responseHash = sha256(JSON.stringify({ role: "assistant", content: "4" }));
const receipt = buildReceipt({
prompt_hash: promptHash,
response_hash: responseHash,
model: "different-model",
prompt_tokens: 12,
completion_tokens: 4,
wallclock_ms: 800,
supplier_pkh: supplier.pubKeyHash,
escrow_ref: escrowRef,
});
const signed = signReceipt(receipt, supplier.privateKeyHex);
return jsonFetch({
choices: [{ message: { role: "assistant", content: "4" } }],
receipt: signed.receipt,
receipt_signature: signed.signature,
});
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) =>
e instanceof ReceiptVerificationError && e.reason === "request_spec_hash_mismatch"
);
});
// 11. receipt signature invalid
it("throws ReceiptVerificationError('invalid_signature') when receipt_signature is corrupt", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
const resp = makeValidSupplierResponse(escrowRef, SAMPLE_MESSAGES);
resp.receipt_signature = "0".repeat(128); // zeroed-out invalid signature
return jsonFetch(resp);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof ReceiptVerificationError && e.reason === "invalid_signature"
);
});
// 12. supplier_pkh in receipt ≠ advert.supplier_pkh
it("throws ReceiptVerificationError('wrong_supplier') when receipt.supplier_pkh != advert.supplier_pkh", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
const resp = makeValidSupplierResponse(escrowRef, SAMPLE_MESSAGES);
// Tamper supplier_pkh in the receipt
resp.receipt = { ...resp.receipt, supplier_pkh: buyer.pubKeyHash };
return jsonFetch(resp);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof ReceiptVerificationError && e.reason === "wrong_supplier"
);
});
// 13. escrow_ref in receipt ≠ what was posted
it("throws ReceiptVerificationError('wrong_escrow_ref') when receipt.escrow_ref doesn't match", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
const resp = makeValidSupplierResponse("e".repeat(64) + "#99", SAMPLE_MESSAGES); // wrong ref
return jsonFetch(resp);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof ReceiptVerificationError && e.reason === "wrong_escrow_ref"
);
});
// 14. network timeout
it("throws SupplierError('timeout') when supplier HTTP request times out", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
// Simulate abort/timeout
return Promise.reject(new DOMException("The user aborted a request.", "AbortError"));
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toSatisfy(
(e: unknown) => e instanceof SupplierError && e.reason === "timeout"
);
});
// 15. chain.submitTx fails
it("re-throws chain error and emits progress event 'chain_submit_failed' when chain.submitTx throws", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
const chainError = new Error("chain: connection refused");
vi.spyOn(chain, "submitTx").mockRejectedValue(chainError);
const events: string[] = [];
const mp = makeMarketplace(chain, fetchSpy);
mp.on("progress", (e: ProgressEvent) => events.push(e.type));
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toThrow("chain: connection refused");
expect(events).toContain("chain_submit_failed");
});
// 16. failure path does NOT emit 'receipt_verified'
it("does not emit 'receipt_verified' when supplier returns 5xx", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
return jsonFetch({ error: "internal" }, 500);
}
return jsonFetch({});
});
const events: string[] = [];
const mp = makeMarketplace(chain, fetchSpy);
mp.on("progress", (e: ProgressEvent) => events.push(e.type));
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toBeInstanceOf(SupplierError);
expect(events).not.toContain("receipt_verified");
});
// 17. failure path records task with status: "failed" + reason
it("records task with status 'failed' and failure_reason when supplier returns 5xx", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
return jsonFetch({ error: "internal", reason: "ollama_failure" }, 500);
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toBeInstanceOf(SupplierError);
const history = mp.getTaskHistory();
expect(history).toHaveLength(1);
expect(history[0].status).toBe("failed");
expect(history[0].failure_reason).toBeTruthy();
});
// 18. failure on receipt verification still does NOT emit 'receipt_verified'
it("does not emit 'receipt_verified' when receipt signature is invalid", async () => {
seedAdvertUtxo(chain, makeActiveAdvert());
fetchSpy.mockImplementation((url: unknown, opts: unknown) => {
if (String(url).includes("/v1/chat/completions")) {
const headers = (opts as { headers?: Record<string, string> })?.headers ?? {};
const escrowRef = headers["X-Escrow-Ref"] ?? "x".repeat(64) + "#0";
const resp = makeValidSupplierResponse(escrowRef, SAMPLE_MESSAGES);
resp.receipt_signature = "0".repeat(128);
return jsonFetch(resp);
}
return jsonFetch({});
});
const events: string[] = [];
const mp = makeMarketplace(chain, fetchSpy);
mp.on("progress", (e: ProgressEvent) => events.push(e.type));
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toBeInstanceOf(ReceiptVerificationError);
expect(events).not.toContain("receipt_verified");
});
// 19. TxConstructionError name field
it("TxConstructionError has name='TxConstructionError'", () => {
const e = new TxConstructionError("test reason");
expect(e.name).toBe("TxConstructionError");
expect(e.reason).toBe("test reason");
});
// 20. ReceiptVerificationError name field
it("ReceiptVerificationError has name='ReceiptVerificationError'", () => {
const e = new ReceiptVerificationError("test reason");
expect(e.name).toBe("ReceiptVerificationError");
expect(e.reason).toBe("test reason");
});
it("rejects a bounded-input request before posting escrow", async () => {
const advert = makeActiveAdvert({
detail_uri: `https://supplier.example.com/reseller${BOUNDED_INPUT_DETAIL_MARKER}`,
});
seedAdvertUtxo(chain, advert);
const submitSpy = vi.spyOn(chain, "submitTx");
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).endsWith("/capability")) {
return jsonFetch({
capability_id: advert.capability_id,
model: advert.model,
max_output_tokens: advert.max_output_tokens,
max_processing_ms: advert.max_processing_ms,
max_input_tokens: 1,
price_lovelace: advert.price_lovelace.toString(),
advert_ref: `${ADVERT_REF.txHash}#${ADVERT_REF.index}`,
supplier_pkh: advert.supplier_pkh,
pub_key_hex: supplier.pubKeyHex,
});
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({
advertRef: ADVERT_REF,
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
}),
).rejects.toSatisfy(
(error: unknown) =>
error instanceof TxConstructionError &&
error.reason === "input_cap_exceeded",
);
expect(submitSpy).not.toHaveBeenCalled();
});
});
// ─── M1-F-1: isSyncThrow dead-code removal (RED) ─────────────────────────────
//
// ARCH §9 #10: `HttpError.isSyncThrow` is dead code — production fetch never
// throws synchronously; the flag was a defensive workaround for a now-fixed
// Caroline test fixture. The sentinel return branch in Marketplace.submitPrompt
// must also be removed. These two tests stay RED until Catherine removes both.
//
// Audit note: no existing test in this file relies on the sentinel-return path.
// All tests above (1-20) correctly expect submitPrompt to REJECT on error.
// The sentinel branch (isSyncThrow → resolve with { response: "", receipt: ... })
// is never triggered by any test because the existing fetch mocks return Promises
// (not synchronous throws). Safe to remove without updating any existing test.
describe("M1-F-1: isSyncThrow flag removal", () => {
// 21. isSyncThrow flag does not exist on HttpError
it("RED: HttpError has no isSyncThrow property (flag removed)", () => {
// Will RED until Catherine removes `public readonly isSyncThrow` from HttpError.
// After removal, new HttpError(...) must NOT expose isSyncThrow.
// HttpError is statically imported at top of file.
const err = new HttpError("network", "test error");
expect((err as unknown as Record<string, unknown>).isSyncThrow).toBeUndefined();
});
// 22. Synchronously-throwing fetch rejects (not sentinel-resolves)
it("RED: submitPrompt REJECTS (not resolves) when injected fetch throws synchronously", async () => {
// Will RED until Catherine removes the isSyncThrow sentinel branch.
// Currently: a sync-throwing fetch → sentinel resolve { response: "", ... }.
// After fix: a sync-throwing fetch → HttpError("network", ...) → propagation → REJECT.
const chain = new MockChainProvider();
chain.advanceSlot(1_745_500_000);
seedAdvertUtxo(chain, makeActiveAdvert());
// A fetch that throws SYNCHRONOUSLY (not returning a Promise).
const syncThrowFetch = vi.fn(() => {
throw new Error("sync throw from mock fetch");
});
const mp = makeMarketplace(chain, syncThrowFetch);
// Must REJECT — the sentinel-return path must not exist after M1-F-1-green.
await expect(
mp.submitPrompt({ advertRef: ADVERT_REF, messages: SAMPLE_MESSAGES, payment_lovelace: PAYMENT })
).rejects.toThrow();
});
});