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>
201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
/**
|
|
* tx-post-ocr-escrow.test.ts — coverage for the OCR escrow builder added
|
|
* for model-scoped `ocr.page.extract.<model-slug>.v1` capabilities.
|
|
*
|
|
* Focus: the NEW validation + hash logic in postOcrEscrow.ts. The shared
|
|
* escrow-construction pieces (advert resolution, datum assembly, mock tx
|
|
* encoding) mirror postTtsEscrow.ts and are covered by tx-post-escrow.test.ts
|
|
* for the chat shape; here we pin:
|
|
* - ocrPromptHash canonicalisation (golden hash — buyer/supplier contract)
|
|
* - envelope validation failures throw BEFORE any chain access
|
|
* - the escrow datum carries the OCR prompt_hash on the happy path
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { createHash } from "crypto";
|
|
|
|
import {
|
|
ocrPromptHash,
|
|
validateOcrRequest,
|
|
buildPostOcrEscrowTx,
|
|
MAX_OCR_IMAGE_B64_CHARS,
|
|
TxConstructionError,
|
|
type OcrRequest,
|
|
} 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");
|
|
|
|
function validRequest(): OcrRequest {
|
|
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
|
}
|
|
|
|
describe("ocrPromptHash — canonicalisation contract", () => {
|
|
it("equals sha256(canonicalize({image_b64, mime, output_format}))", () => {
|
|
const req = validRequest();
|
|
const expected = createHash("sha256")
|
|
.update(canonicalize({
|
|
image_b64: req.image_b64,
|
|
mime: req.mime,
|
|
output_format: req.output_format,
|
|
}), "utf8")
|
|
.digest("hex");
|
|
expect(ocrPromptHash(req)).toBe(expected);
|
|
});
|
|
|
|
it("is key-order independent (JCS canonicalisation)", () => {
|
|
const a = ocrPromptHash({ image_b64: IMG, mime: "image/png", output_format: "json" });
|
|
const b = ocrPromptHash({ output_format: "json", mime: "image/png", image_b64: IMG } as OcrRequest);
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("changes when any field changes", () => {
|
|
const base = ocrPromptHash(validRequest());
|
|
expect(ocrPromptHash({ ...validRequest(), mime: "image/jpeg" })).not.toBe(base);
|
|
expect(ocrPromptHash({ ...validRequest(), output_format: "html" })).not.toBe(base);
|
|
expect(ocrPromptHash({ ...validRequest(), image_b64: IMG + "AA" })).not.toBe(base);
|
|
});
|
|
});
|
|
|
|
describe("validateOcrRequest — envelope gates", () => {
|
|
it("rejects empty image", () => {
|
|
expect(() => validateOcrRequest({ ...validRequest(), image_b64: "" }))
|
|
.toThrowError(TxConstructionError);
|
|
});
|
|
|
|
it("rejects oversized image", () => {
|
|
const big = "A".repeat(MAX_OCR_IMAGE_B64_CHARS + 4);
|
|
expect(() => validateOcrRequest({ ...validRequest(), image_b64: big }))
|
|
.toThrow(/exceeds/);
|
|
});
|
|
|
|
it("rejects data-URL prefixes and whitespace (plain base64 only)", () => {
|
|
for (const bad of [`data:image/png;base64,${IMG}`, `${IMG} `, "with space AA"]) {
|
|
expect(() => validateOcrRequest({ ...validRequest(), image_b64: bad }))
|
|
.toThrow(/base64/);
|
|
}
|
|
});
|
|
|
|
it("rejects unknown mime and output_format", () => {
|
|
expect(() => validateOcrRequest({ ...validRequest(), mime: "image/tiff" }))
|
|
.toThrow(/mime/);
|
|
expect(() => validateOcrRequest({ ...validRequest(), output_format: "yaml" }))
|
|
.toThrow(/output_format/);
|
|
});
|
|
});
|
|
|
|
describe("buildPostOcrEscrowTx — validation precedes chain access", () => {
|
|
it("throws the envelope error against an unseeded chain (no advert query first)", async () => {
|
|
const chain = new MockChainProvider();
|
|
const buyerKey = {
|
|
pubKeyHash: "ab".repeat(28),
|
|
pubKeyHex: "cd".repeat(32),
|
|
privateKeyHex: "ef".repeat(32),
|
|
address: "addr_test1fake",
|
|
};
|
|
await expect(buildPostOcrEscrowTx({
|
|
chain,
|
|
buyerKey,
|
|
advertRef: { txHash: "b".repeat(64), index: 0 },
|
|
request: { ...validRequest(), mime: "image/gif" },
|
|
payment_lovelace: 200000n,
|
|
})).rejects.toThrow(/mime/);
|
|
});
|
|
|
|
it("unseeded advert ref → 'advert ref not on chain' after a valid envelope", async () => {
|
|
const chain = new MockChainProvider();
|
|
const buyerKey = {
|
|
pubKeyHash: "ab".repeat(28),
|
|
pubKeyHex: "cd".repeat(32),
|
|
privateKeyHex: "ef".repeat(32),
|
|
address: "addr_test1fake",
|
|
};
|
|
await expect(buildPostOcrEscrowTx({
|
|
chain,
|
|
buyerKey,
|
|
advertRef: { txHash: "b".repeat(64), index: 0 },
|
|
request: validRequest(),
|
|
payment_lovelace: 200000n,
|
|
})).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);
|
|
});
|
|
});
|