diff --git a/gateway/src/ocr/extract.ts b/gateway/src/ocr/extract.ts index 43b5b35..c613695 100644 --- a/gateway/src/ocr/extract.ts +++ b/gateway/src/ocr/extract.ts @@ -42,7 +42,7 @@ import type { GatewayDeps } from "../deps.js"; import type { GatewayStore } from "../db/store.js"; import { requireKey } from "../middleware/apiKeyAuth.js"; import { asyncHandler } from "../middleware/http.js"; -import { selectCandidates } from "../routing/selectSupplier.js"; +import { selectCandidates, type SupplierCandidate } from "../routing/selectSupplier.js"; import { preflight } from "../onchain/preflight.js"; import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js"; import { ensureWalletHealthy } from "../walletHealth.js"; @@ -107,8 +107,12 @@ function parseOcrRequest(body: unknown): ParsedOcrRequest { return { image_b64: image, mime, output_format: rawFormat, model }; } -/** Sanitized venue-attribution headers. Invalid values drop to "". */ -function venueAttribution(req: Request): { venue: string; runUserHash: string } { +/** Sanitized venue-attribution headers. Invalid values drop to "". + * Exported for direct unit testing (I3 review finding) — it is the only + * gate on what lands in the ocr_settled/ocr_failed structured log lines the + * wedge-ledger emitter reads, so it needs coverage independent of the HTTP + * route. */ +export function venueAttribution(req: Request): { venue: string; runUserHash: string } { const venueRaw = req.header("X-Venue") ?? ""; const hashRaw = req.header("X-Run-User-Hash") ?? ""; return { @@ -173,6 +177,16 @@ async function runOcrOneShot( const { config, store, chain, fetchFn } = deps; const capabilityId = config.ocrCapabilityId; + // Hoisted above the try so the catch block can report whatever is known + // even when the failure happens AFTER an escrow was posted (e.g. the + // supplier call or settle step fails) — these fields are the + // reconciliation spine that joins on-chain settlement to platform + // billing, and losing them makes paid-but-failed jobs unreconcilable + // (I2 review finding). + let result: Awaited> | undefined; + let used: SupplierCandidate | undefined; + let escrowRefStr: string | null = null; + try { const candidates = await selectCandidates({ indexerUrl: config.indexerUrl, @@ -210,8 +224,6 @@ async function runOcrOneShot( // Submit. Fall back to the next supplier ONLY on a pre-post // TxConstructionError (no escrow was posted). A SupplierError means the // escrow is already posted — do not retry (the sweeper recovers it). - let result: Awaited> | undefined; - let used: (typeof candidates)[number] | undefined; let lastErr: unknown; for (const cand of candidates) { try { @@ -235,11 +247,11 @@ async function runOcrOneShot( } // Settle: resolve the live Submitted ref, Accept, await confirmation. - const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`; + escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`; const submittedRef = await resolveSubmittedRef({ indexerUrl: config.indexerUrl, buyerPkh: ctx.walletKey.pubKeyHash, - originalRefStr, + originalRefStr: escrowRefStr, fetchFn, }); await acceptAndConfirm(chain, ctx.walletKey, submittedRef); @@ -249,7 +261,7 @@ async function runOcrOneShot( capabilityId, model: used.model, supplierPkh: used.supplierPkh, - escrowRef: originalRefStr, + escrowRef: escrowRefStr, costLovelace: used.priceLovelace, promptTokens: result.usage.prompt_tokens, completionTokens: result.usage.completion_tokens, @@ -261,7 +273,7 @@ async function runOcrOneShot( // caller sent none. console.log(JSON.stringify({ evt: "ocr_settled", - escrow_ref: originalRefStr, + escrow_ref: escrowRefStr, capability_id: capabilityId, model: used.model, supplier_pkh: used.supplierPkh, @@ -286,7 +298,7 @@ async function runOcrOneShot( x_vector: { receipt: result.receipt, receipt_signature: result.receiptSignature, - escrow_ref: originalRefStr, + escrow_ref: escrowRefStr, receipt_labels: RECEIPT_LABELS_V0, }, }); @@ -294,15 +306,33 @@ async function runOcrOneShot( recordOcrUsage(store, { keyId, capabilityId, - model: parsed.model, - supplierPkh: null, - escrowRef: null, - costLovelace: null, - promptTokens: 0, - completionTokens: 0, + model: used?.model ?? parsed.model, + supplierPkh: used?.supplierPkh ?? null, + escrowRef: escrowRefStr, + costLovelace: used?.priceLovelace ?? null, + promptTokens: result?.usage.prompt_tokens ?? 0, + completionTokens: result?.usage.completion_tokens ?? 0, status: "failed", failureReason: err instanceof Error ? err.message : String(err), }); + + // Structured failure line mirroring ocr_settled's shape (same venue / + // run_user_hash / escrow_ref fields) so paid-but-failed jobs — an + // escrow posted on-chain, then a later step (supplier call, settle) + // failed — can still be reconciled against on-chain state instead of + // vanishing from the wedge-ledger emitter's input (I2 review finding). + console.log(JSON.stringify({ + evt: "ocr_failed", + escrow_ref: escrowRefStr, + capability_id: capabilityId, + model: used?.model ?? parsed.model, + supplier_pkh: used?.supplierPkh ?? null, + venue: attribution.venue, + run_user_hash: attribution.runUserHash, + error: err instanceof Error ? err.message : String(err), + ts: new Date().toISOString(), + })); + throw err; } } diff --git a/gateway/src/onchain/preflight.ts b/gateway/src/onchain/preflight.ts index 8ede3a7..3fe009d 100644 --- a/gateway/src/onchain/preflight.ts +++ b/gateway/src/onchain/preflight.ts @@ -2,14 +2,20 @@ * gateway/src/onchain/preflight.ts — balance + collateral pre-flight. * * The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond + - * supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends - * that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate). - * So required = price + buyer_bond + supplier_bond + collateral + fee reserve, - * AND the wallet must currently hold a pure-AP3X UTxO ≥ collateral. We check both - * up front and return a clean 402 instead of failing mid-flight. + * supplier_bond, raised to the Submitted-state min-ada floor so long + * capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight — + * see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and + * postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim + * are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5 + * AP3X (assertCollateralCandidate). So required = escrowLockFloor(price + + * buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet + * must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front + * and return a clean 402 instead of failing mid-flight. */ import type { ChainProvider, Utxo } from "@marketplace/shared/chain"; +import type { EscrowDatum } from "@marketplace/shared/cbor"; +import { escrowLockFloor } from "@marketplace/shared/tx"; export const COLLATERAL_MIN_LOVELACE = 5_000_000n; export const FEE_RESERVE_LOVELACE = 2_000_000n; @@ -32,20 +38,48 @@ export interface PreflightResult { collateralOk: boolean; } +export interface PreflightCost { + capabilityId: string; + priceLovelace: bigint; + buyerBondLovelace: bigint; + supplierBondLovelace: bigint; +} + +/** Placeholder datum used only to size-estimate the Submitted-state min-ada + * floor. Every field except capability_id and the cost amounts is a + * fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit + * POSIX-ms timestamp) — capability_id's actual length is what drives the + * estimate, exactly as in escrowLockFloor's own contract. */ +function representativeEscrowDatum(cost: PreflightCost): EscrowDatum { + return { + buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55", + supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9", + advert_ref: { txHash: "a6".repeat(32), index: 0 }, + capability_id: cost.capabilityId, + request_spec_hash: "1b".repeat(32), + prompt_hash: "23".repeat(32), + payment_lovelace: cost.priceLovelace, + buyer_bond_lovelace: cost.buyerBondLovelace, + supplier_bond_lovelace: cost.supplierBondLovelace, + deliver_by: 1_786_105_955_257, + posted_at: 1_786_105_625_257, + submitted_at: null, + result_receipt_hash: null, + state: "Open", + }; +} + export async function preflight( chain: ChainProvider, address: string, - cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint }, + cost: PreflightCost, ): Promise { const utxos = await chain.queryUtxosByAddress(address); const available = totalLovelace(utxos); const collateralOk = hasCollateral(utxos); - const required = - cost.priceLovelace + - cost.buyerBondLovelace + - cost.supplierBondLovelace + - COLLATERAL_MIN_LOVELACE + - FEE_RESERVE_LOVELACE; + const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace; + const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal); + const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE; return { ok: available >= required && collateralOk, availableLovelace: available, diff --git a/gateway/src/server.ts b/gateway/src/server.ts index 69772bb..aa4a5cf 100644 --- a/gateway/src/server.ts +++ b/gateway/src/server.ts @@ -61,7 +61,7 @@ export function createApp(deps: GatewayDeps): Express { // the route; every other path keeps the 1mb ceiling. const jsonBody = express.json({ limit: "1mb" }); app.use((req, res, next) => - req.path.startsWith("/v1/ocr") ? next() : jsonBody(req, res, next)); + req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next)); app.get("/healthz", (_req: Request, res: Response) => { res.json({ ok: true }); @@ -86,8 +86,11 @@ export function createApp(deps: GatewayDeps): Express { app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps)); // Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS - // (~12M chars ≈ 9 MB binary) plus JSON envelope. - app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), ...gated, makeOcrExtractHandler(deps)); + // (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate + // limits) run BEFORE the parser so an unauthenticated caller cannot + // buffer/JSON.parse up to 16mb before getting a 401, and rate limiting + // can actually throttle the request (2026-08 review finding C2). + app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps)); app.get("/openai/v1/models", auth, makeModelsHandler(deps)); app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps)); app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps)); diff --git a/gateway/test/ocr.test.ts b/gateway/test/ocr.test.ts index 6df93b9..3a6fcbd 100644 --- a/gateway/test/ocr.test.ts +++ b/gateway/test/ocr.test.ts @@ -7,7 +7,7 @@ * covered by http.test.ts + the SDK suites; end-to-end runs live on testnet. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { tmpdir } from "os"; import { join } from "path"; import { randomBytes, randomUUID } from "crypto"; @@ -18,13 +18,49 @@ import { SdkRegistry } from "../src/sdk/registry.js"; import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js"; import { seal } from "../src/crypto/seal.js"; import { hashApiKey } from "../src/middleware/apiKeyAuth.js"; +import { venueAttribution } from "../src/ocr/extract.js"; import type { GatewayConfig } from "../src/config.js"; import type { GatewayDeps } from "../src/deps.js"; +// The one-shot settle step (resolveSubmittedRef → acceptAndConfirm) talks to +// the live indexer + chain; the happy-path route test below only needs to +// prove that a successful submitOcr reaches the response with the +// first-party-brokered disclosure label (I3), so it stubs just those two +// chain-touching steps and keeps every other export real. +vi.mock("../src/onchain/settle.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSubmittedRef: vi.fn(async () => ({ txHash: "e".repeat(64), index: 0 })), + acceptAndConfirm: vi.fn(async () => "f".repeat(64)), + }; +}); + const MASTER = "ab".repeat(32); const IMG = Buffer.from("page bytes").toString("base64"); -function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps { +/** A funded wallet: covers price+bonds+collateral+fee and has pure-ADA ≥ 5. */ +const fundedChain = { + queryUtxosByAddress: async () => [ + { ref: { txHash: "ff".repeat(32), index: 0 }, address: "a", lovelace: 20_000_000n, assets: {}, datumHex: null, scriptRef: null }, + ], +} as unknown as GatewayDeps["chain"]; + +const OCR_SUPPLIER = { + utxo_ref: `${"cc".repeat(32)}#0`, + supplier_pkh: "s3", + capability_id: "ocr.page.extract.chandra-ocr-2.v1", + model: "chandra-ocr-2", + max_output_tokens: 4096, + price_lovelace: "200000", + supplier_bond_lovelace: "1000000", + buyer_bond_lovelace: "1000000", + endpoint_url: "http://sup", + advert_status: "Active", + status: "free", +}; + +function makeDeps(fetchFn?: typeof globalThis.fetch, overrides?: { chain?: GatewayDeps["chain"] }): GatewayDeps { const dbDir = join(tmpdir(), `gw-ocr-test-${randomUUID()}`); const store = new GatewayStore(dbDir); const config: GatewayConfig = { @@ -46,7 +82,7 @@ function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps { ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1", corsOrigins: [], }; - const chain = { queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]; + const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]); const registry = new SdkRegistry({ chain, indexerUrl: config.indexerUrl, @@ -194,3 +230,98 @@ describe("POST /v1/ocr/extract — supplier lookup + body limit", () => { expect(res.status).toBe(413); }); }); + +// ─── venueAttribution — pure-function header sanitization (I3) ────────────── +// +// grep for "first-party-brokered" used to return only source lines: nothing +// exercised venueAttribution's regex gates or RECEIPT_LABELS_V0 reaching the +// wire. These two blocks close that gap. + +function fakeReq(headers: Record): Parameters[0] { + return { header: (name: string) => headers[name] } as unknown as Parameters[0]; +} + +describe("venueAttribution — header sanitization", () => { + it("passes through a valid venue slug and a valid lowercase hash", () => { + const hash = "a".repeat(64); + const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": hash })); + expect(out).toEqual({ venue: "apify", runUserHash: hash }); + }); + + it("lowercases a valid but uppercase hex hash", () => { + const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": "A".repeat(64) })); + expect(out.runUserHash).toBe("a".repeat(64)); + }); + + it("drops an invalid venue to empty string (uppercase, bad chars, oversized)", () => { + for (const bad of ["Apify", "apify!", "apify_venue", "a".repeat(33)]) { + const out = venueAttribution(fakeReq({ "X-Venue": bad, "X-Run-User-Hash": "a".repeat(64) })); + expect(out.venue).toBe(""); + } + }); + + it("drops an invalid run-user-hash to empty string (wrong length, non-hex)", () => { + for (const bad of ["a".repeat(63), "a".repeat(65), "z".repeat(64), "not-hex-at-all"]) { + const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": bad })); + expect(out.runUserHash).toBe(""); + } + }); + + it("defaults both fields to empty string when headers are missing", () => { + const out = venueAttribution(fakeReq({})); + expect(out).toEqual({ venue: "", runUserHash: "" }); + }); +}); + +// ─── Happy path — receipt disclosure label reaches the response (I3) ──────── + +describe("POST /v1/ocr/extract — happy path", () => { + it("200 response x_vector.receipt_labels contains first-party-brokered", async () => { + const fetchFn = (async (url: unknown) => { + const u = String(url); + if (u.includes("/suppliers")) { + return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 }); + } + return new Response("[]", { status: 200 }); + }) as unknown as typeof globalThis.fetch; + + const deps = makeDeps(fetchFn, { chain: fundedChain }); + const rawKey = makeKey(deps, 0); + const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!; + + // Stub only the chain-touching supplier call; escrow ref shape matches + // what resolveSubmittedRef's mock (module-level, top of file) expects. + const ctx = deps.registry.getContext(keyRow); + const submitOcr = vi.fn(async () => ({ + output_format: "markdown", + content: "extracted text", + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + receipt: { + prompt_hash: "1b".repeat(32), + response_hash: "2c".repeat(32), + model: "chandra-ocr-2", + prompt_tokens: 10, + completion_tokens: 5, + wallclock_ms: Date.now(), + supplier_pkh: "s3", + escrow_ref: `${"d".repeat(64)}#0`, + }, + receiptSignature: "sig".repeat(20), + escrowRef: { txHash: "d".repeat(64), index: 0 }, + })); + (ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr; + + const res = await request(createApp(deps)) + .post("/v1/ocr/extract") + .set("Authorization", `Bearer ${rawKey}`) + .send(validBody()); + + expect(res.status).toBe(200); + expect(submitOcr).toHaveBeenCalledTimes(1); + expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered"); + // escrow_ref in the response comes from submitOcr's returned escrowRef + // (the ORIGINAL Open ref), not the mocked resolveSubmittedRef's answer — + // see settle.ts's docstring on why those two refs differ on-chain. + expect(res.body.x_vector.escrow_ref).toBe(`${"d".repeat(64)}#0`); + }); +}); diff --git a/gateway/test/unit.test.ts b/gateway/test/unit.test.ts index c435613..2f5bd1a 100644 --- a/gateway/test/unit.test.ts +++ b/gateway/test/unit.test.ts @@ -208,8 +208,16 @@ describe("onchain/preflight", () => { }); it("preflight requires balance AND collateral", async () => { - const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n }; - // required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA + const cost = { + capabilityId: "llm.text.generate.v1", + priceLovelace: 2_000_000n, + buyerBondLovelace: 1_000_000n, + supplierBondLovelace: 1_000_000n, + }; + // required = escrowLockFloor(2 + 1 + 1) + 5 (collateral) + 2 (fee) = 11 ADA + // — at this economic total (4 ADA) the Submitted-state min-ada floor for a + // short capability id (~2.2 ADA) stays well under the raw total, so the + // floor is a no-op here; tx-minada-floor.test.ts covers the lifted case. const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any; const ok = await preflight(fakeChain, "a", cost); expect(ok.ok).toBe(true); diff --git a/packages/shared/src/tx/index.ts b/packages/shared/src/tx/index.ts index fadff01..dd5b5c7 100644 --- a/packages/shared/src/tx/index.ts +++ b/packages/shared/src/tx/index.ts @@ -7,6 +7,7 @@ export { TxConstructionError } from "./types.js"; export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js"; export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js"; export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js"; +export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js"; export type { Signer } from "./signer.js"; export { MockSigner } from "./signer.js"; export type { Blueprint } from "./blueprint.js"; diff --git a/supplier/.env.chandra.example b/supplier/.env.chandra.example index a46739c..09a7285 100644 --- a/supplier/.env.chandra.example +++ b/supplier/.env.chandra.example @@ -52,7 +52,6 @@ OPENAI_TIMEOUT_MS=120000 # page inference is seconds; keep < advert max # openai-vision upstream only (attestation upgrade path): # OCR_PROMPT_OVERRIDE= # exact serving prompt, pinned at integration # HF_MODEL_REVISION= # 40-hex checkpoint SHA; mismatch refuses Claim -# HF_WEIGHTS_SOURCE=hf:datalab-to/chandra-ocr-2 # REVISION_PROBE_URL= # e.g. http://chandra-vllm:8000/info # ── Service ─────────────────────────────────────────────────────────────── diff --git a/supplier/chandra-detail.example.json b/supplier/chandra-detail.example.json index c5b6747..60dc173 100644 --- a/supplier/chandra-detail.example.json +++ b/supplier/chandra-detail.example.json @@ -1,7 +1,7 @@ { "capability_id": "ocr.page.extract.chandra-ocr-2.v1", "weights_source": "hf:datalab-to/chandra-ocr-2", - "model_revision": "<40-hex commit SHA pinned at endpoint provisioning>", + "model_revision": "<40-hex commit SHA; self-hosted (OCR_UPSTREAM=openai-vision) deployments only — the Datalab-hosted default (OCR_UPSTREAM=datalab) is a third-party API with no pinnable checkpoint, so this is unset there>", "license": "modified OpenRAIL-M (weights); Apache-2.0 (code)", "request_shape": { "route": "POST /v1/ocr/extract", @@ -14,8 +14,8 @@ "verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)" }, "revision_probe": { - "route": "", - "policy": "probe before every Claim; mismatch refuses the job" + "route": "", + "policy": "self-hosted (OCR_UPSTREAM=openai-vision) upstream: probe before every Claim, mismatch refuses the job. Datalab-hosted default (OCR_UPSTREAM=datalab): per-job liveness probe only, no checkpoint pin." }, "operator_disclosure": "first-party (HAL8-operated supplier)" } diff --git a/supplier/src/config.ts b/supplier/src/config.ts index 57fe13b..df17b26 100644 --- a/supplier/src/config.ts +++ b/supplier/src/config.ts @@ -165,12 +165,6 @@ export interface SupplierConfig { * Env: HF_MODEL_REVISION. "" = no pin. */ hfModelRevision: string; - /** - * Hub identity of the served weights, e.g. "hf:datalab-to/chandra-ocr-2". - * Recorded for telemetry now; feeds the receipt extension when that - * schema is added. Env: HF_WEIGHTS_SOURCE. "" = unset. - */ - hfWeightsSource: string; /** * Serving-runtime info route reporting the loaded checkpoint (TGI-style * /info or vLLM equivalent). Env: REVISION_PROBE_URL. "" = probing off. @@ -422,7 +416,6 @@ export function loadConfig(env: Record): SupplierCon if (hfModelRevision !== "" && !/^[0-9a-fA-F]{40}$/.test(hfModelRevision)) { throw new Error("loadConfig: HF_MODEL_REVISION must be a 40-hex commit SHA when set"); } - const hfWeightsSource = env.HF_WEIGHTS_SOURCE ?? ""; const revisionProbeUrl = env.REVISION_PROBE_URL ?? ""; // Datalab upstream: the health route is derived from DATALAB_BASE_URL, so // the liveness probe defaults ON without a REVISION_PROBE_URL. The @@ -489,7 +482,6 @@ export function loadConfig(env: Record): SupplierCon datalabTimeoutMs, ocrPromptOverride, hfModelRevision, - hfWeightsSource, revisionProbeUrl, revisionProbeMode, liveChain, diff --git a/supplier/src/server.ts b/supplier/src/server.ts index bfbb637..c19d09b 100644 --- a/supplier/src/server.ts +++ b/supplier/src/server.ts @@ -1385,7 +1385,7 @@ export function createApp(deps: SupplierDeps): Application { // below; every other route keeps the 1mb ceiling. const jsonBody = express.json({ limit: "1mb" }); app.use((req, res, next) => - req.path.startsWith("/v1/ocr") ? next() : jsonBody(req, res, next)); + req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next)); // /healthz is mounted FIRST and uses no deps — it must succeed regardless // of chain/state/config (independent of /status free/working/offline). diff --git a/tests/fixtures/supplier-side/sample-config.ts b/tests/fixtures/supplier-side/sample-config.ts index b2709a1..9e8e980 100644 --- a/tests/fixtures/supplier-side/sample-config.ts +++ b/tests/fixtures/supplier-side/sample-config.ts @@ -65,7 +65,6 @@ export function buildSampleOcrConfig(): SupplierConfig { datalabTimeoutMs: 120_000, ocrPromptOverride: "", hfModelRevision: "", - hfWeightsSource: "", revisionProbeUrl: "", revisionProbeMode: "off", }; diff --git a/tests/unit/supplier-routes-ocr.test.ts b/tests/unit/supplier-routes-ocr.test.ts index 7cb6438..0f40b50 100644 --- a/tests/unit/supplier-routes-ocr.test.ts +++ b/tests/unit/supplier-routes-ocr.test.ts @@ -138,7 +138,7 @@ describe("POST /v1/ocr/extract — header + body validation", () => { }); describe("POST /v1/ocr/extract — revision-probe gate", () => { - it("503 revision_probe_failed when the probe cannot reach the runtime", async () => { + it("503 advert_unavailable — chain validation runs before the revision probe fires", async () => { // Advert/escrow would resolve AFTER the probe? No — probe runs at step // 7.5, after chain checks. Seeding a full escrow in the mock provider is // the chat-route suite's job; here we assert the config gate wiring by diff --git a/tests/unit/tx-post-ocr-escrow.test.ts b/tests/unit/tx-post-ocr-escrow.test.ts index 8c8fc65..ceb9001 100644 --- a/tests/unit/tx-post-ocr-escrow.test.ts +++ b/tests/unit/tx-post-ocr-escrow.test.ts @@ -24,6 +24,13 @@ import { } from "../../packages/shared/src/tx/index.js"; import { canonicalize } from "../../packages/shared/src/cbor/canonical.js"; import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js"; +import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js"; +import { decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js"; +import { escrowLockFloor } from "../../packages/shared/src/tx/internal/minAdaFloor.js"; +import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js"; +import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js"; +import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js"; +import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js"; const IMG = Buffer.from("not really a png but bytes are bytes").toString("base64"); @@ -120,3 +127,74 @@ describe("buildPostOcrEscrowTx — validation precedes chain access", () => { })).rejects.toMatchObject({ reason: "advert ref not on chain" }); }); }); + +describe("buildPostOcrEscrowTx — happy path locks the min-ada floor (C1 regression)", () => { + // Guards packages/shared/src/tx/escrow/postOcrEscrow.ts:205 + // (`escrowLockFloor(escrowDatum, economicTotal)`). Without that call the + // escrow locks only the raw economic total, the Submit continuing output + // gets bumped to min-ada, value_equal fails, and the validator errors with + // an empty trace — the 2026-08-07 mainnet incident. Neither existing + // `buildPostOcrEscrowTx` test above reaches line 205 (both throw first), + // so this is the only test that would catch a regression back to a bare + // `economicTotal` assignment. + const ADVERT_SCRIPT_ADDRESS = "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6"; + const ADVERT_REF: OutputReference = { txHash: "c".repeat(64), index: 0 }; + const PRICE = 200_000n; + const BUYER_BOND = 1_000_000n; + const SUPPLIER_BOND = 1_000_000n; + const ECONOMIC_TOTAL = PRICE + BUYER_BOND + SUPPLIER_BOND; // 2_200_000n + + function makeOcrAdvert(): AdvertDatum { + const supplier = buildSupplierWalletKey(); + return { + supplier_pkh: supplier.pubKeyHash, + capability_id: "ocr.page.extract.chandra-ocr-2.v1", + model: "chandra-ocr-2", + max_output_tokens: 4096, + max_processing_ms: 60_000, + price_lovelace: PRICE, + supplier_bond_lovelace: SUPPLIER_BOND, + buyer_bond_lovelace: BUYER_BOND, + endpoint_url: "https://supplier.example.com/v1", + detail_uri: "ipfs://Qm000", + detail_hash: "a".repeat(64), + advertised_at: 1_745_500_000_000, + status: "Active", + }; + } + + function seedOcrAdvert(chain: MockChainProvider): void { + const utxo: Utxo = { + ref: ADVERT_REF, + address: ADVERT_SCRIPT_ADDRESS, + lovelace: 2_000_000n, + assets: {}, + datumHex: encodeAdvertDatum(makeOcrAdvert()), + scriptRef: null, + }; + chain.seed(utxo); + } + + it("locks escrowLockFloor(datum, economicTotal), strictly above the raw economic total", async () => { + const chain = new MockChainProvider(); + chain.advanceSlot(1_745_500_000); + seedOcrAdvert(chain); + + const buyer = buildBuyerWalletKey(); + const result = await buildPostOcrEscrowTx({ + chain, + buyerKey: buyer, + advertRef: ADVERT_REF, + request: validRequest(), + payment_lovelace: PRICE, + }); + + const escrowUtxo = await chain.queryUtxo(result.escrowOutputRef); + expect(escrowUtxo).not.toBeNull(); + const datum = decodeEscrowDatum(escrowUtxo!.datumHex!); + + const expectedFloor = escrowLockFloor(datum, ECONOMIC_TOTAL); + expect(escrowUtxo!.lovelace).toBe(expectedFloor); + expect(escrowUtxo!.lovelace).toBeGreaterThan(ECONOMIC_TOTAL); + }); +});