fix(ocr): review pass — min-ada floor regression test, auth-before-parse, failure-path reconciliation, disclosure label coverage

Fixes 2 critical, 2 important, and 5 minor findings from review of 069edcb:

- C1: add a happy-path test that reaches postOcrEscrow.ts's escrowLockFloor
  call and asserts the locked amount; verified it fails against a bare
  economicTotal and passes against the real code.
- C2: gate /v1/ocr/extract (auth + rate limits) before the 16mb body parser
  so an unauthenticated caller can no longer force a full buffer/JSON.parse.
- I2: hoist result/used/escrowRef in the OCR one-shot handler so the failure
  path records escrow_ref/supplier/cost when an escrow was actually posted,
  and emit an ocr_failed structured log line mirroring ocr_settled.
- I3: export venueAttribution and cover its sanitization directly; add a
  stubbed happy-path route test asserting the first-party-brokered
  disclosure label reaches the response.
- Minor: drop unused hfWeightsSource end to end, rename a misleading test,
  make the OCR capability template's pinning language conditional on the
  self-hosted upstream, tighten the /v1/ocr path-prefix guard, and make
  preflight's required_lovelace use the same escrowLockFloor the builder
  locks (exported from the shared tx barrel for gateway use).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
FilipBl4gojevic
2026-08-10 17:12:12 +02:00
parent 069edcb076
commit 2b2295b7ed
13 changed files with 326 additions and 51 deletions

View File

@@ -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<ReturnType<typeof ctx.sdk.submitOcr>> | 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<ReturnType<typeof ctx.sdk.submitOcr>> | 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;
}
}

View File

@@ -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<PreflightResult> {
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,

View File

@@ -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));

View File

@@ -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<typeof import("../src/onchain/settle.js")>();
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<string, string>): Parameters<typeof venueAttribution>[0] {
return { header: (name: string) => headers[name] } as unknown as Parameters<typeof venueAttribution>[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`);
});
});

View File

@@ -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);

View File

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

View File

@@ -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 ───────────────────────────────────────────────────────────────

View File

@@ -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": "<runtime info route reporting the loaded checkpoint>",
"policy": "probe before every Claim; mismatch refuses the job"
"route": "<runtime info route reporting the loaded checkpoint; self-hosted (openai-vision) upstream only>",
"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)"
}

View File

@@ -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<string, string | undefined>): 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<string, string | undefined>): SupplierCon
datalabTimeoutMs,
ocrPromptOverride,
hfModelRevision,
hfWeightsSource,
revisionProbeUrl,
revisionProbeMode,
liveChain,

View File

@@ -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).

View File

@@ -65,7 +65,6 @@ export function buildSampleOcrConfig(): SupplierConfig {
datalabTimeoutMs: 120_000,
ocrPromptOverride: "",
hfModelRevision: "",
hfWeightsSource: "",
revisionProbeUrl: "",
revisionProbeMode: "off",
};

View File

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

View File

@@ -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);
});
});