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 type { GatewayStore } from "../db/store.js";
import { requireKey } from "../middleware/apiKeyAuth.js"; import { requireKey } from "../middleware/apiKeyAuth.js";
import { asyncHandler } from "../middleware/http.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 { preflight } from "../onchain/preflight.js";
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js"; import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
import { ensureWalletHealthy } from "../walletHealth.js"; import { ensureWalletHealthy } from "../walletHealth.js";
@@ -107,8 +107,12 @@ function parseOcrRequest(body: unknown): ParsedOcrRequest {
return { image_b64: image, mime, output_format: rawFormat, model }; return { image_b64: image, mime, output_format: rawFormat, model };
} }
/** Sanitized venue-attribution headers. Invalid values drop to "". */ /** Sanitized venue-attribution headers. Invalid values drop to "".
function venueAttribution(req: Request): { venue: string; runUserHash: string } { * 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 venueRaw = req.header("X-Venue") ?? "";
const hashRaw = req.header("X-Run-User-Hash") ?? ""; const hashRaw = req.header("X-Run-User-Hash") ?? "";
return { return {
@@ -173,6 +177,16 @@ async function runOcrOneShot(
const { config, store, chain, fetchFn } = deps; const { config, store, chain, fetchFn } = deps;
const capabilityId = config.ocrCapabilityId; 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 { try {
const candidates = await selectCandidates({ const candidates = await selectCandidates({
indexerUrl: config.indexerUrl, indexerUrl: config.indexerUrl,
@@ -210,8 +224,6 @@ async function runOcrOneShot(
// Submit. Fall back to the next supplier ONLY on a pre-post // Submit. Fall back to the next supplier ONLY on a pre-post
// TxConstructionError (no escrow was posted). A SupplierError means the // TxConstructionError (no escrow was posted). A SupplierError means the
// escrow is already posted — do not retry (the sweeper recovers it). // 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; let lastErr: unknown;
for (const cand of candidates) { for (const cand of candidates) {
try { try {
@@ -235,11 +247,11 @@ async function runOcrOneShot(
} }
// Settle: resolve the live Submitted ref, Accept, await confirmation. // 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({ const submittedRef = await resolveSubmittedRef({
indexerUrl: config.indexerUrl, indexerUrl: config.indexerUrl,
buyerPkh: ctx.walletKey.pubKeyHash, buyerPkh: ctx.walletKey.pubKeyHash,
originalRefStr, originalRefStr: escrowRefStr,
fetchFn, fetchFn,
}); });
await acceptAndConfirm(chain, ctx.walletKey, submittedRef); await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
@@ -249,7 +261,7 @@ async function runOcrOneShot(
capabilityId, capabilityId,
model: used.model, model: used.model,
supplierPkh: used.supplierPkh, supplierPkh: used.supplierPkh,
escrowRef: originalRefStr, escrowRef: escrowRefStr,
costLovelace: used.priceLovelace, costLovelace: used.priceLovelace,
promptTokens: result.usage.prompt_tokens, promptTokens: result.usage.prompt_tokens,
completionTokens: result.usage.completion_tokens, completionTokens: result.usage.completion_tokens,
@@ -261,7 +273,7 @@ async function runOcrOneShot(
// caller sent none. // caller sent none.
console.log(JSON.stringify({ console.log(JSON.stringify({
evt: "ocr_settled", evt: "ocr_settled",
escrow_ref: originalRefStr, escrow_ref: escrowRefStr,
capability_id: capabilityId, capability_id: capabilityId,
model: used.model, model: used.model,
supplier_pkh: used.supplierPkh, supplier_pkh: used.supplierPkh,
@@ -286,7 +298,7 @@ async function runOcrOneShot(
x_vector: { x_vector: {
receipt: result.receipt, receipt: result.receipt,
receipt_signature: result.receiptSignature, receipt_signature: result.receiptSignature,
escrow_ref: originalRefStr, escrow_ref: escrowRefStr,
receipt_labels: RECEIPT_LABELS_V0, receipt_labels: RECEIPT_LABELS_V0,
}, },
}); });
@@ -294,15 +306,33 @@ async function runOcrOneShot(
recordOcrUsage(store, { recordOcrUsage(store, {
keyId, keyId,
capabilityId, capabilityId,
model: parsed.model, model: used?.model ?? parsed.model,
supplierPkh: null, supplierPkh: used?.supplierPkh ?? null,
escrowRef: null, escrowRef: escrowRefStr,
costLovelace: null, costLovelace: used?.priceLovelace ?? null,
promptTokens: 0, promptTokens: result?.usage.prompt_tokens ?? 0,
completionTokens: 0, completionTokens: result?.usage.completion_tokens ?? 0,
status: "failed", status: "failed",
failureReason: err instanceof Error ? err.message : String(err), 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; throw err;
} }
} }

View File

@@ -2,14 +2,20 @@
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight. * gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
* *
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond + * The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends * supplier_bond, raised to the Submitted-state min-ada floor so long
* that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate). * capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve, * see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
* AND the wallet must currently hold a pure-AP3X UTxO ≥ collateral. We check both * postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
* up front and return a clean 402 instead of failing mid-flight. * 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 { 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 COLLATERAL_MIN_LOVELACE = 5_000_000n;
export const FEE_RESERVE_LOVELACE = 2_000_000n; export const FEE_RESERVE_LOVELACE = 2_000_000n;
@@ -32,20 +38,48 @@ export interface PreflightResult {
collateralOk: boolean; 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( export async function preflight(
chain: ChainProvider, chain: ChainProvider,
address: string, address: string,
cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint }, cost: PreflightCost,
): Promise<PreflightResult> { ): Promise<PreflightResult> {
const utxos = await chain.queryUtxosByAddress(address); const utxos = await chain.queryUtxosByAddress(address);
const available = totalLovelace(utxos); const available = totalLovelace(utxos);
const collateralOk = hasCollateral(utxos); const collateralOk = hasCollateral(utxos);
const required = const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
cost.priceLovelace + const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
cost.buyerBondLovelace + const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
cost.supplierBondLovelace +
COLLATERAL_MIN_LOVELACE +
FEE_RESERVE_LOVELACE;
return { return {
ok: available >= required && collateralOk, ok: available >= required && collateralOk,
availableLovelace: available, availableLovelace: available,

View File

@@ -61,7 +61,7 @@ export function createApp(deps: GatewayDeps): Express {
// the route; every other path keeps the 1mb ceiling. // the route; every other path keeps the 1mb ceiling.
const jsonBody = express.json({ limit: "1mb" }); const jsonBody = express.json({ limit: "1mb" });
app.use((req, res, next) => 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) => { app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
@@ -86,8 +86,11 @@ export function createApp(deps: GatewayDeps): Express {
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps)); app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS // Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
// (~12M chars ≈ 9 MB binary) plus JSON envelope. // (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), ...gated, makeOcrExtractHandler(deps)); // 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.get("/openai/v1/models", auth, makeModelsHandler(deps));
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps)); app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(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. * 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 { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { randomBytes, randomUUID } from "crypto"; import { randomBytes, randomUUID } from "crypto";
@@ -18,13 +18,49 @@ import { SdkRegistry } from "../src/sdk/registry.js";
import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js"; import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js";
import { seal } from "../src/crypto/seal.js"; import { seal } from "../src/crypto/seal.js";
import { hashApiKey } from "../src/middleware/apiKeyAuth.js"; import { hashApiKey } from "../src/middleware/apiKeyAuth.js";
import { venueAttribution } from "../src/ocr/extract.js";
import type { GatewayConfig } from "../src/config.js"; import type { GatewayConfig } from "../src/config.js";
import type { GatewayDeps } from "../src/deps.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 MASTER = "ab".repeat(32);
const IMG = Buffer.from("page bytes").toString("base64"); 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 dbDir = join(tmpdir(), `gw-ocr-test-${randomUUID()}`);
const store = new GatewayStore(dbDir); const store = new GatewayStore(dbDir);
const config: GatewayConfig = { const config: GatewayConfig = {
@@ -46,7 +82,7 @@ function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1", ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [], 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({ const registry = new SdkRegistry({
chain, chain,
indexerUrl: config.indexerUrl, indexerUrl: config.indexerUrl,
@@ -194,3 +230,98 @@ describe("POST /v1/ocr/extract — supplier lookup + body limit", () => {
expect(res.status).toBe(413); 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 () => { it("preflight requires balance AND collateral", async () => {
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n }; const cost = {
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA 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 fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
const ok = await preflight(fakeChain, "a", cost); const ok = await preflight(fakeChain, "a", cost);
expect(ok.ok).toBe(true); expect(ok.ok).toBe(true);

View File

@@ -7,6 +7,7 @@ export { TxConstructionError } from "./types.js";
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js"; export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js"; export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js"; export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
export type { Signer } from "./signer.js"; export type { Signer } from "./signer.js";
export { MockSigner } from "./signer.js"; export { MockSigner } from "./signer.js";
export type { Blueprint } from "./blueprint.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): # openai-vision upstream only (attestation upgrade path):
# OCR_PROMPT_OVERRIDE= # exact serving prompt, pinned at integration # OCR_PROMPT_OVERRIDE= # exact serving prompt, pinned at integration
# HF_MODEL_REVISION= # 40-hex checkpoint SHA; mismatch refuses Claim # 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 # REVISION_PROBE_URL= # e.g. http://chandra-vllm:8000/info
# ── Service ─────────────────────────────────────────────────────────────── # ── Service ───────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
{ {
"capability_id": "ocr.page.extract.chandra-ocr-2.v1", "capability_id": "ocr.page.extract.chandra-ocr-2.v1",
"weights_source": "hf:datalab-to/chandra-ocr-2", "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)", "license": "modified OpenRAIL-M (weights); Apache-2.0 (code)",
"request_shape": { "request_shape": {
"route": "POST /v1/ocr/extract", "route": "POST /v1/ocr/extract",
@@ -14,8 +14,8 @@
"verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)" "verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)"
}, },
"revision_probe": { "revision_probe": {
"route": "<runtime info route reporting the loaded checkpoint>", "route": "<runtime info route reporting the loaded checkpoint; self-hosted (openai-vision) upstream only>",
"policy": "probe before every Claim; mismatch refuses the job" "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)" "operator_disclosure": "first-party (HAL8-operated supplier)"
} }

View File

@@ -165,12 +165,6 @@ export interface SupplierConfig {
* Env: HF_MODEL_REVISION. "" = no pin. * Env: HF_MODEL_REVISION. "" = no pin.
*/ */
hfModelRevision: string; 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 * Serving-runtime info route reporting the loaded checkpoint (TGI-style
* /info or vLLM equivalent). Env: REVISION_PROBE_URL. "" = probing off. * /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)) { 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"); 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 ?? ""; const revisionProbeUrl = env.REVISION_PROBE_URL ?? "";
// Datalab upstream: the health route is derived from DATALAB_BASE_URL, so // Datalab upstream: the health route is derived from DATALAB_BASE_URL, so
// the liveness probe defaults ON without a REVISION_PROBE_URL. The // 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, datalabTimeoutMs,
ocrPromptOverride, ocrPromptOverride,
hfModelRevision, hfModelRevision,
hfWeightsSource,
revisionProbeUrl, revisionProbeUrl,
revisionProbeMode, revisionProbeMode,
liveChain, liveChain,

View File

@@ -1385,7 +1385,7 @@ export function createApp(deps: SupplierDeps): Application {
// below; every other route keeps the 1mb ceiling. // below; every other route keeps the 1mb ceiling.
const jsonBody = express.json({ limit: "1mb" }); const jsonBody = express.json({ limit: "1mb" });
app.use((req, res, next) => 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 // /healthz is mounted FIRST and uses no deps — it must succeed regardless
// of chain/state/config (independent of /status free/working/offline). // of chain/state/config (independent of /status free/working/offline).

View File

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

View File

@@ -138,7 +138,7 @@ describe("POST /v1/ocr/extract — header + body validation", () => {
}); });
describe("POST /v1/ocr/extract — revision-probe gate", () => { 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 // 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 // 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 // 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"; } from "../../packages/shared/src/tx/index.js";
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js"; import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.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"); 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" }); })).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);
});
});