13 Commits

Author SHA1 Message Date
david
304bbc62ad fix(deploy): run the deploy job on the self-hosted nixos runner
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Keep SSH key material in $RUNNER_TEMP instead of ~/.ssh since a
self-hosted runner's home persists across jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:00:23 +02:00
david
312f3e730e feat(deploy): CI-gated continuous deployment to the vector-marketplace host
Merges to main now deploy automatically once CI passes:

- .github/workflows/deploy-mainnet.yml: fires on successful CI runs of
  main (or manually via workflow_dispatch with an optional force input),
  SSHes into the mainnet host with the DEPLOY_SSH_KEY secret (host key
  pinned) and runs deploy/mainnet/deploy.sh at the new commit via
  git show FETCH_HEAD:, so deploy logic ships with the code it deploys.
- deploy/mainnet/deploy.sh: hard-resets /root/agents-marketplace to
  origin/main, maps the old..new diff to affected compose projects
  (packages/, patches/, contracts/ and root manifests rebuild all image
  builds; the 17 supplier projects share one Dockerfile so builds after
  the first are cache hits), builds everything up front, then does a
  rolling up -d with a per-project health gate. Suppliers drain first:
  /status is polled until not "working" (DRAIN_TIMEOUT_SECS, default
  600s) so an in-flight job is not killed and its bond forfeited.
  FORCE=1 redeploys the current sha, DRY_RUN=1 previews the plan, and
  each deploy logs old -> new to /var/log/marketplace-deploy.log with a
  printed rollback one-liner.

Verified: DRY_RUN=1 on the host against the pending 678ee86..6156900
delta selects indexer, buyer, gateway and all 17 suppliers, skips the
non-running wallet-monitor, and leaves image-only projects untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:56:50 +02:00
dcejtlin-apex
6156900fc2 Merge pull request #10 from Apex-Fusion/feat/ocr-capability
feat(ocr): OCR capability + gateway /v1/ocr/extract route
2026-08-11 09:40:25 +02:00
FilipBl4gojevic
7d348e762f fix(ocr): live busy pre-check before posting a candidate's escrow
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
runOcrOneShot posted the escrow before calling the supplier, so a busy
supplier's 409 stranded an already-paid escrow until reclaim - and the
buyer-side busy-retry multiplies this into several stranded escrows per
page. Add a live /status check per candidate, immediately before its
escrow posts, mirroring sessions.ts's startChat pre-check: "working"
skips to the next candidate without posting, anything else (free,
unreachable, malformed, slow) is treated as usable so the check is an
optimisation, never a gate. Bounded to a 2s timeout so a stalled
supplier cannot meaningfully delay the request.

Existing candidate-fallback, escrow-ref capture, usage recording, and
receipt labels are unchanged.
2026-08-10 19:31:22 +02:00
FilipBl4gojevic
7bf3252cdb fix(gateway): match both path separators in the direct-invocation guard
process.argv[1] arrives with backslashes on Windows, so the
forward-slash-only pattern never matched and `start` exited 0 without
starting the server, and without printing anything. Found while running
the gateway locally to verify this branch's OCR route end to end;
bundled here because it blocks that verification on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 18:44:41 +02:00
FilipBl4gojevic
feabd12876 fix(ocr): capture escrow_ref from the escrow_posted event, not just submitOcr's return
escrowRefStr was only assigned after submitOcr resolved, but submitOcr posts
the escrow internally and THEN calls the supplier over HTTP — a SupplierError
there (timeout, network failure, non-2xx, malformed response, receipt
mismatch) meant the escrow was already on chain while the failure path still
recorded escrow_ref: null. Given the OCR upstream's 120s timeout budget this
is likely the most common paid-but-failed shape, and the exact one the
reconciliation spine exists for.

Subscribe to the SDK's "progress" event (type "escrow_posted") around each
submitOcr attempt so the ref is captured the instant it lands, regardless of
whether submitOcr goes on to resolve or reject. Listener is scoped per
candidate and removed in a finally so it cannot accumulate.

Adds a regression test proving the failure-path usage row and the ocr_failed
structured log line both carry the real ref instead of null; verified it
fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:34:55 +02:00
FilipBl4gojevic
2b2295b7ed 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>
2026-08-10 17:12:12 +02:00
FilipBl4gojevic
069edcb076 feat(ocr): model-scoped OCR capability + gateway /v1/ocr/extract route
Adds the ocr.page.extract.chandra-ocr-2.v1 capability end to end: the
postOcrEscrow builder (locking the min-ada floor for the Submitted-state
datum, required since the min-ada fix), the gateway OCR route that selects
an OCR supplier by capability, posts a per-page escrow, calls the supplier,
verifies and settles, and returns content plus the signed receipt labelled
first-party-brokered. Supplier OCR handler with a hosted document-API
upstream and buyer-SDK OCR support included.

Squashed from the 2026-08-06 working subset; the deploy pack is
intentionally excluded (it lives in a private operator repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 16:40:00 +02:00
dcejtlin-apex
d5349057b0 Merge pull request #9 from Apex-Fusion/fix/escrow-minada-floor
fix(escrow): min-ada floor so long-capability Submits settle
2026-08-10 15:08:11 +02:00
david
678ee862ea fix(docker): COPY patches/ into every builder stage
The lockfile gained patchedDependencies (libsodium-wrappers-sumo) in
2aa1688, and the package manager hashes the patch file during install —
every image build since then fails with ENOENT on /repo/patches. No
image had been rebuilt since, so it surfaced only on the next deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:30:49 +02:00
dcejtlin-apex
6ca7f2927a Merge pull request #8 from Apex-Fusion/fix/wallet-health-idempotence
fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
2026-08-10 14:11:59 +02:00
david
4d94aa5040 feat(deploy): Let's Encrypt TLS at traefik for all 17 mainnet supplier hosts
Same pattern as the gateway (a54fdcb): the mp-suppliers-* DNS records are no
longer Cloudflare-proxied, so each supplier gets a websecure router with the
letsencrypt resolver and its :80 router becomes a permanent HTTPS redirect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:28:51 +02:00
FilipBl4gojevic
36c5cfc6be fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
planConsolidate returned already-healthy only for wallets with EXACTLY
2 UTxOs, but buildConsolidateWalletTx lands 3 (collateral + working +
the fee-reserve remainder as change). The shape consolidation produces
can never satisfy its own health check, so every wallet-health tick
re-consolidates and pays the ~0.5 AP3X min fee again. Measured in a
production deployment: a 45s ticker submitted 232 consolidations and
burned ~116 AP3X in about 2.5 idle hours; at the default 600s interval
the same loop leaks ~3 AP3X per hour whenever the wallet is not exactly
2 UTxOs.

Healthy is now structural: a pure-lovelace UTxO >= collateralLovelace
exists, at least 1 AP3X sits outside it, and fragmentation is bounded
(<= 6 UTxOs, new optional maxUtxos param). The health check also runs
BEFORE the low-balance guard, so a wallet that can already run script
transactions is never reshaped or refused. walletHealth gains two
independent circuit breakers - halt after 3 consecutive consolidations
that never reach healthy, halt past 6 consolidations per 24h - so any
future non-convergence costs at most ~1.5 AP3X instead of the whole
wallet, plus a docstring that matches reality.

Tests: the exact post-consolidation-shape regression, one-step
idempotence against the builder output model, candidate boundaries
(native assets, 1,628-lovelace-short), healthy-below-reshape-threshold,
and breaker behavior including error and reset paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:10:45 +02:00
56 changed files with 4404 additions and 56 deletions

69
.github/workflows/deploy-mainnet.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Deploy mainnet
# CD for the vector-marketplace host (91.98.147.172).
#
# Trigger: automatically after the "CI" workflow succeeds on main, or manually
# via workflow_dispatch (Actions tab -> Deploy mainnet -> Run workflow).
#
# The runner only SSHes in and executes deploy/mainnet/deploy.sh AT THE NEW
# COMMIT (fetched server-side via `git show FETCH_HEAD:`), so deploy logic is
# versioned with the code it deploys. The script itself decides which compose
# projects to rebuild based on the old..new diff, drains busy suppliers, and
# health-gates every restart — see deploy/mainnet/deploy.sh.
#
# Secrets: DEPLOY_SSH_KEY — ed25519 private key whose public half is in
# root@vector-marketplace:~/.ssh/authorized_keys.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
force:
description: "Redeploy even if the server is already at origin/main"
type: boolean
default: false
concurrency:
group: deploy-mainnet
cancel-in-progress: false # never kill a deploy mid-rollout
env:
DEPLOY_HOST: 91.98.147.172
DEPLOY_USER: root
# Pinned host key (ssh-keyscan -t ed25519 91.98.147.172)
DEPLOY_HOSTKEY: "91.98.147.172 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJvjM8D+pNUb2Qr4yJbqVcRrUzbpbfyv0/tupUgD5Th0"
jobs:
deploy:
name: deploy to vector-marketplace
runs-on: nixos
# workflow_run fires on failure too — only deploy green main.
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
steps:
# Self-hosted runner: keep key material in the job's temp dir, not the
# runner user's ~/.ssh (which persists across jobs).
- name: Set up SSH
run: |
install -m 700 -d "$RUNNER_TEMP/ssh"
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > "$RUNNER_TEMP/ssh/id_ed25519"
chmod 600 "$RUNNER_TEMP/ssh/id_ed25519"
printf '%s\n' "$DEPLOY_HOSTKEY" > "$RUNNER_TEMP/ssh/known_hosts"
- name: Run deploy script on server
run: |
FORCE=0
[ "${{ github.event.inputs.force }}" = "true" ] && FORCE=1
ssh -o BatchMode=yes \
-o IdentitiesOnly=yes \
-i "$RUNNER_TEMP/ssh/id_ed25519" \
-o UserKnownHostsFile="$RUNNER_TEMP/ssh/known_hosts" \
"$DEPLOY_USER@$DEPLOY_HOST" "
set -euo pipefail
cd /root/agents-marketplace
git fetch origin main
git show FETCH_HEAD:deploy/mainnet/deploy.sh > /run/marketplace-deploy.sh
FORCE=$FORCE bash /run/marketplace-deploy.sh
"

View File

@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json
COPY supplier/package.json ./supplier/package.json

View File

@@ -73,8 +73,10 @@ import {
buildPostEscrowTx,
buildPostTtsEscrowTx,
buildPostChatEscrowTx,
buildPostOcrEscrowTx,
ttsPromptHash,
chatSessionPromptHash,
ocrPromptHash,
buildAcceptTx,
buildReclaimTx,
TxConstructionError,
@@ -88,6 +90,8 @@ import type {
SubmitPromptResult,
SubmitTtsOptions,
SubmitTtsResult,
SubmitOcrOptions,
SubmitOcrResult,
StartChatOptions,
StartChatResult,
ChatSettleMode,
@@ -807,6 +811,278 @@ export class Marketplace extends EventEmitterBase {
};
}
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
const request = { image_b64, mime, output_format };
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
const recordFailure = (reason: string): void => {
this.historyStore.save({
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
supplier_pkh: advertDatum?.supplier_pkh ?? "",
capability_id: advertDatum?.capability_id ?? "",
prompt_preview: previewStr,
posted_at: postedAtMs,
status: "failed",
failure_reason: reason,
});
};
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
let escrowResult;
try {
const utxo = await this.chain.queryUtxo(advertRef);
if (utxo && utxo.datumHex) {
try {
advertDatum = decodeAdvertDatum(utxo.datumHex);
} catch { /* builder will re-throw structurally */ }
}
escrowResult = await buildPostOcrEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
request,
payment_lovelace,
});
} catch (err) {
if (err instanceof TxConstructionError) {
recordFailure(err.reason);
throw err;
}
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
recordFailure((err as Error).message);
throw err;
}
escrowOutputRef = escrowResult.escrowOutputRef;
escrowRefStr = refToString(escrowOutputRef);
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch { /* mock providers / tests */ }
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
try {
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
}
} catch { /* metadata best-effort */ }
if (!advertDatum) {
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const ocrBody = { image_b64, mime, output_format };
let ocrResult;
try {
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
});
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
recordFailure((err as Error).message);
throw err;
}
// 202 + poll mirror of submitTts's async handling. OCR inference for a
// page runs seconds; the long tail is the Submit tx confirmation.
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
const jobId = (ocrResult.body as { job_id?: string }).job_id;
if (typeof jobId !== "string" || jobId.length === 0) {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier 202 response missing job_id",
});
recordFailure("malformed_response");
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
let polled = ocrResult;
let firstIter = true;
while (Date.now() < pollDeadline) {
if (!firstIter) {
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
}
firstIter = false;
try {
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
throw err;
}
if (polled.status === 200) break;
if (polled.status === 202) continue;
const failBody = (polled.body && typeof polled.body === "object")
? polled.body as { reason?: string; message?: string }
: {};
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
status: polled.status,
message: failBody.message ?? `supplier returned ${polled.status}`,
});
recordFailure(sErr.reason);
throw sErr;
}
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
});
recordFailure("timeout");
throw sErr;
}
ocrResult = polled;
}
if (!ocrResult.ok) {
const bodyReason =
ocrResult.body && typeof ocrResult.body === "object"
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
const sErr = new SupplierError(bodyReason, {
status: ocrResult.status,
message: `supplier returned ${ocrResult.status}`,
});
recordFailure(bodyReason);
throw sErr;
}
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier body is not valid JSON",
});
recordFailure("malformed_response");
throw sErr;
}
const responseBody = ocrResult.body as {
output_format?: string;
content?: string;
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
};
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier response is missing content / receipt / receipt_signature",
});
recordFailure("malformed_response");
throw sErr;
}
const receipt = responseBody.receipt;
const receiptSignature = responseBody.receipt_signature;
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
// ── 5. Receipt verification ───────────────────────────────────────
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
recordFailure("wrong_supplier");
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
recordFailure("wrong_escrow_ref");
throw new ReceiptVerificationError("wrong_escrow_ref");
}
// The OCR prompt commitment uses the SAME canonical hash the supplier
// validated against the escrow datum. Recompute from the envelope we
// sent; a mismatch means the supplier signed a different request.
const expectedPromptHash = ocrPromptHash(request);
if (receipt.prompt_hash !== expectedPromptHash) {
recordFailure("prompt_hash_mismatch");
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
if (receipt.model !== advertDatum.model) {
recordFailure("request_spec_hash_mismatch");
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
// The receipt's response_hash must commit the content we actually
// received in the requested output shape.
const expectedResponseHash = nodeCrypto.createHash("sha256")
.update(canonicalize({
content: responseBody.content,
output_format: responseBody.output_format ?? output_format,
}), "utf8")
.digest("hex");
if (receipt.response_hash !== expectedResponseHash) {
recordFailure("response_hash_mismatch");
throw new ReceiptVerificationError("response_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (receiptSignature === ZERO_SIGNATURE) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
recordFailure("malformed_receipt");
throw new ReceiptVerificationError("malformed_receipt");
}
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
this.historyStore.save({
escrow_ref: escrowRefStr,
supplier_pkh: advertDatum.supplier_pkh,
capability_id: advertDatum.capability_id,
prompt_preview: previewStr,
posted_at: postedAtMs,
status: "completed",
response: responseBody.content.length <= 200
? responseBody.content
: responseBody.content.slice(0, 200),
receipt,
receipt_signature: receiptSignature,
});
return {
output_format: responseBody.output_format ?? output_format,
content: responseBody.content,
usage: {
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
total_tokens: responseBody.usage?.total_tokens ?? 0,
},
receipt,
receiptSignature,
escrowRef: escrowOutputRef,
};
}
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
/**

View File

@@ -187,6 +187,35 @@ export interface SubmitTtsResult {
escrowRef: OutputReference;
}
/**
* SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped
* `ocr.page.extract.<model-slug>.v1` capabilities. The same `{image_b64,
* mime, output_format}` envelope is hashed (sha256 ∘ canonical) into the
* escrow datum's `prompt_hash` AND posted to the supplier's /v1/ocr/extract
* body, so the supplier can verify the request matches the on-chain commit.
*/
export interface SubmitOcrOptions {
advertRef: OutputReference;
image_b64: string; // plain base64, no data: prefix
mime: string; // image/png | image/jpeg | image/webp
output_format: string; // markdown | html | json
payment_lovelace: bigint;
}
/**
* SubmitOcrResult — returned from Marketplace.submitOcr() on success.
* `content` is the extraction in the requested output_format; the receipt's
* response_hash commits sha256(canonical({content, output_format})).
*/
export interface SubmitOcrResult {
output_format: string;
content: string;
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
receipt: Receipt;
receiptSignature: string;
escrowRef: OutputReference;
}
/**
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and

View File

@@ -289,3 +289,34 @@ For the M1-F-2 (LiveOgmiosProvider) handoff: the supplier compose already
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
starts talking to a real Ogmios, no compose changes are needed.
## 10. Continuous deployment (mainnet, vector-marketplace host)
Merges to `main` deploy automatically to the mainnet host once CI is green:
1. `.github/workflows/ci.yml` runs typecheck + vitest on the merge commit.
2. On success, `.github/workflows/deploy-mainnet.yml` SSHes into the host
(secret `DEPLOY_SSH_KEY`, host key pinned in the workflow) and executes
`deploy/mainnet/deploy.sh` **at the new commit** (`git show FETCH_HEAD:...`),
so deploy logic always matches the code being deployed.
3. The script hard-resets `/root/agents-marketplace` to `origin/main`, maps the
old..new diff to affected compose projects (changes under `packages/`,
`patches/`, `contracts/` or root manifests rebuild everything), builds all
affected images up front, then does a rolling `up -d` with a health gate per
project. Suppliers are drained first: `/status` is polled until not
`working` (up to `DRAIN_TIMEOUT_SECS`, default 600) so an in-flight job
isn't killed and its escrow bond forfeited.
Manual controls:
- **Manual deploy / redeploy**: Actions → "Deploy mainnet" → Run workflow
(check *force* to redeploy the same sha), or on the host:
`bash /root/agents-marketplace/deploy/mainnet/deploy.sh` (`FORCE=1` to
redeploy, `DRY_RUN=1` to preview which projects would restart).
- **Rollback**: `cd /root/agents-marketplace && git reset --hard <old-sha> &&
FORCE=1 bash deploy/mainnet/deploy.sh`. Every deploy appends
`old -> new (projects)` to `/var/log/marketplace-deploy.log`.
Projects with no running containers on the host (e.g. wallet-monitor) are
skipped; image-only projects (ollama, chatmock, tts-piper) redeploy only when
their own compose file changes.

178
deploy/mainnet/deploy.sh Executable file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# deploy/mainnet/deploy.sh — pull-based CD for the vector-marketplace host.
#
# Runs ON the server (root@vector-marketplace, repo at /root/agents-marketplace).
# Invoked by .github/workflows/deploy-mainnet.yml over SSH, but safe to run by
# hand: `bash deploy/mainnet/deploy.sh`.
#
# What it does:
# 1. Fetches origin/main and hard-resets the checkout to it.
# 2. Diffs old..new to work out which compose projects actually need a
# rebuild (gateway vs suppliers vs buyer vs indexer ...). Changes under
# packages/, patches/, contracts/ or the root manifests rebuild everything,
# because every Dockerfile COPYs those into its build context.
# 3. Builds affected images first (all 17 supplier projects share one
# Dockerfile/context, so build #2..#17 are layer-cache hits), THEN does a
# rolling `up -d` so the downtime window is restart-only, not build+restart.
# 4. Suppliers are drained before recreate: /status is polled until the
# supplier is not "working" (a busy supplier killed mid-job forfeits its
# escrow bond). After DRAIN_TIMEOUT_SECS we recreate anyway, so a wedged
# supplier cannot block the deploy forever.
# 5. Every restarted container must report healthy (or plain running when it
# has no healthcheck) within HEALTH_TIMEOUT_SECS or the deploy fails.
#
# Env knobs:
# FORCE=1 redeploy even if already at origin/main
# DRY_RUN=1 print what would be rebuilt/restarted, change nothing
# DRAIN_TIMEOUT_SECS=N max wait per busy supplier (default 600, 0 = skip)
# HEALTH_TIMEOUT_SECS=N max wait for healthy (default 180)
#
# Rollback: git reset --hard <old-sha> && FORCE=1 bash deploy/mainnet/deploy.sh
# (old sha is printed below and appended to /var/log/marketplace-deploy.log).
set -euo pipefail
REPO=/root/agents-marketplace
COMPOSE_DIR="$REPO/deploy/mainnet"
LOCK=/run/marketplace-deploy.lock
LOG=/var/log/marketplace-deploy.log
DRAIN_TIMEOUT_SECS="${DRAIN_TIMEOUT_SECS:-600}"
HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-180}"
FORCE="${FORCE:-0}"
DRY_RUN="${DRY_RUN:-0}"
exec 9>"$LOCK"
flock -n 9 || { echo "another deploy is already running (lock: $LOCK)"; exit 1; }
cd "$REPO"
OLD=$(git rev-parse HEAD)
git fetch origin main
NEW=$(git rev-parse FETCH_HEAD)
if [ "$OLD" = "$NEW" ] && [ "$FORCE" != "1" ]; then
echo "already at origin/main ($NEW) — nothing to deploy (FORCE=1 to override)"
exit 0
fi
echo "deploying $OLD -> $NEW"
if [ "$DRY_RUN" = "1" ]; then
echo "(dry run — checkout not reset, nothing rebuilt or restarted)"
else
git reset --hard FETCH_HEAD
fi
CHANGED=$(git diff --name-only "$OLD" "$NEW" || true)
[ "$FORCE" = "1" ] && [ -z "$CHANGED" ] && CHANGED="(forced)"
# Paths COPY'd into every image's build context (see */Dockerfile).
SHARED_RE='^(packages/|patches/|contracts/|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig\.base\.json$|\.npmrc$)'
changed_matches() { # changed_matches <ere> -> 0 if any changed path matches
[ "$FORCE" = "1" ] && return 0
echo "$CHANGED" | grep -qE "$1"
}
# Compose project -> the source prefixes that require rebuilding its image.
# Image-only projects (ollama, chatmock, tts-piper) redeploy only when their
# own compose file changes.
project_re() {
case "$1" in
docker-compose.indexer.yml) echo "^indexer/|^indexer-ui/|$SHARED_RE" ;;
docker-compose.buyer.yml) echo "^buyer/|$SHARED_RE" ;;
docker-compose.gateway.yml) echo "^gateway/|$SHARED_RE" ;;
docker-compose.wallet-monitor.yml) echo "^wallet-monitor/|$SHARED_RE" ;;
docker-compose.supplier*.yml) echo "^supplier/|$SHARED_RE" ;;
*) echo "" ;;
esac
}
# Restart order: infra first, suppliers last (each supplier drains first).
ORDERED_NON_SUPPLIER="docker-compose.indexer.yml docker-compose.buyer.yml docker-compose.gateway.yml docker-compose.wallet-monitor.yml docker-compose.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
SUPPLIERS=$(cd "$COMPOSE_DIR" && ls docker-compose.supplier*.yml)
AFFECTED=()
for f in $ORDERED_NON_SUPPLIER $SUPPLIERS; do
[ -f "$COMPOSE_DIR/$f" ] || continue
re=$(project_re "$f")
hit=0
[ -n "$re" ] && changed_matches "$re" && hit=1
changed_matches "^deploy/mainnet/$f\$" && hit=1
[ "$hit" = "1" ] || continue
if [ -z "$(docker compose -f "$COMPOSE_DIR/$f" ps -q 2>/dev/null)" ]; then
echo "skip $f — affected but no running containers on this host"
continue
fi
AFFECTED+=("$f")
done
if [ "${#AFFECTED[@]}" -eq 0 ]; then
echo "no running compose project is affected by $OLD..$NEW — done"
echo "$(date -Is) $OLD -> $NEW (no services affected)" >> "$LOG"
exit 0
fi
echo "affected projects: ${AFFECTED[*]}"
if [ "$DRY_RUN" = "1" ]; then
echo "(dry run — stopping before build/rollout)"
exit 0
fi
echo "== build phase =="
for f in "${AFFECTED[@]}"; do
echo "-- building $f"
docker compose -f "$COMPOSE_DIR/$f" build
done
drain_supplier() { # drain_supplier <compose-file>
[ "$DRAIN_TIMEOUT_SECS" = "0" ] && return 0
local cid deadline status
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q | head -1)
[ -n "$cid" ] || return 0
deadline=$(( $(date +%s) + DRAIN_TIMEOUT_SECS ))
while [ "$(date +%s)" -lt "$deadline" ]; do
status=$(docker exec "$cid" wget -qO- http://localhost:8080/status 2>/dev/null \
| grep -o '"status":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
if [ "$status" != "working" ]; then
return 0
fi
echo " supplier busy (status=working) — waiting for job to finish..."
sleep 15
done
echo " WARNING: still busy after ${DRAIN_TIMEOUT_SECS}s — recreating anyway"
}
wait_healthy() { # wait_healthy <compose-file>
local deadline cid state
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS ))
for cid in $(docker compose -f "$COMPOSE_DIR/$1" ps -q); do
while :; do
state=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")
case "$state" in
healthy|running) break ;;
starting|unhealthy|created|restarting)
if [ "$(date +%s)" -ge "$deadline" ]; then
echo " FAILED: $(docker inspect -f '{{.Name}}' "$cid") is '$state' after ${HEALTH_TIMEOUT_SECS}s; last logs:"
docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'
return 1
fi
sleep 5 ;;
*) echo " FAILED: container state '$state'"; docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'; return 1 ;;
esac
done
done
}
echo "== rollout phase =="
for f in "${AFFECTED[@]}"; do
echo "-- rolling out $f"
case "$f" in docker-compose.supplier*) drain_supplier "$f" ;; esac
docker compose -f "$COMPOSE_DIR/$f" up -d
wait_healthy "$f"
echo " ok"
done
echo "$(date -Is) $OLD -> $NEW (${AFFECTED[*]})" >> "$LOG"
echo "== deploy complete: $NEW =="
echo "rollback: cd $REPO && git reset --hard $OLD && FORCE=1 bash deploy/mainnet/deploy.sh"

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.middlewares=mp-suppliers-deepseek-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -58,6 +58,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.middlewares=mp-suppliers-ds-flash-chat-1-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -58,6 +58,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.middlewares=mp-suppliers-ds-flash-chat-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -58,6 +58,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.middlewares=mp-suppliers-ds-flash-chat-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -61,6 +61,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.middlewares=mp-suppliers-gpt-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -62,6 +62,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -62,6 +62,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -74,6 +74,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.middlewares=mp-suppliers-kimi-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -75,6 +75,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.middlewares=mp-suppliers-kimi-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-tts-mainnet.middlewares=mp-suppliers-tts-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -60,6 +60,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-mainnet.middlewares=mp-suppliers-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-mainnet-tls.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -22,6 +22,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json
COPY gateway/package.json ./gateway/package.json

View File

@@ -53,6 +53,9 @@ export interface GatewayConfig {
walletHealthIntervalMs: number;
sdkRegistryMax: number;
corsOrigins: string[];
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
ocrCapabilityId: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -138,6 +141,7 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
chatSettleMode: chatSettleMode(env),
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))

View File

@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
const invokedDirectly =
typeof process !== "undefined" &&
process.argv?.[1] !== undefined &&
/gateway\/(src|dist)\/index\.(t|j)s$/.test(process.argv[1]);
// Accept both path separators: on Windows process.argv[1] arrives with
// backslashes, so a forward-slash-only pattern silently never matches and
// the process exits 0 without ever starting the server.
/gateway[\\/](src|dist)[\\/]index\.(t|j)s$/.test(process.argv[1]);
if (invokedDirectly) {
try {

402
gateway/src/ocr/extract.ts Normal file
View File

@@ -0,0 +1,402 @@
/**
* gateway/src/ocr/extract.ts — POST /v1/ocr/extract.
*
* One escrow per page against a model-scoped `ocr.page.extract.<model>.v1`
* supplier (config.ocrCapabilityId). Lifecycle mirrors the one-shot chat
* path, all inside the per-key mutex: parse → select supplier → preflight →
* per-candidate live busy pre-check → submitOcr (escrow → supplier call →
* receipt verify) → resolve Submitted ref → Accept (awaited) → record usage
* → wallet-health → respond.
*
* Response (200):
* {
* output_format, content,
* usage: { prompt_tokens, completion_tokens, total_tokens },
* x_vector: {
* receipt, receipt_signature, escrow_ref,
* receipt_labels: ["first-party-brokered"]
* }
* }
*
* Venue attribution: the caller MAY send
* X-Venue: venue slug (e.g. "apify"), ≤ 32 chars
* X-Run-User-Hash: sha256 of the venue-side runner id, 64 hex
* Both are logged on a structured line per settled job. The wedge-ledger
* emitter consumes these lines; a usage-table venue
* column is deliberately deferred to later work so this route stays
* schema-additive. payer_class is derived at ledger time from the hash
* (known first-party hashes vs the rest), never asserted here.
*
* Demo keys are rejected: the demo executor is chat-session-shaped and its
* wallet holds one escrow per session; OCR needs a funded per-key wallet.
*/
import { randomUUID } from "crypto";
import type { Request, Response } from "express";
import { TxConstructionError } from "@marketplace/shared/tx";
import {
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "@marketplace/shared/tx";
import { SupplierError, type ProgressEvent } from "@marketplace/buyer/sdk";
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, type SupplierCandidate } from "../routing/selectSupplier.js";
import { preflight } from "../onchain/preflight.js";
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
import { ensureWalletHealthy } from "../walletHealth.js";
import { badRequest, forbidden, notFound, paymentRequired, toGatewayError } from "../openai/errors.js";
/** Operator-model v0: HAL8 operates both the custodial buyer and the
* supplier fleet, so every receipt this route returns is first-party
* brokered. Becomes per-supplier metadata when
* control-independent supply exists. */
const RECEIPT_LABELS_V0 = ["first-party-brokered"] as const;
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const VENUE_RE = /^[a-z0-9-]{1,32}$/;
/** Bound on the live busy pre-check below - cheap enough that a stalled
* supplier never meaningfully delays the request. */
const SUPPLIER_STATUS_CHECK_TIMEOUT_MS = 2_000;
interface ParsedOcrRequest {
image_b64: string;
mime: string;
output_format: string;
/** "" = any model under the capability (single-model ids). */
model: string;
}
function parseOcrRequest(body: unknown): ParsedOcrRequest {
if (typeof body !== "object" || body === null) {
throw badRequest("invalid_body", "request body must be a JSON object");
}
const b = body as Record<string, unknown>;
const image = b.image_b64;
if (typeof image !== "string" || image.length === 0) {
throw badRequest("invalid_image", "`image_b64` is required and must be a non-empty base64 string");
}
if (image.length > MAX_OCR_IMAGE_B64_CHARS) {
throw badRequest("image_too_large", `\`image_b64\` exceeds ${MAX_OCR_IMAGE_B64_CHARS} characters`);
}
if (!B64_RE.test(image)) {
throw badRequest("invalid_image", "`image_b64` must be plain base64 (no data: prefix, no whitespace)");
}
const mime = b.mime;
if (typeof mime !== "string" || !ALLOWED_OCR_MIMES.has(mime)) {
throw badRequest("invalid_mime", `\`mime\` must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
const rawFormat = b.output_format ?? "markdown";
if (typeof rawFormat !== "string" || !ALLOWED_OCR_OUTPUT_FORMATS.has(rawFormat)) {
throw badRequest(
"invalid_output_format",
`\`output_format\` must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`,
);
}
let model = "";
if (b.model !== undefined && b.model !== null) {
if (typeof b.model !== "string" || b.model === "") {
throw badRequest("invalid_model", "`model` must be a non-empty string when provided");
}
model = b.model;
}
return { image_b64: image, mime, output_format: rawFormat, model };
}
/** 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 {
venue: VENUE_RE.test(venueRaw) ? venueRaw : "",
runUserHash: HEX64_RE.test(hashRaw) ? hashRaw.toLowerCase() : "",
};
}
/**
* Live busy pre-check, run immediately before a candidate's escrow posts.
* Mirrors sessions.ts's startChat pre-check: same signal ("working" on the
* supplier's own /status), same best-effort posture. Posting an escrow only
* to have the supplier reject as busy strands the escrow until reclaim and
* burns the tx fee, so this is worth a bounded, cheap HTTP round trip.
*
* Never a gate: the indexer already filters candidates on advert status, so
* a timeout, network error, non-2xx, or malformed body all resolve to "not
* busy" (unknown, not blocking) - a briefly-unreachable /status must never
* stop the candidate from being attempted.
*/
async function isSupplierBusy(
endpointUrl: string,
fetchFn: typeof globalThis.fetch,
): Promise<boolean> {
try {
const res = await fetchFn(`${endpointUrl.replace(/\/+$/, "")}/status`, {
signal: AbortSignal.timeout(SUPPLIER_STATUS_CHECK_TIMEOUT_MS),
});
if (!res.ok) return false;
const body = (await res.json()) as unknown;
const status = body && typeof body === "object" ? (body as { status?: unknown }).status : undefined;
return status === "working";
} catch {
return false;
}
}
function recordOcrUsage(
store: GatewayStore,
p: {
keyId: string;
capabilityId: string;
model: string;
supplierPkh: string | null;
escrowRef: string | null;
costLovelace: bigint | null;
promptTokens: number;
completionTokens: number;
status: "completed" | "failed";
failureReason?: string;
},
): void {
store.insertUsage({
id: randomUUID(),
key_id: p.keyId,
created_at: Date.now(),
kind: "ocr",
model: p.model,
capability_id: p.capabilityId,
supplier_pkh: p.supplierPkh,
escrow_ref: p.escrowRef,
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
prompt_tokens: p.promptTokens,
completion_tokens: p.completionTokens,
status: p.status,
failure_reason: p.failureReason ?? null,
});
}
export function makeOcrExtractHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
if (keyRow.demo) {
throw forbidden("demo_key_unsupported", "demo keys cannot use /v1/ocr/extract; create a funded API key");
}
const ctx = deps.registry.getContext(keyRow);
const parsed = parseOcrRequest(req.body);
const attribution = venueAttribution(req);
await ctx.mutex.run(() => runOcrOneShot(deps, keyRow.id, ctx, parsed, attribution, res));
});
}
async function runOcrOneShot(
deps: GatewayDeps,
keyId: string,
ctx: ReturnType<GatewayDeps["registry"]["getContext"]>,
parsed: ParsedOcrRequest,
attribution: { venue: string; runUserHash: string },
res: Response,
): Promise<void> {
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,
model: parsed.model,
capabilityId,
fetchFn,
});
if (candidates.length === 0) {
throw notFound(
"model_not_found",
parsed.model === ""
? `no available supplier for capability "${capabilityId}"`
: `no available supplier for model "${parsed.model}" under "${capabilityId}"`,
);
}
const primary = candidates[0];
const pf = await preflight(chain, ctx.walletKey.address, primary);
if (!pf.ok) {
throw paymentRequired(
pf.collateralOk
? "insufficient balance for this request"
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
{
x_vector: {
required_lovelace: pf.requiredLovelace.toString(),
available_lovelace: pf.availableLovelace.toString(),
collateral_ok: pf.collateralOk,
deposit_address: ctx.walletKey.address,
},
},
);
}
// 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).
//
// submitOcr posts the escrow internally and only returns escrowRef when
// it resolves. When the supplier call AFTER the post throws
// (SupplierError: timeout, network failure, non-2xx, malformed
// response, receipt mismatch) the escrow already landed on chain but
// escrowRefStr below would otherwise stay null — the OCR upstream has a
// 120s budget, so this is likely the most common paid-but-failed shape.
// The SDK emits a "progress" event of type "escrow_posted" (carrying
// escrow_ref) the instant the tx confirms, so listen for it around each
// attempt and capture the ref from the event rather than only from
// submitOcr's return value (I2 follow-up).
let lastErr: unknown;
for (const cand of candidates) {
// Live pre-check, run right here (not once at the top of the request):
// skip a candidate that is busy right now instead of paying to find
// out. See isSupplierBusy's docstring for the exact signal + posture.
if (await isSupplierBusy(cand.endpointUrl, fetchFn)) {
lastErr = new SupplierError("supplier_busy", {
status: 409,
message: "supplier is busy with another job",
});
continue;
}
const onProgress = (ev: unknown): void => {
const e = ev as ProgressEvent;
if (e.type === "escrow_posted" && e.escrow_ref) escrowRefStr = e.escrow_ref;
};
ctx.sdk.on("progress", onProgress);
try {
result = await ctx.sdk.submitOcr({
advertRef: cand.advertRef,
image_b64: parsed.image_b64,
mime: parsed.mime,
output_format: parsed.output_format,
payment_lovelace: cand.priceLovelace,
});
used = cand;
break;
} catch (err) {
lastErr = err;
if (err instanceof TxConstructionError) continue;
throw err;
} finally {
ctx.sdk.off("progress", onProgress);
}
}
if (!result || !used) {
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
}
// Settle: resolve the live Submitted ref, Accept, await confirmation.
escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
const submittedRef = await resolveSubmittedRef({
indexerUrl: config.indexerUrl,
buyerPkh: ctx.walletKey.pubKeyHash,
originalRefStr: escrowRefStr,
fetchFn,
});
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
recordOcrUsage(store, {
keyId,
capabilityId,
model: used.model,
supplierPkh: used.supplierPkh,
escrowRef: escrowRefStr,
costLovelace: used.priceLovelace,
promptTokens: result.usage.prompt_tokens,
completionTokens: result.usage.completion_tokens,
status: "completed",
});
// Structured settle line for the wedge-ledger emitter. One line per
// settled job; venue/run_user_hash empty when the
// caller sent none.
console.log(JSON.stringify({
evt: "ocr_settled",
escrow_ref: escrowRefStr,
capability_id: capabilityId,
model: used.model,
supplier_pkh: used.supplierPkh,
cost_lovelace: used.priceLovelace.toString(),
receipt_labels: RECEIPT_LABELS_V0,
venue: attribution.venue,
run_user_hash: attribution.runUserHash,
ts: new Date().toISOString(),
}));
// Re-shape the wallet ({collateral, working}) so the next request keeps
// a valid collateral UTxO. Best-effort; serialized by the mutex.
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
// eslint-disable-next-line no-console
console.error("[gateway] wallet-health after ocr:", e instanceof Error ? e.message : e),
);
res.status(200).json({
output_format: result.output_format,
content: result.content,
usage: result.usage,
x_vector: {
receipt: result.receipt,
receipt_signature: result.receiptSignature,
escrow_ref: escrowRefStr,
receipt_labels: RECEIPT_LABELS_V0,
},
});
} catch (err) {
recordOcrUsage(store, {
keyId,
capabilityId,
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

@@ -53,6 +53,9 @@ export function parseRef(ref: string): OutputReference | null {
export interface SelectSupplierOpts {
indexerUrl: string;
/** Exact model match; "" matches ANY model under the capability (used by
* single-model capabilities like the ocr.* ids, where the capability
* already pins the model). */
model: string;
capabilityId: string;
fetchFn?: typeof globalThis.fetch;
@@ -82,7 +85,7 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
const candidates: SupplierCandidate[] = [];
for (const raw of body as SupplierView[]) {
if (raw.capability_id !== opts.capabilityId) continue;
if (raw.model !== opts.model) continue;
if (opts.model !== "" && raw.model !== opts.model) continue;
if (raw.advert_status !== "Active") continue;
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
continue;

View File

@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
import { sendError } from "./middleware/http.js";
import { notFound } from "./openai/errors.js";
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
import { makeOcrExtractHandler } from "./ocr/extract.js";
import { makeModelsHandler } from "./openai/models.js";
import {
makeOpenSessionHandler,
@@ -56,7 +57,11 @@ export function createApp(deps: GatewayDeps): Express {
app.set("trust proxy", true);
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
app.use(corsMiddleware(deps.config.corsOrigins));
app.use(express.json({ limit: "1mb" }));
// /v1/ocr carries a base64 page image and mounts its own larger parser on
// 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));
app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true });
@@ -80,6 +85,12 @@ export function createApp(deps: GatewayDeps): Express {
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
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. 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

@@ -46,6 +46,7 @@ function makeDeps(
chatSettleMode: overrides?.chatSettleMode ?? "full",
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [],
};
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);

572
gateway/test/ocr.test.ts Normal file
View File

@@ -0,0 +1,572 @@
/**
* gateway/test/ocr.test.ts — POST /v1/ocr/extract route coverage.
*
* Focus: auth gating, demo-key rejection, body validation, capability-based
* supplier lookup, and the enlarged body limit. The settle path (submitOcr →
* resolveSubmittedRef → acceptAndConfirm) reuses the one-shot chat plumbing
* covered by http.test.ts + the SDK suites; end-to-end runs live on testnet.
*/
import { describe, it, expect, vi } from "vitest";
import { tmpdir } from "os";
import { join } from "path";
import { randomBytes, randomUUID } from "crypto";
import request from "supertest";
import { createApp } from "../src/server.js";
import { GatewayStore } from "../src/db/store.js";
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";
import { SupplierError } from "@marketplace/buyer/sdk";
// 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");
/** 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 = {
masterKeyHex: MASTER,
indexerUrl: "http://ix",
ogmiosUrl: "http://og",
networkId: 0,
liveChain: true,
port: 0,
dbDir,
signupRate: { max: 1000, windowMs: 60_000 },
keyRate: { max: 1000, windowMs: 60_000 },
demoIpRate: { max: 1000, windowMs: 60_000 },
sweeperIntervalMs: 60_000,
demoSessionIdleMs: 180_000,
chatSettleMode: "full",
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [],
};
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
const registry = new SdkRegistry({
chain,
indexerUrl: config.indexerUrl,
networkId: config.networkId,
masterKeyHex: config.masterKeyHex,
max: config.sdkRegistryMax,
});
const defaultFetch = (async () => new Response("[]", { status: 200 })) as unknown as typeof globalThis.fetch;
return { config, store, chain, registry, fetchFn: fetchFn ?? defaultFetch };
}
/** Insert a key row (demo or funded) and return the raw bearer token. */
function makeKey(deps: GatewayDeps, demo: 0 | 1): string {
const rawKey = `vk-${randomBytes(24).toString("hex")}`;
const privHex = genPrivKeyHex();
const walletKey = deriveWalletKey(privHex, 0);
const sealed = seal(privHex, MASTER);
deps.store.insertKey({
id: randomUUID(),
key_hash: hashApiKey(rawKey),
key_prefix: rawKey.slice(0, 12),
label: demo ? "demo" : "funded",
wallet_pkh: walletKey.pubKeyHash,
deposit_address: walletKey.address,
enc_priv_nonce: sealed.nonce,
enc_priv_ct: sealed.ct,
enc_priv_tag: sealed.tag,
master_key_version: 1,
created_at: Date.now(),
demo,
});
return rawKey;
}
function validBody() {
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
}
describe("POST /v1/ocr/extract — auth + demo gating", () => {
it("401 without a bearer key, OpenAI error shape", async () => {
const res = await request(createApp(makeDeps()))
.post("/v1/ocr/extract").send(validBody());
expect(res.status).toBe(401);
expect(res.body.error?.type).toBe("authentication_error");
});
it("403 demo_key_unsupported for demo keys", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 1);
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(res.status).toBe(403);
expect(res.body.error?.code).toBe("demo_key_unsupported");
});
});
describe("POST /v1/ocr/extract — body validation", () => {
async function post(body: unknown) {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
return request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(body as object);
}
it("400 invalid_image on missing/empty/data-URL image_b64", async () => {
for (const image_b64 of [undefined, "", `data:image/png;base64,${IMG}`]) {
const res = await post({ ...validBody(), image_b64 });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe("invalid_image");
}
});
it("400 invalid_mime on unsupported mime", async () => {
const res = await post({ ...validBody(), mime: "image/gif" });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe("invalid_mime");
});
it("400 invalid_output_format on unsupported format", async () => {
const res = await post({ ...validBody(), output_format: "yaml" });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe("invalid_output_format");
});
it("output_format defaults to markdown (reaches supplier lookup)", async () => {
const res = await post({ image_b64: IMG, mime: "image/png" });
// Passes validation, then the empty indexer answer → 404 model_not_found.
expect(res.status).toBe(404);
expect(res.body.error?.code).toBe("model_not_found");
});
});
describe("POST /v1/ocr/extract — supplier lookup + body limit", () => {
it("404 model_not_found when no supplier advertises the capability", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(res.status).toBe(404);
expect(res.body.error?.code).toBe("model_not_found");
});
it("queries the indexer with the configured ocr capability id", async () => {
let queried = "";
const fetchFn = (async (url: unknown) => {
queried = String(url);
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
const deps = makeDeps(fetchFn);
const rawKey = makeKey(deps, 0);
await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(queried).toContain("capability_id=ocr.page.extract.chandra-ocr-2.v1");
});
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
const big = "A".repeat(2_000_000);
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send({ ...validBody(), image_b64: big });
expect(res.status).not.toBe(413);
// Valid base64 + no suppliers → 404 model_not_found.
expect(res.status).toBe(404);
});
it("chat completions keep the 1mb ceiling (413 on a 2MB body)", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
const big = "A".repeat(2_000_000);
const res = await request(createApp(deps))
.post("/openai/v1/chat/completions")
.set("Authorization", `Bearer ${rawKey}`)
.send({ model: "qwen", messages: [{ role: "user", content: big }] });
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`);
});
});
// ─── Failure path — escrow ref survives a post-escrow SupplierError (I2) ────
//
// submitOcr posts the escrow, THEN calls the supplier. A SupplierError
// (timeout, network failure, non-2xx, malformed response, receipt mismatch)
// means the escrow already landed on chain even though submitOcr rejects —
// per the OCR route's 120s supplier budget, this is likely the MOST common
// paid-but-failed shape. Before this fix, escrowRefStr was only assigned
// from submitOcr's *return value*, so this exact case recorded escrow_ref:
// null. The route now also listens for the SDK's "escrow_posted" progress
// event around each attempt, so the ref survives regardless of whether
// submitOcr goes on to resolve or reject.
describe("POST /v1/ocr/extract — escrow ref survives a post-escrow SupplierError", () => {
it("records the escrow_ref from the escrow_posted progress event, not null, when submitOcr posts an escrow then rejects", 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))!;
// Same monkeypatch pattern as the happy-path test above: fetch the real
// per-key Marketplace instance from the registry BEFORE the request so
// its real EventEmitter (on/off/emit) stays live, then replace only
// submitOcr. The stub mirrors what Marketplace.submitOcr actually does
// on this failure shape: emit "escrow_posted" the instant the tx
// confirms, then throw once the supplier call fails.
const ctx = deps.registry.getContext(keyRow);
const knownEscrowRef = `${"9".repeat(64)}#0`;
const submitOcr = vi.fn(async () => {
ctx.sdk.emit("progress", { type: "escrow_posted", escrow_ref: knownEscrowRef });
throw new SupplierError("timeout", { message: "supplier did not respond within budget" });
});
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
// Snapshot the recorded calls BEFORE restoring — mockRestore() also
// clears .mock.calls (it runs mockReset()/mockClear() first), so
// reading logSpy.mock.calls after restore always sees [].
const loggedLines = logSpy.mock.calls.map((args) => String(args[0]));
logSpy.mockRestore();
expect(submitOcr).toHaveBeenCalledTimes(1);
// toGatewayError maps SupplierError("timeout") to 504 supplier_timeout —
// confirms the request actually took the failure path this test targets.
expect(res.status).toBe(504);
expect(res.body.error?.code).toBe("supplier_timeout");
// The reconciliation spine: the usage row recorded in the catch block
// must carry the real ref, not null.
const rows = deps.store.listUsage(keyRow.id, 10);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("failed");
expect(rows[0].escrow_ref).toBe(knownEscrowRef);
// The structured ocr_failed log line (wedge-ledger emitter input) must
// carry the same ref.
const failedLine = loggedLines
.filter((line) => line.includes('"evt":"ocr_failed"'))
.map((line) => JSON.parse(line) as { escrow_ref: string | null });
expect(failedLine).toHaveLength(1);
expect(failedLine[0].escrow_ref).toBe(knownEscrowRef);
});
});
// ─── Busy pre-check - never post an escrow to a candidate that is busy ──────
//
// runOcrOneShot now checks each candidate's live /status immediately before
// calling submitOcr for it (mirrors sessions.ts's startChat pre-check):
// "working" means busy and moves on to the next candidate WITHOUT posting an
// escrow; anything else (free, unreachable, malformed, slow) is treated as
// usable. submitOcr is the escrow-posting path in these tests (same
// monkeypatch pattern as the suites above), so asserting its call count is
// exactly how "no escrow was posted" is observed.
const OCR_SUPPLIER_A = {
...OCR_SUPPLIER,
utxo_ref: `${"aa".repeat(32)}#0`,
supplier_pkh: "sA",
endpoint_url: "http://sup-a",
};
const OCR_SUPPLIER_B = {
...OCR_SUPPLIER,
utxo_ref: `${"bb".repeat(32)}#0`,
supplier_pkh: "sB",
endpoint_url: "http://sup-b",
};
/** Indexer /suppliers lookup plus a per-endpoint /status responder, on one
* fetchFn - everything else falls back to the suite's usual empty array. */
function makeFetchWithStatus(
suppliers: Array<typeof OCR_SUPPLIER>,
statusByEndpoint: Record<string, () => Response>,
): typeof globalThis.fetch {
return (async (url: unknown) => {
const u = String(url);
if (u.includes("/suppliers")) {
return new Response(JSON.stringify(suppliers), { status: 200 });
}
for (const [endpoint, respond] of Object.entries(statusByEndpoint)) {
if (u === `${endpoint}/status`) return respond();
}
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
}
/** Same successful submitOcr shape as the happy-path suite above, reused
* across the busy pre-check tests. */
function makeSubmitOcrStub() {
return vi.fn(async (_opts: { advertRef: { txHash: string; index: number } }) => ({
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 },
}));
}
describe("POST /v1/ocr/extract - busy pre-check", () => {
it("a busy supplier is never sent an escrow; the route returns the busy response", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
"http://sup": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
// The escrow-posting path was never invoked.
expect(submitOcr).not.toHaveBeenCalled();
// toGatewayError maps SupplierError("supplier_busy") to 503 overloaded -
// the same busy response a post-escrow 409 would have produced.
expect(res.status).toBe(503);
expect(res.body.error?.code).toBe("overloaded");
const rows = deps.store.listUsage(keyRow.id, 10);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("failed");
expect(rows[0].escrow_ref).toBeNull();
});
it("a free supplier proceeds to post and settle exactly as before", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
"http://sup": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(res.status).toBe(200);
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
});
it("falls back to the second candidate when the first is busy, posting exactly one escrow", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER_A, OCR_SUPPLIER_B], {
"http://sup-a": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
"http://sup-b": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(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);
// Exactly one escrow posted, and it is the second (free) candidate's -
// the busy first candidate was skipped, not retried after failing.
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(submitOcr.mock.calls[0][0].advertRef).toEqual({ txHash: "bb".repeat(32), index: 0 });
});
it("a status check that errors does not block the attempt", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
"http://sup": () => {
// Simulates both a network error and an aborted (timed-out) request -
// isSupplierBusy's catch block treats every failure mode the same
// way: unknown, not busy, proceed. A real AbortSignal timeout is not
// exercised here to keep the suite fast; the outcome is identical.
throw new Error("simulated network failure reaching /status");
},
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
// The candidate was still tried despite the failed pre-check.
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(res.status).toBe(200);
});
});

View File

@@ -45,6 +45,7 @@ function makeSweepDeps(opts: { chatSettleMode: "full" | "ticket"; escrows: Escro
chatSettleMode: opts.chatSettleMode,
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [],
};
const chain = { queryUtxo: async () => null } as unknown as GatewayDeps["chain"];

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

@@ -29,6 +29,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY indexer/package.json ./indexer/package.json
COPY indexer-ui/package.json ./indexer-ui/package.json

View File

@@ -0,0 +1,257 @@
/**
* tx/escrow/postOcrEscrow.ts — PostEscrow tx builder for model-scoped OCR
* capabilities (`ocr.page.extract.<model-slug>.v1`, one page per job).
*
* Mirrors `postTtsEscrow.ts` but commits an OCR-shaped prompt_hash over the
* canonicalised request envelope `{image_b64, mime, output_format}` — the
* SAME object the supplier hashes when validating the incoming
* POST /v1/ocr/extract body. Any mismatch on either side fails the
* supplier-side prompt_mismatch gate.
*
* The image travels off-chain (buyer → supplier HTTP body); the chain only
* ever carries prompt_hash. Never place image bytes or extracted text in a
* datum.
*
* Off-chain invariants (mirror TTS's, with OCR-shaped body validation):
* 1. advert UTxO exists at advertRef
* 2. advert datum.status === "Active"
* 3. payment_lovelace === advert.price_lovelace
* 4. buyerKey.pubKeyHash !== advert.supplier_pkh
* 5. body.image_b64 is a non-empty plain-base64 string within size bounds
* (no data-URL prefix — the mime rides in its own field)
* 6. body.mime ∈ ALLOWED_OCR_MIMES
* 7. body.output_format ∈ ALLOWED_OCR_OUTPUT_FORMATS
*/
import * as nodeCrypto from "crypto";
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
import type { EscrowDatum } from "../../cbor/types.js";
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
import { canonicalize } from "../../cbor/canonical.js";
import type { WalletKey, PostEscrowBuildResult } from "../types.js";
import { TxConstructionError } from "../types.js";
import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
/** OCR request envelope. The buyer-side hash and the supplier-side hash
* MUST agree on this exact set of keys, in this exact JCS-canonical
* encoding, or the supplier will reject with `prompt_mismatch`. */
export interface OcrRequest {
/** Plain base64 of the page image bytes. No `data:` prefix. */
image_b64: string;
/** Image content type. */
mime: string;
/** Requested extraction output shape. */
output_format: string;
}
export const ALLOWED_OCR_MIMES = new Set([
"image/png", "image/jpeg", "image/webp",
]);
export const ALLOWED_OCR_OUTPUT_FORMATS = new Set(["markdown", "html", "json"]);
/**
* Upper bound on the base64 payload, in characters (~9 MB of image bytes).
* A 300-DPI page scan lands around 0.5–3 MB binary; this cap protects the
* supplier's JSON body parser and the off-chain transport, not the chain
* (the chain only sees the 32-byte hash).
*/
export const MAX_OCR_IMAGE_B64_CHARS = 12_000_000;
/** Plain base64 (standard alphabet, optional padding). Rejects data URLs,
* whitespace and base64url — one canonical encoding, one hash. */
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
export interface PostOcrEscrowParams {
chain: ChainProvider;
buyerKey: WalletKey;
advertRef: OutputReference;
request: OcrRequest;
payment_lovelace: bigint;
}
function sha256Utf8Hex(s: string): string {
return nodeCrypto.createHash("sha256").update(s, "utf8").digest("hex");
}
/** Hash function shared between buyer (here) and supplier (server.ts).
* Exported so unit tests on either side can pin the canonicalisation. */
export function ocrPromptHash(req: OcrRequest): string {
return sha256Utf8Hex(canonicalize({
image_b64: req.image_b64,
mime: req.mime,
output_format: req.output_format,
}));
}
/** Body validation shared conceptually with the supplier route. Throws
* TxConstructionError with a machine-readable reason. */
export function validateOcrRequest(request: OcrRequest): void {
if (typeof request?.image_b64 !== "string" || request.image_b64.length === 0) {
throw new TxConstructionError("image required",
"request.image_b64 must be a non-empty base64 string");
}
if (request.image_b64.length > MAX_OCR_IMAGE_B64_CHARS) {
throw new TxConstructionError("image too large",
`request.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
}
if (!BASE64_RE.test(request.image_b64)) {
throw new TxConstructionError("image not base64",
"request.image_b64 must be plain base64 (no data: prefix, no whitespace)");
}
if (!ALLOWED_OCR_MIMES.has(request.mime)) {
throw new TxConstructionError("mime invalid",
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(request.output_format)) {
throw new TxConstructionError("output_format invalid",
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
}
}
export async function buildPostOcrEscrowTx(
params: PostOcrEscrowParams,
): Promise<PostEscrowBuildResult> {
const { chain, buyerKey, advertRef, request, payment_lovelace } = params;
// 5–7. Body validation before any chain access.
validateOcrRequest(request);
// 1. Advert UTxO must exist.
const advertUtxo = await chain.queryUtxo(advertRef);
if (advertUtxo === null) {
throw new TxConstructionError(
"advert ref not on chain",
`no UTxO at ${advertRef.txHash}#${advertRef.index}`,
);
}
if (!advertUtxo.datumHex) {
throw new TxConstructionError(
"advert datum missing",
`UTxO ${advertRef.txHash}#${advertRef.index} has no inline datum`,
);
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
// 2. Advert must be Active.
if (advertDatum.status !== "Active") {
throw new TxConstructionError(
"advert is retired",
`advert.status is ${advertDatum.status}, expected Active`,
);
}
// 3. Payment must equal advertised price.
if (payment_lovelace !== advertDatum.price_lovelace) {
throw new TxConstructionError(
"payment must equal advertised price",
`payment ${payment_lovelace} != advert.price ${advertDatum.price_lovelace}`,
);
}
// 4. Buyer cannot be supplier.
if (buyerKey.pubKeyHash === advertDatum.supplier_pkh) {
throw new TxConstructionError(
"buyer cannot be supplier",
`buyerKey pkh ${buyerKey.pubKeyHash} equals advert.supplier_pkh`,
);
}
// Time source: same convention as chat/TTS.
const tipSlot = await chain.tip();
const isLive = detectCborBackend(chain) === "live";
const postedAt = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
const deliverBy = postedAt + advertDatum.max_processing_ms + NETWORK_BUFFER_MS;
const requestSpecCanonical = canonicalize({
capability_id: advertDatum.capability_id,
max_output_tokens: advertDatum.max_output_tokens,
model: advertDatum.model,
});
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = ocrPromptHash(request);
const economicTotal =
advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace;
const escrowDatum: EscrowDatum = {
buyer_pkh: buyerKey.pubKeyHash,
supplier_pkh: advertDatum.supplier_pkh,
advert_ref: advertRef,
capability_id: advertDatum.capability_id,
request_spec_hash: requestSpecHash,
prompt_hash: promptHash,
payment_lovelace: advertDatum.price_lovelace,
buyer_bond_lovelace: advertDatum.buyer_bond_lovelace,
supplier_bond_lovelace: advertDatum.supplier_bond_lovelace,
deliver_by: deliverBy,
posted_at: postedAt,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
// Lock enough that the escrow output satisfies min-ada in its LARGEST
// future state (Submitted) - value_equal on Claim/Submit forbids any
// later bump (2026-08-07 min-ada incident).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the capability-agnostic live builder (messages is
// void-marked there; the on-chain commitment is the escrowDatum, which
// carries our OCR prompt_hash).
if (isLive) {
const liveCborPath = "../internal/liveCbor.js";
const { buildLiveTxForEscrow } = await import(/* @vite-ignore */ liveCborPath);
return buildLiveTxForEscrow({
chain: chain as LiveOgmiosProvider,
buyerKey,
advertRef,
messages: [],
escrowDatum,
totalLocked,
deliverBy,
postedAt,
});
}
// Mock backend path (synthetic JSON-in-hex tx body).
const blueprint = loadBlueprint();
const escrowAddress = blueprint.escrowScriptAddress(0);
const body = {
type: "post-escrow",
inputs: [],
outputs: [
{
ref: { txHash: "$self", index: 0 },
address: escrowAddress,
lovelace: totalLocked,
assets: {},
datumHex: encodeEscrowDatum(escrowDatum),
scriptRef: null,
},
],
requiredSigners: [buyerKey.pubKeyHash],
validityRange: { lowerBoundMs: postedAt, upperBoundMs: deliverBy },
meta: { script_hash: blueprint.escrowScriptHash, advert_ref: advertRef },
};
const txCborHex = encodeTxBody(body);
const expectedTxHash = sha256Hex(txCborHex);
await chain.submitTx(txCborHex);
return {
txCborHex,
expectedTxHash,
escrowOutputRef: { txHash: expectedTxHash, index: 0 },
};
}

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";
@@ -37,6 +38,15 @@ export {
} from "./escrow/postTtsEscrow.js";
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
export { buildPostChatEscrowTx, chatSessionPromptHash } from "./escrow/postChatEscrow.js";
export type { PostOcrEscrowParams, OcrRequest } from "./escrow/postOcrEscrow.js";
export {
buildPostOcrEscrowTx,
ocrPromptHash,
validateOcrRequest,
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "./escrow/postOcrEscrow.js";
export type { ClaimParams } from "./escrow/claim.js";
export { buildClaimTx } from "./escrow/claim.js";
export type { SubmitParams } from "./escrow/submit.js";

View File

@@ -2,9 +2,22 @@
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
*
* Given a wallet's current UTxO set + a target collateral lovelace, decide
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
* {≥collateral, working}). The same downstream tx-builder handles
* consolidate and split — only the "reason" tag differs for logging.
* whether the wallet needs reshaping or is already usable for script spends.
*
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
* candidate lucid's setCollateral needs), AND
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
* spend money that is not the candidate), AND
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
*
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
* remainder as change), so the shape consolidation produced could never
* satisfy its own health check — the wallet-health ticker re-consolidated
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
* The health check also runs BEFORE the low-balance guard now: a wallet that
* can already transact must never be told "balance too low to consolidate".
*
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
*/
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
export const DEFAULT_FEE_RESERVE = 2_000_000n;
/** Reshape only when fragmentation passes this; receipt/change UTxOs
* accumulating between jobs are fine as long as a candidate exists. */
export const DEFAULT_MAX_UTXOS = 6;
const MIN_WORKING_LOVELACE = 1_000_000n;
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
function pureLovelace(u: UtxoLike): bigint | null {
for (const [unit, amount] of Object.entries(u.assets)) {
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
}
return u.assets.lovelace ?? 0n;
}
export function planConsolidate(
utxos: UtxoLike[],
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
feeReserve: bigint = DEFAULT_FEE_RESERVE,
maxUtxos: number = DEFAULT_MAX_UTXOS,
): PlannedConsolidate {
if (utxos.length === 0) {
throw new Error("wallet has no UTxOs — fund the address first");
@@ -42,6 +67,32 @@ export function planConsolidate(
0n,
);
// Structural health check first — reshaping a usable wallet wastes a fee.
if (utxos.length <= maxUtxos) {
// Smallest qualifying candidate: leave larger UTxOs as working funds.
let candidate: bigint | null = null;
for (const u of utxos) {
const pure = pureLovelace(u);
if (pure !== null && pure >= collateralLovelace) {
if (candidate === null || pure < candidate) candidate = pure;
}
}
if (candidate !== null) {
const workingOutside = totalLovelaceIn - candidate;
if (workingOutside >= MIN_WORKING_LOVELACE) {
return {
inputCount: utxos.length,
totalLovelaceIn,
collateralOutput: candidate,
workingOutput: workingOutside,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
}
// Reshape needed — only an actual reshape has a funds threshold.
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
if (totalLovelaceIn < required) {
throw new Error(
@@ -51,23 +102,6 @@ export function planConsolidate(
);
}
if (utxos.length === 2) {
const lov0 = utxos[0].assets.lovelace ?? 0n;
const lov1 = utxos[1].assets.lovelace ?? 0n;
const smaller = lov0 < lov1 ? lov0 : lov1;
const larger = lov0 < lov1 ? lov1 : lov0;
if (smaller >= collateralLovelace) {
return {
inputCount: 2,
totalLovelaceIn,
collateralOutput: smaller,
workingOutput: larger,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
return {

View File

@@ -0,0 +1,83 @@
# supplier/.env.chandra.example — Chandra OCR-2 supplier (ocr capability)
#
# Copy to supplier/.env.chandra on the host, chmod 600, fill the blanks.
# TESTNET FIRST: run the full lifecycle (advert → escrow → claim → submit →
# accept) on testnet before any mainnet value enters this file. Mainnet ops
# (funding, advert post, container start) are operator-executed.
#
# The upstream is any OpenAI-compatible VISION endpoint serving
# datalab-to/chandra-ocr-2 (vLLM recommended; `chandra_vllm` docker or
# `vllm serve datalab-to/chandra-ocr-2 --revision <sha>`).
# LICENSING: the model weights and the hosted API each carry their own
# terms. Confirm your own licensing position with the model provider before
# serving commercially.
# ── Identity / chain ──────────────────────────────────────────────────────
SUPPLIER_PRIV_KEY_HEX= # gen-keypair output; NEW wallet, working float only
NETWORK_ID= # 0 testnet | 1 mainnet — set explicitly, never default
OGMIOS_URL= # wss://ogmios.vector.testnet... | mainnet equivalent
ADVERT_REF= # <txHash>#<index> from tx:post-advert (below)
LIVE_CHAIN= # 1 only when submitting for real
VECTOR_ZERO_TIME_MS= # per deploy/README (mainnet 1756485600000)
# ── Capability ────────────────────────────────────────────────────────────
CAPABILITY_KIND=ocr
# ── Upstream selection ────────────────────────────────────────────────────
# "datalab" = Datalab hosted document API (v0 deployment, decided 2026-08-06)
# "openai-vision" = self-hosted vLLM (the attestation upgrade path)
OCR_UPSTREAM=datalab
# ── Datalab upstream (OCR_UPSTREAM=datalab) ───────────────────────────────
# TERMS: the upstream provider's end-user terms govern how you may use and
# redistribute output, and they vary by account and plan. Confirm your own
# position with the provider before serving third parties.
DATALAB_API_KEY= # provider account key; never commit
DATALAB_BASE_URL=https://www.datalab.to
DATALAB_MODE=accurate # fast | balanced | accurate
DATALAB_TIMEOUT_MS=120000 # submit + poll budget; keep < advert max_processing_ms
# ── OpenAI-compatible vision upstream (OCR_UPSTREAM=openai-vision) ────────
# No /v1 suffix — supplier/src/ocrVision.ts appends /v1/chat/completions.
# OPENAI_BASE_URL= # e.g. http://chandra-vllm:8000
# OPENAI_API_KEY= # empty for a local vLLM; token for hosted endpoints
OPENAI_TIMEOUT_MS=120000 # page inference is seconds; keep < advert max_processing_ms
# OPENAI_MAX_TOKENS=8192 # optional output ceiling (advert cap applies regardless)
# ── OCR extras ────────────────────────────────────────────────────────────
# Datalab upstream: the pre-Claim probe defaults to per_job liveness against
# {DATALAB_BASE_URL}/api/v1/health - no further config needed. Set
# REVISION_PROBE_MODE=off only if you want claims without the health gate.
#
# 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
# REVISION_PROBE_URL= # e.g. http://chandra-vllm:8000/info
# ── Service ───────────────────────────────────────────────────────────────
PORT=8080
# ── Advert posting (run once per network, testnet first) ──────────────────
# Model string convention: datalab upstream = "datalab/convert-accurate"
# (honest: hosted engine + mode); self-hosted = "datalab-to/chandra-ocr-2@<sha7>".
# Endpoint host: your deployed supplier's public HTTPS endpoint (deploy
# target tracked outside this repo, not in this file).
#
# pnpm --filter @marketplace/supplier tx:post-advert -- \
# --ogmios-url $OGMIOS_URL \
# --priv-key $SUPPLIER_PRIV_KEY_HEX \
# --capability-id ocr.page.extract.chandra-ocr-2.v1 \
# --model "datalab/convert-accurate" \
# --max-output-tokens 8192 \
# --max-processing-ms 150000 \
# --price-lovelace 200000 \
# --endpoint-url https://<your-supplier-host> \
# --detail-uri https://<your-supplier-host>/chandra-detail.json \
# --detail-hash <sha256 of the detail JSON>
#
# max_processing_ms 150000 covers DATALAB_TIMEOUT_MS (120s) + chain legs.
# Price 200000 (0.2 AP3X) is the fleet convention placeholder — a future
# pricing decision supersedes it. Bonds default to 1 AP3X each.
# The detail JSON template lives at supplier/chandra-detail.example.json.
# Testnet: DNS is not required — a direct http://<host-ip>:<port> endpoint
# works for the testnet advert (retire it after the drills).

View File

@@ -27,6 +27,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
# Workspace manifests first to maximise layer cache reuse.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY supplier/package.json ./supplier/package.json
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the

View File

@@ -0,0 +1,21 @@
{
"capability_id": "ocr.page.extract.chandra-ocr-2.v1",
"weights_source": "hf:datalab-to/chandra-ocr-2",
"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",
"body": { "image_b64": "plain base64, no data: prefix", "mime": "image/png | image/jpeg | image/webp", "output_format": "markdown | html | json" },
"one_page_per_job": true
},
"receipt": {
"schema": "v1 (8 fields, Ed25519-signed)",
"response_hash": "sha256(canonical({content, output_format}))",
"verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)"
},
"revision_probe": {
"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

@@ -60,8 +60,26 @@ export interface AdvertRef {
* "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
* "tts" — `audio.synthesize.piper.v1`
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
* streaming). Requires LLM_BACKEND="openai". */
export type CapabilityKind = "chat" | "tts" | "chat-session";
* streaming). Requires LLM_BACKEND="openai".
* "ocr" — model-scoped `ocr.page.extract.<model-slug>.v1` (one page
* image → one extraction). Upstream is an OpenAI-compatible
* VISION endpoint (OPENAI_BASE_URL), e.g. vLLM serving
* datalab-to/chandra-ocr-2. */
export type CapabilityKind = "chat" | "tts" | "chat-session" | "ocr";
/** Served-checkpoint probe cadence for the ocr capability.
* "per_job" — probe the upstream before every Claim (default when
* REVISION_PROBE_URL is set, and always the default for the
* datalab upstream, whose health route is derived).
* "off" — no probing. */
export type RevisionProbeMode = "per_job" | "off";
/** Upstream flavor for the ocr capability.
* "openai-vision" — OpenAI-compatible vision endpoint (vLLM self-hosting;
* supports the revision-pin attestation probe).
* "datalab" — Datalab hosted document API (submit + poll; liveness
* probe only — hosted supply has no checkpoint to pin). */
export type OcrUpstream = "openai-vision" | "datalab";
/** LLM backend selected for the chat capability. */
export type LlmBackend = "ollama" | "openai";
@@ -123,6 +141,37 @@ export interface SupplierConfig {
maxChatSessions: number;
/** Chain settlement mode for chat sessions. Default "full". */
chatSettleMode: ChatSettleMode;
/**
* Upstream flavor for the ocr capability. Env: OCR_UPSTREAM
* ("openai-vision" default | "datalab").
*/
ocrUpstream: OcrUpstream;
/** Datalab API key (X-API-Key). Required when ocrUpstream="datalab". */
datalabApiKey: string;
/** Datalab API origin. Env: DATALAB_BASE_URL, default https://www.datalab.to */
datalabBaseUrl: string;
/** Datalab quality mode: fast | balanced | accurate. Env: DATALAB_MODE. */
datalabMode: string;
/** Whole-call budget for submit+poll, ms. Env: DATALAB_TIMEOUT_MS. */
datalabTimeoutMs: number;
/**
* Overrides the built-in per-format OCR instruction prompt when non-empty
* (capabilityKind="ocr" + openai-vision upstream only). Env: OCR_PROMPT_OVERRIDE.
*/
ocrPromptOverride: string;
/**
* Expected served-checkpoint SHA (40-hex) for the ocr upstream. When set
* together with revisionProbeUrl, a probe mismatch refuses the Claim.
* Env: HF_MODEL_REVISION. "" = no pin.
*/
hfModelRevision: string;
/**
* Serving-runtime info route reporting the loaded checkpoint (TGI-style
* /info or vLLM equivalent). Env: REVISION_PROBE_URL. "" = probing off.
*/
revisionProbeUrl: string;
/** Probe cadence; see RevisionProbeMode. Env: REVISION_PROBE_MODE. */
revisionProbeMode: RevisionProbeMode;
/**
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
@@ -174,8 +223,13 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
// Default to "chat" so existing chat suppliers keep booting unchanged.
const capKindStr = env.CAPABILITY_KIND ?? "chat";
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"');
if (
capKindStr !== "chat" &&
capKindStr !== "tts" &&
capKindStr !== "chat-session" &&
capKindStr !== "ocr"
) {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", "chat-session", or "ocr"');
}
const capabilityKind: CapabilityKind = capKindStr;
@@ -204,6 +258,19 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_URL ?? "";
} else if (capabilityKind === "ocr") {
// Upstream is either an OpenAI-compatible vision endpoint (self-hosted
// vLLM; OPENAI_BASE_URL required) or the Datalab hosted API
// (DATALAB_API_KEY required, validated below). Ollama and piper are
// unused regardless of LLM_BACKEND.
const upstream = env.OCR_UPSTREAM ?? "openai-vision";
if (upstream === "openai-vision") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
} else {
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
}
ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_URL ?? "";
} else if (capabilityKind === "chat") {
if (llmBackend === "openai") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
@@ -318,6 +385,52 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
}
const chatSettleMode: ChatSettleMode = settleModeStr;
// OCR-capability extras. All optional; harmless when set on other kinds
// (ignored by the chat/tts/chat-session paths).
const ocrUpstreamStr = env.OCR_UPSTREAM ?? "openai-vision";
if (ocrUpstreamStr !== "openai-vision" && ocrUpstreamStr !== "datalab") {
throw new Error('loadConfig: OCR_UPSTREAM must be "openai-vision" or "datalab"');
}
const ocrUpstream: OcrUpstream = ocrUpstreamStr;
const datalabApiKey = env.DATALAB_API_KEY ?? "";
if (capabilityKind === "ocr" && ocrUpstream === "datalab" && datalabApiKey === "") {
throw new Error('loadConfig: OCR_UPSTREAM="datalab" requires DATALAB_API_KEY');
}
const datalabBaseUrl = env.DATALAB_BASE_URL ?? "https://www.datalab.to";
const datalabMode = env.DATALAB_MODE ?? "accurate";
if (datalabMode !== "fast" && datalabMode !== "balanced" && datalabMode !== "accurate") {
throw new Error('loadConfig: DATALAB_MODE must be "fast", "balanced", or "accurate"');
}
const datalabTimeoutStr = env.DATALAB_TIMEOUT_MS;
let datalabTimeoutMs = 120_000;
if (datalabTimeoutStr !== undefined && datalabTimeoutStr !== "") {
if (!POS_INT_RE.test(datalabTimeoutStr)) {
throw new Error("loadConfig: DATALAB_TIMEOUT_MS must be a positive integer");
}
datalabTimeoutMs = Number(datalabTimeoutStr);
}
const ocrPromptOverride = env.OCR_PROMPT_OVERRIDE ?? "";
const hfModelRevision = env.HF_MODEL_REVISION ?? "";
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 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
// openai-vision upstream keeps the explicit-URL requirement.
const probeDefault =
ocrUpstream === "datalab" || revisionProbeUrl !== "" ? "per_job" : "off";
const probeModeStr = env.REVISION_PROBE_MODE ?? probeDefault;
if (probeModeStr !== "per_job" && probeModeStr !== "off") {
throw new Error('loadConfig: REVISION_PROBE_MODE must be "per_job" or "off"');
}
if (probeModeStr === "per_job" && ocrUpstream === "openai-vision" && revisionProbeUrl === "" && capabilityKind === "ocr") {
throw new Error('loadConfig: REVISION_PROBE_MODE="per_job" requires REVISION_PROBE_URL for the openai-vision upstream');
}
const revisionProbeMode: RevisionProbeMode = probeModeStr;
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -362,6 +475,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
chatIdleTimeoutMs,
maxChatSessions,
chatSettleMode,
ocrUpstream,
datalabApiKey,
datalabBaseUrl,
datalabMode,
datalabTimeoutMs,
ocrPromptOverride,
hfModelRevision,
revisionProbeUrl,
revisionProbeMode,
liveChain,
walletHealthIntervalMs,
};

249
supplier/src/datalabOcr.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* supplier/src/datalabOcr.ts — HTTP client for the Datalab hosted document
* API (https://www.datalab.to), used when OCR_UPSTREAM="datalab".
*
* Contract (verified against documentation.datalab.to, 2026-08-06):
* POST {base}/api/v1/convert multipart/form-data, X-API-Key
* fields: file (binary), output_format (markdown|html|json), mode
* → { success, error, request_id, request_check_url }
* GET {base}/api/v1/convert/{id} X-API-Key
* → { status: "complete" | ..., markdown|html|json, page_count, error }
*
* callDatalabOcr() submits ONE page image and polls until complete, then
* returns the SAME result shape as ocrVision.callOcrVision so runOcrJob's
* receipt construction is shared. Token counts are pinned to 0 — the
* Datalab API has no token semantics (mirrors the Piper/TTS convention);
* wallclock_ms brackets submit→complete locally.
*
* JSON output: when output_format="json" the result field can be an object;
* it is serialized with JSON.stringify to keep `content` a string. The
* receipt commits sha256(canonical({content, output_format})) over exactly
* the string the buyer receives, so determinism holds regardless.
*
* Error reasons (mirror the taxonomy of the other adapters):
* "datalab_failure" — non-2xx, network error, success:false, job error
* "datalab_timeout" — submit+poll exceeded timeoutMs
* "datalab_malformed" — response JSON missing the expected fields
*
* probeDatalabHealth() — pre-Claim liveness gate for the route (the
* revision-probe slot repurposed for hosted supply): GETs {base}/api/v1/health
* with the API key; a non-OK answer refuses the Claim so escrows are not
* stranded behind a dead upstream.
*/
import type { OcrRequest } from "@marketplace/shared/tx";
import type { OcrVisionResult } from "./ocrVision.js";
export interface CallDatalabOcrParams {
/** API origin, no trailing slash. Default deployment: https://www.datalab.to */
baseUrl: string;
apiKey: string;
request: OcrRequest;
/** Whole-call budget: submit + polling until complete. */
timeoutMs: number;
/** Datalab quality mode: fast | balanced | accurate. */
mode: string;
/** Poll cadence; overridable in tests. Default 2000. */
pollIntervalMs?: number;
}
export type DatalabErrorReason = "datalab_failure" | "datalab_timeout" | "datalab_malformed";
export class DatalabError extends Error {
public readonly reason: DatalabErrorReason;
constructor(reason: DatalabErrorReason, message?: string) {
super(message ?? reason);
this.name = "DatalabError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
const EXT_BY_MIME: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
clearTimeout(t);
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
}, { once: true });
});
}
export async function callDatalabOcr(params: CallDatalabOcrParams): Promise<OcrVisionResult> {
const { baseUrl, apiKey, request, timeoutMs, mode } = params;
const pollIntervalMs = params.pollIntervalMs ?? 2_000;
const base = baseUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
// ── 1. Submit ─────────────────────────────────────────────────────
const form = new FormData();
const bytes = Buffer.from(request.image_b64, "base64");
const ext = EXT_BY_MIME[request.mime] ?? "png";
form.append("file", new Blob([bytes], { type: request.mime }), `page.${ext}`);
form.append("output_format", request.output_format);
form.append("mode", mode);
let submitRes: Response;
try {
submitRes = await fetch(`${base}/api/v1/convert`, {
method: "POST",
headers: { "X-API-Key": apiKey },
body: form,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new DatalabError("datalab_timeout", `Datalab submit exceeded ${timeoutMs}ms`);
}
throw new DatalabError("datalab_failure",
`Datalab submit failed: ${(err as Error)?.message ?? String(err)}`);
}
if (!submitRes.ok) {
let detail = "";
try { detail = await submitRes.text(); } catch { /* body unavailable */ }
throw new DatalabError("datalab_failure",
`Datalab submit returned HTTP ${submitRes.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
}
let submitBody: Record<string, unknown>;
try {
submitBody = await submitRes.json() as Record<string, unknown>;
} catch {
throw new DatalabError("datalab_malformed", "Datalab submit response was not JSON");
}
if (submitBody.success !== true) {
throw new DatalabError("datalab_failure",
`Datalab submit success=false: ${String(submitBody.error ?? "no error detail")}`);
}
const requestId = submitBody.request_id;
if (typeof requestId !== "string" || requestId.length === 0) {
throw new DatalabError("datalab_malformed", "Datalab submit response missing request_id");
}
// ── 2. Poll until complete ────────────────────────────────────────
const checkUrl = typeof submitBody.request_check_url === "string" && submitBody.request_check_url.length > 0
? submitBody.request_check_url
: `${base}/api/v1/convert/${requestId}`;
for (;;) {
let pollRes: Response;
try {
pollRes = await fetch(checkUrl, {
headers: { "X-API-Key": apiKey },
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
}
throw new DatalabError("datalab_failure",
`Datalab poll failed: ${(err as Error)?.message ?? String(err)}`);
}
if (!pollRes.ok) {
throw new DatalabError("datalab_failure", `Datalab poll returned HTTP ${pollRes.status}`);
}
let poll: Record<string, unknown>;
try {
poll = await pollRes.json() as Record<string, unknown>;
} catch {
throw new DatalabError("datalab_malformed", "Datalab poll response was not JSON");
}
const status = typeof poll.status === "string" ? poll.status.toLowerCase() : "";
if (status === "complete") {
if (poll.error && typeof poll.error === "string" && poll.error.length > 0) {
throw new DatalabError("datalab_failure", `Datalab conversion error: ${poll.error}`);
}
const raw = poll[request.output_format];
let content: string;
if (typeof raw === "string") {
content = raw;
} else if (raw !== null && raw !== undefined && typeof raw === "object") {
content = JSON.stringify(raw);
} else {
throw new DatalabError("datalab_malformed",
`Datalab result missing "${request.output_format}" field`);
}
if (content.length === 0) {
throw new DatalabError("datalab_malformed", "Datalab result content is empty");
}
return {
content,
prompt_tokens: 0,
completion_tokens: 0,
wallclock_ms: Date.now() - startedAt,
};
}
if (status === "failed" || status === "error") {
throw new DatalabError("datalab_failure",
`Datalab conversion status=${status}: ${String(poll.error ?? "no detail")}`);
}
try {
await sleep(pollIntervalMs, controller.signal);
} catch (err) {
if (isAbortError(err)) {
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
}
throw err;
}
}
} finally {
clearTimeout(timer);
}
}
// ─── Liveness probe (pre-Claim gate for hosted supply) ─────────────────────
export interface ProbeDatalabParams {
baseUrl: string;
apiKey: string;
timeoutMs: number;
}
export interface ProbeDatalabResult {
ok: boolean;
detail: string;
}
export async function probeDatalabHealth(params: ProbeDatalabParams): Promise<ProbeDatalabResult> {
const base = params.baseUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const res = await fetch(`${base}/api/v1/health`, {
headers: { "X-API-Key": params.apiKey },
signal: controller.signal,
});
if (!res.ok) {
return { ok: false, detail: `health HTTP ${res.status}` };
}
return { ok: true, detail: "health ok" };
} catch (err) {
const msg = isAbortError(err)
? `health probe exceeded ${params.timeoutMs}ms`
: `health probe failed: ${(err as Error)?.message ?? String(err)}`;
return { ok: false, detail: msg };
} finally {
clearTimeout(timer);
}
}

View File

@@ -38,11 +38,15 @@ import type { SupplierConfig } from "./config.js";
import * as ollama from "./ollama.js";
import * as openai from "./openai.js";
import * as piper from "./piper.js";
import * as ocrVision from "./ocrVision.js";
import * as datalabOcr from "./datalabOcr.js";
import type { OcrRequest } from "@marketplace/shared/tx";
import type {
JobStore,
JobResponsePayload,
ChatJobResponsePayload,
TtsJobResponsePayload,
OcrJobResponsePayload,
} from "./jobs.js";
export interface RunChatJobDeps {
@@ -364,3 +368,173 @@ export async function runTtsJob(params: RunTtsJobParams): Promise<void> {
}
}
}
// ─── runOcrJob ────────────────────────────────────────────────────────────
//
// Mirror of runTtsJob for model-scoped `ocr.page.extract.<model-slug>.v1`
// capabilities. Same 7-step shape (setRunning → upstream → receipt →
// buildSubmit → awaitTx → complete → finally release). Differences from the
// TTS path:
//
// - Upstream is `ocrVision.callOcrVision` against an OpenAI-compatible
// VISION endpoint (vLLM serving e.g. datalab-to/chandra-ocr-2); the
// one-page image rides as a data-URL content part.
// - response_hash = sha256(canonical({content, output_format})) — commits
// the extraction text AND the shape it was requested in.
// - prompt_tokens / completion_tokens come from upstream usage (vision
// tokens are real tokens, unlike Piper).
export interface RunOcrJobParams {
deps: RunChatJobDeps;
jobId: string;
escrowRef: string;
claimedRef: OutputReference;
advert: AdvertDatum;
escrowDatum: EscrowDatum;
/** The OCR request envelope. The PROMPT_HASH committed by the buyer in
* the escrow datum is sha256(canonicalize(this same object)), validated
* by the route before Claim. */
requestBody: OcrRequest;
}
export async function runOcrJob(params: RunOcrJobParams): Promise<void> {
const { deps, jobId, escrowRef, claimedRef, advert, escrowDatum, requestBody } = params;
deps.jobs.setRunning(jobId);
try {
// ── 2. Call the OCR upstream (openai-vision or datalab) ───────────
let inference: ocrVision.OcrVisionResult;
try {
if (deps.config.ocrUpstream === "datalab") {
inference = await datalabOcr.callDatalabOcr({
baseUrl: deps.config.datalabBaseUrl,
apiKey: deps.config.datalabApiKey,
request: requestBody,
timeoutMs: deps.config.datalabTimeoutMs,
mode: deps.config.datalabMode,
});
} else {
const configuredMax = deps.config.openaiMaxTokens;
const maxTokens =
configuredMax > 0
? Math.min(configuredMax, advert.max_output_tokens)
: advert.max_output_tokens;
inference = await ocrVision.callOcrVision({
baseUrl: deps.config.openaiBaseUrl,
model: advert.model,
request: requestBody,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
maxTokens,
promptOverride: deps.config.ocrPromptOverride,
});
}
} catch (err) {
const rawReason =
(err as ocrVision.OcrVisionError | datalabOcr.DatalabError)?.reason ??
(deps.config.ocrUpstream === "datalab" ? "datalab_failure" : "ocr_failure");
const message = err instanceof Error ? err.message : String(err);
// *_timeout collapses to the failure reason for jobs.fail, matching
// the chat/tts runner failure-code table (502 only).
const collapsedReason =
rawReason === "ocr_timeout" ? "ocr_failure"
: rawReason === "datalab_timeout" ? "datalab_failure"
: rawReason;
console.warn(
`[job_failed] jobId=${jobId} reason=${rawReason} httpStatus=502 msg=${message}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: collapsedReason,
message,
});
return;
}
// ── 3. Build + sign receipt ───────────────────────────────────────
const responseHash = sha256Hex(canonicalize({
content: inference.content,
output_format: requestBody.output_format,
}));
const receipt = buildReceipt({
prompt_hash: escrowDatum.prompt_hash,
response_hash: responseHash,
model: advert.model,
prompt_tokens: inference.prompt_tokens,
completion_tokens: inference.completion_tokens,
wallclock_ms: inference.wallclock_ms,
supplier_pkh: deps.supplierKey.pubKeyHash,
escrow_ref: escrowRef,
});
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
const resultHash = receiptResultHash(signed);
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
const submitOutcome = await deps.state.walletMutex.run(async () => {
let built: { txCborHex: string; expectedTxHash: string };
try {
built = await buildSubmitTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef: claimedRef,
receiptHash: resultHash,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const };
});
if (submitOutcome.kind === "build_failed") {
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_failed",
message: `Submit tx failed: ${message}`,
});
return;
}
if (submitOutcome.kind === "await_failed") {
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_timeout",
message: `Submit awaitTx failed: ${msg}`,
});
return;
}
// ── 6. Mark complete ──────────────────────────────────────────────
const payload: OcrJobResponsePayload = {
kind: "ocr",
output_format: requestBody.output_format,
content: inference.content,
usage: {
prompt_tokens: inference.prompt_tokens,
completion_tokens: inference.completion_tokens,
total_tokens: inference.prompt_tokens + inference.completion_tokens,
},
receipt: signed.receipt as unknown as Record<string, unknown>,
receipt_signature: signed.signature,
};
deps.jobs.complete(jobId, payload);
} finally {
try {
deps.state.release(escrowRef);
} catch {
/* never let lock-release escape */
}
}
}

View File

@@ -63,13 +63,35 @@ export interface TtsJobResponsePayload {
receipt_signature: string;
}
/**
* OCR-flavoured terminal payload. Stored when a `runOcrJob` succeeds.
* `content` is the extraction in the requested output_format (markdown/
* html/json as text). The receipt's response_hash commits
* sha256(canonical({content, output_format})).
*/
export interface OcrJobResponsePayload {
kind: "ocr";
output_format: string; // "markdown" | "html" | "json"
content: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
receipt: Record<string, unknown>;
receipt_signature: string;
}
/**
* Discriminated union of every job-payload shape the store can hold.
* `kind` defaults to "chat" on the chat shape (older code paths don't
* set it; new `runChatJob` writes can set kind: "chat" explicitly to make
* the discriminator narrowing reliable).
*/
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload;
export type JobResponsePayload =
| ChatJobResponsePayload
| TtsJobResponsePayload
| OcrJobResponsePayload;
export interface JobFailure {
httpStatus: number;

271
supplier/src/ocrVision.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* supplier/src/ocrVision.ts — HTTP client for an OpenAI-compatible VISION
* /v1/chat/completions endpoint (vLLM serving an OCR VLM, e.g.
* datalab-to/chandra-ocr-2). Used by the `ocr` capability kind.
*
* callOcrVision({ baseUrl, model, request, timeoutMs, ... })
* POST ${baseUrl}/v1/chat/completions with a single user message whose
* content is OpenAI vision content parts:
* [{type:"image_url", image_url:{url:"data:<mime>;base64,<b64>"}},
* {type:"text", text:<instruction for the output format>}]
* Returns { content, prompt_tokens, completion_tokens, wallclock_ms }
* — the same shape as ollama.ts / openai.ts so receipt construction is
* identical downstream.
*
* The instruction text per output_format is a serving-side implementation
* detail (like temperature); the buyer's on-chain commitment covers the
* OcrRequest envelope, not the serving prompt. The exact Chandra prompt
* convention gets pinned against a live endpoint during integration —
* override per deployment via OCR_PROMPT_OVERRIDE without a code change.
*
* Error reasons:
* "ocr_failure" — non-2xx HTTP, network error, unexpected fetch failure
* "ocr_timeout" — request exceeded timeoutMs
* "ocr_malformed" — response missing choices[0].message.content (or empty)
*
* probeRevision() — best-effort served-checkpoint probe (light v0). GETs a
* runtime info route and extracts a revision/SHA field if one is present.
* The route handler refuses to Claim on a mismatch when a pin is
* configured. When the receipt schema is extended to carry it, the probed
* value feeds `model_revision`; until then it is a claim gate and
* a log line.
*/
import type { OcrRequest } from "@marketplace/shared/tx";
export interface CallOcrVisionParams {
baseUrl: string;
model: string;
request: OcrRequest;
timeoutMs: number;
/** Optional bearer token (hosted endpoints). "" sends no header. */
apiKey?: string;
/** Hard output-token ceiling forwarded as max_tokens when > 0. */
maxTokens?: number;
/** Overrides the built-in per-format instruction when non-empty. */
promptOverride?: string;
}
export interface OcrVisionResult {
content: string;
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
}
export type OcrVisionErrorReason = "ocr_failure" | "ocr_timeout" | "ocr_malformed";
export class OcrVisionError extends Error {
public readonly reason: OcrVisionErrorReason;
constructor(reason: OcrVisionErrorReason, message?: string) {
super(message ?? reason);
this.name = "OcrVisionError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
/** Default extraction instructions per output_format. Placeholder wording
* pending the Chandra-convention pin (integration item); serving deployments
* override via OCR_PROMPT_OVERRIDE. */
export function defaultOcrInstruction(outputFormat: string): string {
switch (outputFormat) {
case "html":
return "Convert this document page to clean, semantic HTML. Preserve reading order, tables, form fields and layout structure. Output only the HTML.";
case "json":
return "Extract this document page as JSON with layout blocks (type, bbox, text) preserving reading order, tables and form fields. Output only the JSON.";
case "markdown":
default:
return "Convert this document page to clean markdown. Preserve reading order, tables and structure. Transcribe handwriting faithfully. Output only the markdown.";
}
}
export async function callOcrVision(params: CallOcrVisionParams): Promise<OcrVisionResult> {
const { baseUrl, model, request, timeoutMs, apiKey, maxTokens, promptOverride } = params;
const url = `${baseUrl.replace(/\/+$/, "")}/v1/chat/completions`;
const instruction =
promptOverride && promptOverride.length > 0
? promptOverride
: defaultOcrInstruction(request.output_format);
const payload: Record<string, unknown> = {
model,
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: `data:${request.mime};base64,${request.image_b64}` },
},
{ type: "text", text: instruction },
],
},
],
stream: false,
};
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
const body = JSON.stringify(payload);
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new OcrVisionError("ocr_timeout", `OCR request exceeded ${timeoutMs}ms`);
}
throw new OcrVisionError(
"ocr_failure",
`OCR fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (!response.ok) {
let detail = "";
try {
detail = await response.text();
} catch {
// ignore — body unavailable
}
throw new OcrVisionError(
"ocr_failure",
`OCR upstream returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (err) {
throw new OcrVisionError(
"ocr_malformed",
`OCR response was not valid JSON: ${(err as Error)?.message ?? String(err)}`,
);
}
if (!parsed || typeof parsed !== "object") {
throw new OcrVisionError("ocr_malformed", "OCR response was not an object");
}
const obj = parsed as Record<string, unknown>;
const choicesRaw = obj.choices;
if (!Array.isArray(choicesRaw) || choicesRaw.length === 0) {
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices' array");
}
const firstChoice = choicesRaw[0];
if (!firstChoice || typeof firstChoice !== "object") {
throw new OcrVisionError("ocr_malformed", "OCR response choices[0] not an object");
}
const messageRaw = (firstChoice as Record<string, unknown>).message;
if (!messageRaw || typeof messageRaw !== "object") {
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices[0].message'");
}
const content = (messageRaw as Record<string, unknown>).content;
if (typeof content !== "string" || content.length === 0) {
throw new OcrVisionError(
"ocr_malformed",
"OCR response missing/empty choices[0].message.content",
);
}
const usageRaw = obj.usage;
let promptTokens = 0;
let completionTokens = 0;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
}
return {
content,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: Date.now() - startedAt,
};
}
// ─── Revision probe (light v0) ──────────────────────────────────────────────
export interface ProbeRevisionParams {
probeUrl: string;
timeoutMs: number;
apiKey?: string;
}
export interface ProbeRevisionResult {
ok: boolean;
/** 40-hex checkpoint SHA when the runtime reports one; "" otherwise. */
servedSha: string;
/** Human-readable detail for logs/alerts. */
detail: string;
}
const SHA40_RE = /^[0-9a-f]{40}$/i;
/** Fields observed across serving runtimes that carry a checkpoint id
* (TGI /info: model_sha; vLLM variants: sha / revision / model_revision). */
const SHA_FIELDS = ["model_sha", "sha", "revision", "model_revision"];
export async function probeRevision(params: ProbeRevisionParams): Promise<ProbeRevisionResult> {
const { probeUrl, timeoutMs, apiKey } = params;
const headers: Record<string, string> = {};
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(probeUrl, { headers, signal: controller.signal });
if (!response.ok) {
return { ok: false, servedSha: "", detail: `probe HTTP ${response.status}` };
}
let parsed: unknown;
try {
parsed = await response.json();
} catch {
return { ok: false, servedSha: "", detail: "probe response not JSON" };
}
if (!parsed || typeof parsed !== "object") {
return { ok: false, servedSha: "", detail: "probe response not an object" };
}
const obj = parsed as Record<string, unknown>;
for (const field of SHA_FIELDS) {
const v = obj[field];
if (typeof v === "string" && SHA40_RE.test(v)) {
return { ok: true, servedSha: v.toLowerCase(), detail: `probe ${field}=${v}` };
}
}
return { ok: true, servedSha: "", detail: "probe answered without a revision field" };
} catch (err) {
const msg = isAbortError(err)
? `probe exceeded ${timeoutMs}ms`
: `probe failed: ${(err as Error)?.message ?? String(err)}`;
return { ok: false, servedSha: "", detail: msg };
} finally {
clearTimeout(timer);
}
}

View File

@@ -44,12 +44,19 @@ import {
mockSlotToWallclockMs,
detectCborBackend,
normalizeChatMessage,
ocrPromptHash,
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "@marketplace/shared/tx";
import type { OcrRequest } from "@marketplace/shared/tx";
import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js";
import { JobStore } from "./jobs.js";
import { runChatJob, runTtsJob } from "./jobRunner.js";
import { runChatJob, runTtsJob, runOcrJob } from "./jobRunner.js";
import { probeRevision } from "./ocrVision.js";
import { probeDatalabHealth } from "./datalabOcr.js";
import { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js";
import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js";
import { callOpenAiStream } from "./openai.js";
@@ -427,9 +434,9 @@ function makeGetJobHandler(deps: ResolvedDeps) {
// Defensive narrowing: this poll route only knows how to render the
// chat shape. A future shared JobStore could mix capabilities; we'd
// want each kind's poll to be sure it's reading its own.
if ("audio_b64" in payload) {
if (payload.kind === "ocr" || "audio_b64" in payload) {
return jsonError(res, 500, "wrong_payload_kind",
"tts payload returned to chat poll route");
"non-chat payload returned to chat poll route");
}
return res.status(200).json({
choices: payload.choices,
@@ -696,6 +703,303 @@ function makeGetTtsJobHandler(deps: ResolvedDeps) {
};
}
// ─── POST /v1/ocr/extract ───────────────────────────────────────────────────
//
// Mirror of makeTtsHandler for model-scoped `ocr.page.extract.<model-slug>.v1`
// capabilities. Same lifecycle (validate → resolve advert → resolve escrow →
// hash check → claim → fire-and-forget runner → 202). Differences:
// - body shape: { image_b64, mime, output_format } (one page per job)
// - prompt_hash is ocrPromptHash(envelope) — shared with the buyer-side
// builder in packages/shared/src/tx/escrow/postOcrEscrow.ts
// - optional served-revision probe BEFORE Claim: a mispinned or
// unreachable upstream refuses the job instead of stranding the escrow
// in Claimed.
/** Plain base64 (standard alphabet, optional padding). Shared shape with the
* buyer-side validator; kept local so route validation stays dependency-light. */
const OCR_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function makeOcrHandler(deps: ResolvedDeps) {
return async (req: Request, res: Response, next: NextFunction) => {
// Slot held by THIS request (null until acquired / after hand-off).
let acquiredRef: string | null = null;
try {
// ── 1. Header ───────────────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed",
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 2. Body ─────────────────────────────────────────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const imageB64 = typeof body.image_b64 === "string" ? body.image_b64 : "";
if (imageB64.length === 0) {
return jsonError(res, 400, "image_required",
"body.image_b64 must be a non-empty base64 string");
}
if (imageB64.length > MAX_OCR_IMAGE_B64_CHARS) {
return jsonError(res, 413, "image_too_large",
`body.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
}
if (!OCR_BASE64_RE.test(imageB64)) {
return jsonError(res, 400, "image_not_base64",
"body.image_b64 must be plain base64 (no data: prefix, no whitespace)");
}
const mime = typeof body.mime === "string" ? body.mime : "";
if (!ALLOWED_OCR_MIMES.has(mime)) {
return jsonError(res, 400, "mime_invalid",
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
const outputFormat = typeof body.output_format === "string" ? body.output_format : "";
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(outputFormat)) {
return jsonError(res, 400, "output_format_invalid",
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
}
const ocrRequest: OcrRequest = { image_b64: imageB64, mime, output_format: outputFormat };
// ── 3. Advert ───────────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
// ── 4. Escrow ───────────────────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found",
`escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable",
`escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier",
"escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks ──────────────────────────────────────────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch",
"request_spec_hash in escrow does not match advert spec");
}
// The OCR prompt commitment is the canonicalisation of the full
// request envelope. The buyer SDK computes the same shape (shared
// ocrPromptHash) when building the escrow datum.
const expectedPromptHash = ocrPromptHash(ocrRequest);
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch",
"prompt_hash in escrow does not match request body");
}
// ── 7. Deadline ─────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 7.5. Upstream probe (before Claim, when configured) ─────────
// A failure REFUSES the job here — claiming with a dead or mispinned
// upstream would strand the escrow (and, for self-hosted supply,
// corrupt the attestation story). 503 tells the buyer to retry/route
// elsewhere. Datalab upstream: liveness only (hosted supply has no
// checkpoint to pin). openai-vision upstream: liveness + optional
// served-revision match against HF_MODEL_REVISION.
if (deps.config.revisionProbeMode === "per_job") {
if (deps.config.ocrUpstream === "datalab") {
const probe = await probeDatalabHealth({
baseUrl: deps.config.datalabBaseUrl,
apiKey: deps.config.datalabApiKey,
timeoutMs: 10_000,
});
if (!probe.ok) {
console.warn(`[upstream_probe_failed] ${probe.detail}`);
return jsonError(res, 503, "upstream_probe_failed",
`upstream health probe failed: ${probe.detail}`);
}
} else if (deps.config.revisionProbeUrl !== "") {
const probe = await probeRevision({
probeUrl: deps.config.revisionProbeUrl,
timeoutMs: 10_000,
apiKey: deps.config.openaiApiKey,
});
if (!probe.ok) {
console.warn(`[revision_probe_failed] ${probe.detail}`);
return jsonError(res, 503, "revision_probe_failed",
`served-revision probe failed: ${probe.detail}`);
}
if (
deps.config.hfModelRevision !== "" &&
probe.servedSha !== "" &&
probe.servedSha !== deps.config.hfModelRevision.toLowerCase()
) {
console.warn(
`[revision_mismatch] expected=${deps.config.hfModelRevision} served=${probe.servedSha}`,
);
return jsonError(res, 503, "revision_mismatch",
"served checkpoint does not match the pinned revision");
}
}
}
// ── 8. Acquire session slot ─────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
}
acquiredRef = escrowRefStr;
// ── 9. Claim tx: build + confirm as ONE wallet critical section ──
const claimOutcome = await deps.state.walletMutex.run(async () => {
let built;
try {
built = await buildClaimTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const, built };
});
if (claimOutcome.kind === "build_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
triggerOnFailureConsolidate({
chain: deps.chain,
state: deps.state,
supplierKey: deps.supplierKey,
});
return jsonError(res, 503, "chain_submit_failed",
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout",
`Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
}
// ── 10. Spawn runner + 202 ──────────────────────────────────────
const claimedRef: OutputReference = {
txHash: claimOutcome.built.expectedTxHash,
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
// Slot release now happens inside runOcrJob's finally.
acquiredRef = null;
void runOcrJob({
deps: {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
},
jobId,
escrowRef: escrowRefStr,
claimedRef,
advert,
escrowDatum,
requestBody: ocrRequest,
});
return res.status(202).json({
job_id: jobId,
status: "accepted",
escrow_ref: escrowRefStr,
});
} catch (err) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
next(err);
return;
}
};
}
// ─── GET /v1/ocr/extract/:jobId ─────────────────────────────────────────────
function makeGetOcrJobHandler(deps: ResolvedDeps) {
return (req: Request, res: Response) => {
const rawJobId = req.params.jobId;
const jobId = typeof rawJobId === "string" ? rawJobId : "";
if (!UUID_V4_RE.test(jobId)) {
return jsonError(res, 400, "invalid_job_id", "jobId must be a UUIDv4 string");
}
const record = deps.jobs.get(jobId);
if (!record) {
return jsonError(res, 404, "job_not_found", `no job found with id ${jobId}`);
}
res.setHeader("Content-Type", "application/json");
if (record.status === "accepted" || record.status === "running") {
return res.status(202).json({
status: record.status,
escrow_ref: record.escrowRef,
});
}
if (record.status === "done") {
const payload = record.responsePayload!;
if (!("kind" in payload) || payload.kind !== "ocr") {
return jsonError(res, 500, "wrong_payload_kind",
"non-ocr payload returned to ocr poll route");
}
return res.status(200).json({
output_format: payload.output_format,
content: payload.content,
usage: payload.usage,
receipt: payload.receipt,
receipt_signature: payload.receipt_signature,
escrow_ref: record.escrowRef,
});
}
// failed
const f = record.failure!;
return res.status(f.httpStatus).json({
status: "failed",
reason: f.reason,
message: f.message,
escrow_ref: record.escrowRef,
});
};
}
// ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ───
//
// Escrow bookends with off-chain turns:
@@ -1077,7 +1381,11 @@ export function createApp(deps: SupplierDeps): Application {
const app = express();
app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" }));
// /v1/ocr carries a base64 page image and mounts its own larger parser
// 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));
// /healthz is mounted FIRST and uses no deps — it must succeed regardless
// of chain/state/config (independent of /status free/working/offline).
@@ -1095,6 +1403,11 @@ export function createApp(deps: SupplierDeps): Application {
if (resolved.config.capabilityKind === "tts") {
app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved));
} else if (resolved.config.capabilityKind === "ocr") {
// MAX_OCR_IMAGE_B64_CHARS (~12M chars ≈ 9 MB binary) + JSON envelope
// fits comfortably under 16mb.
app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), makeOcrHandler(resolved));
app.get("/v1/ocr/extract/:jobId", makeGetOcrJobHandler(resolved));
} else if (resolved.config.capabilityKind === "chat-session") {
const chat = makeChatSessionHandlers(resolved);
app.post("/v1/chat/start", chat.start);

View File

@@ -7,15 +7,26 @@
* and the lane stalls until an operator runs `tx:consolidate-wallet`.
*
* Two layers of self-healing:
* startWalletHealthTicker — periodic background tick (every N ms). When
* the supplier is idle, runs runConsolidateWallet. planConsolidate
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this
* is a no-op in steady state — no tx, no fee.
* startWalletHealthTicker — periodic background tick (every N ms). Runs
* runConsolidateWallet; planConsolidate's structural health predicate
* short-circuits without a tx when a collateral candidate exists and
* fragmentation is bounded. A tick on a healthy wallet costs one UTxO
* query and nothing else.
* triggerOnFailureConsolidate — fire-and-forget consolidate from the
* Claim-failure path. Debounced so back-to-back failures don't queue
* multiple consolidates. Returns 503 to the buyer for THIS request; the
* next buyer retry (~30-60s later) finds the wallet healthy.
*
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
* against ANY future non-convergence:
* - consecutive guard: 3 consolidations in a row without ever reaching
* `already-healthy` halts wallet-health for this process.
* - budget guard: more than 6 consolidations inside 24h halts it too.
* A halt logs loudly once; consolidate manually and restart the supplier
* to re-arm.
*
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
* inside state.walletMutex — the same mutex that serializes Claims/Submits —
* queueing behind any in-flight chain op instead of skipping. Session slots
@@ -63,6 +74,29 @@ export interface WalletHealthTicker {
const inFlight = new WeakSet<SupplierState>();
const lastOnFailureAt = new WeakMap<SupplierState, number>();
// ── F7 circuit breakers ──────────────────────────────────────────────
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
interface GuardCounters {
consecutive: number;
windowStart: number;
windowCount: number;
halted: boolean;
}
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
function guardsFor(state: SupplierState): GuardCounters {
let g = guardCounters.get(state);
if (!g) {
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
guardCounters.set(state, g);
}
return g;
}
function defaultLog(line: string): void {
// eslint-disable-next-line no-console
console.log(`[wallet-health] ${line}`);
@@ -73,6 +107,8 @@ async function consolidateOnce(
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
): Promise<void> {
const { state } = deps;
const guards = guardsFor(state);
if (guards.halted) return; // the halt already logged loudly, once
if (inFlight.has(state)) {
opts.log("skip: another consolidate already in flight");
return;
@@ -92,11 +128,36 @@ async function consolidateOnce(
}),
);
if (result.reason === "already-healthy") {
guards.consecutive = 0;
opts.log("already-healthy");
} else {
const now = Date.now();
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
guards.windowStart = now;
guards.windowCount = 0;
}
guards.consecutive += 1;
guards.windowCount += 1;
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
guards.halted = true;
opts.log(
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
);
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
guards.halted = true;
opts.log(
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
);
}
}
} catch (err) {
// Neither healthy nor consolidated (e.g. balance too low): no fee was
// spent, so the guards don't move — but nothing converged either, so
// the consecutive counter is NOT reset.
opts.log(`consolidate failed: ${(err as Error).message}`);
} finally {
inFlight.delete(state);
@@ -143,8 +204,9 @@ export function triggerOnFailureConsolidate(
});
}
/** Test-only: reset the in-flight + debounce maps between tests. */
/** Test-only: reset the in-flight + debounce + guard maps between tests. */
export function _resetWalletHealthForTests(state: SupplierState): void {
inFlight.delete(state);
lastOnFailureAt.delete(state);
guardCounters.delete(state);
}

View File

@@ -47,6 +47,40 @@ export function buildSampleTtsConfig(): SupplierConfig {
};
}
/** OCR variant: OpenAI-compatible vision upstream, capabilityKind="ocr". */
export function buildSampleOcrConfig(): SupplierConfig {
return {
...buildSampleConfig(),
ollamaUrl: "",
openaiBaseUrl: "http://vllm.fake:8000",
openaiApiKey: "",
openaiTimeoutMs: 180_000,
openaiMaxTokens: 0,
openaiReasoningDisabled: false,
capabilityKind: "ocr",
ocrUpstream: "openai-vision",
datalabApiKey: "",
datalabBaseUrl: "https://www.datalab.to",
datalabMode: "accurate",
datalabTimeoutMs: 120_000,
ocrPromptOverride: "",
hfModelRevision: "",
revisionProbeUrl: "",
revisionProbeMode: "off",
};
}
/** Datalab-hosted OCR variant (OCR_UPSTREAM=datalab). */
export function buildSampleDatalabOcrConfig(): SupplierConfig {
return {
...buildSampleOcrConfig(),
openaiBaseUrl: "",
ocrUpstream: "datalab",
datalabApiKey: "dl-test-key",
revisionProbeMode: "per_job",
};
}
/** Env map matching the sample config above, for loadConfig() unit tests. */
export function buildSampleEnv(): Record<string, string> {
return {

View File

@@ -0,0 +1,217 @@
/**
* supplier-datalab-ocr.test.ts — coverage for the Datalab hosted-API OCR
* upstream (OCR_UPSTREAM="datalab").
*
* Pins the wire contract verified against documentation.datalab.to
* (2026-08-06): multipart POST /api/v1/convert with X-API-Key, then poll
* GET /api/v1/convert/{id} until status "complete"; result field named by
* output_format. Plus the error taxonomy and the health probe.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
callDatalabOcr,
probeDatalabHealth,
DatalabError,
} from "../../supplier/src/datalabOcr.js";
import { loadConfig } from "../../supplier/src/config.js";
import { buildSampleEnv } from "../fixtures/supplier-side/sample-config.js";
const IMG = Buffer.from("page bytes").toString("base64");
function validParams(overrides?: Partial<Parameters<typeof callDatalabOcr>[0]>) {
return {
baseUrl: "https://dl.fake",
apiKey: "dl-key",
request: { image_b64: IMG, mime: "image/png", output_format: "markdown" },
timeoutMs: 5_000,
mode: "accurate",
pollIntervalMs: 5,
...overrides,
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("callDatalabOcr — happy path + wire shape", () => {
it("submits multipart with X-API-Key, polls, returns the markdown content", async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
let polls = 0;
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
calls.push({ url, init: init ?? {} });
if (url.endsWith("/api/v1/convert")) {
return new Response(JSON.stringify({
success: true, error: null, request_id: "req-1",
request_check_url: "https://dl.fake/api/v1/convert/req-1",
}), { status: 200 });
}
polls += 1;
if (polls < 2) {
return new Response(JSON.stringify({ status: "processing" }), { status: 200 });
}
return new Response(JSON.stringify({
status: "complete", markdown: "# Registry page", page_count: 1, error: null,
}), { status: 200 });
});
const result = await callDatalabOcr(validParams());
expect(result.content).toBe("# Registry page");
expect(result.prompt_tokens).toBe(0);
expect(result.completion_tokens).toBe(0);
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
const submit = calls[0];
expect(submit.url).toBe("https://dl.fake/api/v1/convert");
expect((submit.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
const form = submit.init.body as FormData;
expect(form).toBeInstanceOf(FormData);
expect(form.get("output_format")).toBe("markdown");
expect(form.get("mode")).toBe("accurate");
const file = form.get("file") as Blob;
expect(file).toBeInstanceOf(Blob);
expect(file.type).toBe("image/png");
// Poll used the check URL with the key.
const poll = calls[1];
expect(poll.url).toBe("https://dl.fake/api/v1/convert/req-1");
expect((poll.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
});
it("stringifies an object result for output_format=json", async () => {
vi.stubGlobal("fetch", async (url: string) => {
if (url.endsWith("/api/v1/convert")) {
return new Response(JSON.stringify({ success: true, request_id: "r2", request_check_url: "https://dl.fake/api/v1/convert/r2" }), { status: 200 });
}
return new Response(JSON.stringify({
status: "complete", json: { blocks: [{ type: "Table" }] },
}), { status: 200 });
});
const result = await callDatalabOcr(validParams({
request: { image_b64: IMG, mime: "image/png", output_format: "json" },
}));
expect(result.content).toBe(JSON.stringify({ blocks: [{ type: "Table" }] }));
});
});
describe("callDatalabOcr — error taxonomy", () => {
it("datalab_failure on submit HTTP error", async () => {
vi.stubGlobal("fetch", async () => new Response("no", { status: 500 }));
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
name: "DatalabError", reason: "datalab_failure",
});
});
it("datalab_failure on success:false", async () => {
vi.stubGlobal("fetch", async () =>
new Response(JSON.stringify({ success: false, error: "no credits" }), { status: 200 }));
await expect(callDatalabOcr(validParams())).rejects.toThrow(/no credits/);
});
it("datalab_malformed when request_id missing", async () => {
vi.stubGlobal("fetch", async () =>
new Response(JSON.stringify({ success: true }), { status: 200 }));
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
reason: "datalab_malformed",
});
});
it("datalab_failure when the job reports failed status", async () => {
vi.stubGlobal("fetch", async (url: string) => {
if (url.endsWith("/api/v1/convert")) {
return new Response(JSON.stringify({ success: true, request_id: "r3", request_check_url: "https://dl.fake/api/v1/convert/r3" }), { status: 200 });
}
return new Response(JSON.stringify({ status: "failed", error: "unreadable file" }), { status: 200 });
});
await expect(callDatalabOcr(validParams())).rejects.toThrow(/unreadable file/);
});
it("datalab_malformed when the completed result lacks the requested format field", async () => {
vi.stubGlobal("fetch", async (url: string) => {
if (url.endsWith("/api/v1/convert")) {
return new Response(JSON.stringify({ success: true, request_id: "r4", request_check_url: "https://dl.fake/api/v1/convert/r4" }), { status: 200 });
}
return new Response(JSON.stringify({ status: "complete", html: "<p>x</p>" }), { status: 200 });
});
await expect(callDatalabOcr(validParams())).rejects.toMatchObject({
reason: "datalab_malformed",
});
});
it("datalab_timeout when polling never completes within the budget", async () => {
vi.stubGlobal("fetch", async (url: string) => {
if (url.endsWith("/api/v1/convert")) {
return new Response(JSON.stringify({ success: true, request_id: "r5", request_check_url: "https://dl.fake/api/v1/convert/r5" }), { status: 200 });
}
return new Response(JSON.stringify({ status: "processing" }), { status: 200 });
});
await expect(callDatalabOcr(validParams({ timeoutMs: 60, pollIntervalMs: 10 })))
.rejects.toMatchObject({ reason: "datalab_timeout" });
});
it("exports the error class for instanceof checks", async () => {
vi.stubGlobal("fetch", async () => new Response("x", { status: 502 }));
try {
await callDatalabOcr(validParams());
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(DatalabError);
}
});
});
describe("probeDatalabHealth", () => {
it("ok on 200 from /api/v1/health with the key header", async () => {
let captured: { url: string; init: RequestInit } | null = null;
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
captured = { url, init: init ?? {} };
return new Response(JSON.stringify({ status: "ok" }), { status: 200 });
});
const result = await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "dl-key", timeoutMs: 1000 });
expect(result.ok).toBe(true);
expect(captured!.url).toBe("https://dl.fake/api/v1/health");
expect((captured!.init.headers as Record<string, string>)["X-API-Key"]).toBe("dl-key");
});
it("not ok on HTTP error or network failure", async () => {
vi.stubGlobal("fetch", async () => new Response("x", { status: 503 }));
expect((await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "k", timeoutMs: 1000 })).ok).toBe(false);
vi.stubGlobal("fetch", async () => { throw new Error("ECONNREFUSED"); });
expect((await probeDatalabHealth({ baseUrl: "https://dl.fake", apiKey: "k", timeoutMs: 1000 })).ok).toBe(false);
});
});
describe("loadConfig — datalab upstream validation", () => {
function ocrEnv(extra: Record<string, string>): Record<string, string> {
const env = buildSampleEnv();
delete (env as Record<string, string | undefined>).OLLAMA_URL;
return { ...env, CAPABILITY_KIND: "ocr", ...extra };
}
it("requires DATALAB_API_KEY when OCR_UPSTREAM=datalab", () => {
expect(() => loadConfig(ocrEnv({ OCR_UPSTREAM: "datalab" })))
.toThrow(/DATALAB_API_KEY/);
});
it("datalab upstream boots without OPENAI_BASE_URL and probes per_job by default", () => {
const config = loadConfig(ocrEnv({ OCR_UPSTREAM: "datalab", DATALAB_API_KEY: "dl-k" }));
expect(config.ocrUpstream).toBe("datalab");
expect(config.openaiBaseUrl).toBe("");
expect(config.revisionProbeMode).toBe("per_job");
expect(config.datalabBaseUrl).toBe("https://www.datalab.to");
expect(config.datalabMode).toBe("accurate");
});
it("openai-vision upstream still requires OPENAI_BASE_URL", () => {
expect(() => loadConfig(ocrEnv({})))
.toThrow(/OPENAI_BASE_URL/);
});
it("rejects an invalid DATALAB_MODE", () => {
expect(() => loadConfig(ocrEnv({
OCR_UPSTREAM: "datalab", DATALAB_API_KEY: "dl-k", DATALAB_MODE: "turbo",
}))).toThrow(/DATALAB_MODE/);
});
});

View File

@@ -0,0 +1,164 @@
/**
* supplier-ocr-vision.test.ts — coverage for the OpenAI-compatible VISION
* upstream client used by the `ocr` capability kind.
*
* Mirrors supplier-openai.test.ts: stub global fetch, pin the outbound wire
* shape (vision content parts, data-URL image), and the error taxonomy
* (ocr_failure / ocr_timeout / ocr_malformed).
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
callOcrVision,
probeRevision,
defaultOcrInstruction,
OcrVisionError,
} from "../../supplier/src/ocrVision.js";
const IMG = Buffer.from("some page bytes").toString("base64");
function validParams() {
return {
baseUrl: "http://vllm.fake:8000",
model: "datalab-to/chandra-ocr-2",
request: { image_b64: IMG, mime: "image/png", output_format: "markdown" },
timeoutMs: 5_000,
};
}
function okBody(content = "# Page\ntext") {
return {
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: { prompt_tokens: 900, completion_tokens: 120, total_tokens: 1020 },
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("callOcrVision — happy path + wire shape", () => {
it("POSTs vision content parts with a data-URL image and returns the normalised result", async () => {
let captured: { url: string; init: RequestInit } | null = null;
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
captured = { url, init };
return new Response(JSON.stringify(okBody()), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const result = await callOcrVision(validParams());
expect(result.content).toBe("# Page\ntext");
expect(result.prompt_tokens).toBe(900);
expect(result.completion_tokens).toBe(120);
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
expect(captured).not.toBeNull();
expect(captured!.url).toBe("http://vllm.fake:8000/v1/chat/completions");
const sent = JSON.parse(String(captured!.init.body));
expect(sent.model).toBe("datalab-to/chandra-ocr-2");
expect(sent.stream).toBe(false);
expect(Array.isArray(sent.messages)).toBe(true);
const content = sent.messages[0].content;
expect(content[0].type).toBe("image_url");
expect(content[0].image_url.url).toBe(`data:image/png;base64,${IMG}`);
expect(content[1].type).toBe("text");
expect(content[1].text).toBe(defaultOcrInstruction("markdown"));
});
it("uses promptOverride verbatim when provided", async () => {
let sentText = "";
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
sentText = JSON.parse(String(init.body)).messages[0].content[1].text;
return new Response(JSON.stringify(okBody()), { status: 200 });
});
await callOcrVision({ ...validParams(), promptOverride: "OCR EXACTLY AS CHANDRA WANTS" });
expect(sentText).toBe("OCR EXACTLY AS CHANDRA WANTS");
});
it("forwards max_tokens when positive and the bearer key when set", async () => {
let sent: Record<string, unknown> = {};
let auth = "";
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
sent = JSON.parse(String(init.body));
auth = (init.headers as Record<string, string>).authorization ?? "";
return new Response(JSON.stringify(okBody()), { status: 200 });
});
await callOcrVision({ ...validParams(), maxTokens: 4096, apiKey: "sk-test" });
expect(sent.max_tokens).toBe(4096);
expect(auth).toBe("Bearer sk-test");
});
});
describe("callOcrVision — error taxonomy", () => {
it("ocr_failure on non-2xx", async () => {
vi.stubGlobal("fetch", async () => new Response("boom", { status: 500 }));
await expect(callOcrVision(validParams())).rejects.toMatchObject({
name: "OcrVisionError",
reason: "ocr_failure",
});
});
it("ocr_malformed on missing content", async () => {
vi.stubGlobal("fetch", async () =>
new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content: "" } }] }), { status: 200 }));
await expect(callOcrVision(validParams())).rejects.toMatchObject({
reason: "ocr_malformed",
});
});
it("ocr_timeout when the upstream exceeds timeoutMs", async () => {
vi.stubGlobal("fetch", (_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
const signal = init.signal as AbortSignal;
signal.addEventListener("abort", () => {
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
});
}));
await expect(callOcrVision({ ...validParams(), timeoutMs: 30 })).rejects.toMatchObject({
reason: "ocr_timeout",
});
});
it("exports the error class for instanceof checks", async () => {
vi.stubGlobal("fetch", async () => new Response("nope", { status: 502 }));
try {
await callOcrVision(validParams());
expect.unreachable("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(OcrVisionError);
}
});
});
describe("probeRevision — served-checkpoint probe", () => {
it("extracts a 40-hex sha from known runtime fields", async () => {
const sha = "a".repeat(40);
vi.stubGlobal("fetch", async () =>
new Response(JSON.stringify({ model_sha: sha }), { status: 200 }));
const result = await probeRevision({ probeUrl: "http://vllm.fake:8000/info", timeoutMs: 1000 });
expect(result.ok).toBe(true);
expect(result.servedSha).toBe(sha);
});
it("ok with empty sha when the runtime reports no revision field", async () => {
vi.stubGlobal("fetch", async () =>
new Response(JSON.stringify({ served_model: "chandra" }), { status: 200 }));
const result = await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 });
expect(result.ok).toBe(true);
expect(result.servedSha).toBe("");
});
it("not ok on HTTP error / network failure", async () => {
vi.stubGlobal("fetch", async () => new Response("x", { status: 503 }));
expect((await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 })).ok).toBe(false);
vi.stubGlobal("fetch", async () => { throw new Error("ECONNREFUSED"); });
expect((await probeRevision({ probeUrl: "http://x/info", timeoutMs: 1000 })).ok).toBe(false);
});
});

View File

@@ -0,0 +1,176 @@
/**
* supplier-routes-ocr.test.ts — coverage for the model-scoped OCR supplier
* route (`ocr.page.extract.<model-slug>.v1`).
*
* Focus (mirrors supplier-routes-tts.test.ts): the NEW validation logic in
* `makeOcrHandler` plus capability-routed mounting and the enlarged body
* limit. The shared lifecycle pieces (lock, claim tx, runner spawn) reuse
* the chat-handler implementation and are covered by the chat route tests.
*
* End-to-end happy-path (escrow seed → claim → run → submit) is exercised
* by the post-deploy smoke against the real testnet, not here.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import request from "supertest";
import type { Application } from "express";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { SupplierState } from "../../supplier/src/state.js";
import { JobStore } from "../../supplier/src/jobs.js";
import { createApp } from "../../supplier/src/server.js";
import type { SupplierConfig } from "../../supplier/src/config.js";
import {
buildSampleConfig,
buildSampleOcrConfig,
} from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const ESCROW_REF_HEADER = `${"f".repeat(64)}#0`;
const IMG = Buffer.from("page image bytes").toString("base64");
function makeApp(config?: SupplierConfig): Application {
return createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: config ?? buildSampleOcrConfig(),
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
});
}
function validBody() {
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Capability-routed mounting", () => {
it("ocr supplier exposes /v1/ocr/extract and NOT the chat/tts routes", async () => {
const app = makeApp();
const ocr = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
expect(ocr.status).not.toBe(404);
const chat = await request(app).post("/v1/chat/completions")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ messages: [{ role: "user", content: "hi" }] });
expect(chat.status).toBe(404);
const tts = await request(app).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ text: "x", voice: "nova", format: "mp3", speed: 1 });
expect(tts.status).toBe(404);
});
it("chat supplier does NOT expose /v1/ocr/extract", async () => {
const app = makeApp(buildSampleConfig());
const res = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
expect(res.status).toBe(404);
});
});
describe("POST /v1/ocr/extract — header + body validation", () => {
it("400 escrow_ref_required when X-Escrow-Ref header missing", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract").send(validBody());
expect(res.status).toBe(400);
expect(res.body.reason).toBe("escrow_ref_required");
});
it("400 escrow_ref_malformed when header is not <64hex>#<int>", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", "not-a-ref").send(validBody());
expect(res.status).toBe(400);
expect(res.body.reason).toBe("escrow_ref_malformed");
});
it("400 image_required when image_b64 missing or empty", async () => {
const app = makeApp();
for (const body of [{}, { image_b64: "" }, { mime: "image/png", output_format: "markdown" }]) {
const res = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(body);
expect(res.status).toBe(400);
expect(res.body.reason).toBe("image_required");
}
});
it("400 image_not_base64 for data-URL or whitespace payloads", async () => {
const app = makeApp();
for (const image_b64 of [`data:image/png;base64,${IMG}`, `${IMG} `, "has space"]) {
const res = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), image_b64 });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("image_not_base64");
}
});
it("400 mime_invalid for unsupported mime", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), mime: "image/tiff" });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("mime_invalid");
});
it("400 output_format_invalid for unsupported format", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), output_format: "yaml" });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("output_format_invalid");
});
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
// 2M chars of base64 ≈ 1.5 MB binary; global express.json would 413 this.
const big = "A".repeat(2_000_000);
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), image_b64: big });
// Passes body parsing + validation, then fails on the unseeded chain
// (advert_unavailable 503) — anything but 413 proves the parser took it.
expect(res.status).not.toBe(413);
expect([403, 404, 503]).toContain(res.status);
});
});
describe("POST /v1/ocr/extract — revision-probe gate", () => {
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
// hitting the earlier chain-side failure with probing configured — the
// probe must NOT fire before chain validation (unseeded chain 503s
// first), keeping probe cost off the hot invalid path.
vi.stubGlobal("fetch", async () => { throw new Error("probe must not be called yet"); });
const config: SupplierConfig = {
...buildSampleOcrConfig(),
revisionProbeUrl: "http://vllm.fake:8000/info",
revisionProbeMode: "per_job",
hfModelRevision: "a".repeat(40),
};
const res = await request(makeApp(config)).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
// Unseeded chain → advert_unavailable (503) BEFORE any probe fetch.
expect(res.status).toBe(503);
expect(res.body.reason).toBe("advert_unavailable");
});
});
describe("GET /v1/ocr/extract/:jobId", () => {
it("400 invalid_job_id for non-UUID", async () => {
const res = await request(makeApp()).get("/v1/ocr/extract/not-a-uuid");
expect(res.status).toBe(400);
expect(res.body.reason).toBe("invalid_job_id");
});
it("404 job_not_found for unknown UUID", async () => {
const res = await request(makeApp())
.get("/v1/ocr/extract/123e4567-e89b-42d3-a456-426614174000");
expect(res.status).toBe(404);
expect(res.body.reason).toBe("job_not_found");
});
});

View File

@@ -242,3 +242,115 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
expect(state.snapshot().status).toBe("working");
});
});
describe("walletHealth — F7 circuit breakers", () => {
let state: SupplierState;
let logLines: string[];
let log: (line: string) => void;
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
beforeEach(() => {
vi.useFakeTimers();
state = new SupplierState();
_resetWalletHealthForTests(state);
logLines = [];
log = (line) => logLines.push(line);
consolidate = vi.fn() as never;
});
afterEach(() => {
vi.useRealTimers();
});
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
consolidate.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
await vi.advanceTimersByTimeAsync(3_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
// Two more ticks: the breaker holds, no further txs.
await vi.advanceTimersByTimeAsync(2_000);
expect(consolidate).toHaveBeenCalledTimes(3);
ticker.stop();
});
it("an already-healthy result resets the consecutive counter", async () => {
consolidate
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValue(healthyResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
await vi.advanceTimersByTimeAsync(6_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
ticker.stop();
});
it("halts when the 24h consolidation budget is exhausted", async () => {
// Interleave healthy results so the consecutive guard never fires:
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
consolidate
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(healthyResult)
.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
await vi.advanceTimersByTimeAsync(8_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
// Breaker holds on subsequent ticks.
await vi.advanceTimersByTimeAsync(2_000);
expect(consolidate).toHaveBeenCalledTimes(8);
ticker.stop();
});
it("failed consolidations neither trip nor reset the breakers", async () => {
// Errors spend no fee: they must not count as consolidations, but they
// must not clear the consecutive count either.
consolidate
.mockResolvedValueOnce(consolidatedResult)
.mockResolvedValueOnce(consolidatedResult)
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
.mockResolvedValue(consolidatedResult);
const ticker = startWalletHealthTicker(
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
{ intervalMs: 1_000, log },
);
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
await vi.advanceTimersByTimeAsync(4_000);
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
ticker.stop();
});
});

View File

@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
expect(plan.workingOutput).toBe(8_650_000n);
});
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
// {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
// fees — this wallet can run script txs as-is. The old exact-2-slot
// convention reshaped it for nothing (0.5 AP3X per pass).
const utxos = [u(3_000_000n), u(15_000_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
expect(plan.alreadyHealthy).toBe(false);
expect(plan.inputCount).toBe(2);
expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(true);
expect(plan.collateralOutput).toBe(15_000_000n);
expect(plan.workingOutput).toBe(3_000_000n);
});
it("throws on 0 UTxOs", () => {
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
});
});
describe("planConsolidate — structural health predicate (F7)", () => {
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
const MAINNET_CONSOLIDATE_FEE = 501_628n;
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
// planner called its own output shape unhealthy and re-consolidated
// every 45s tick. Feed it precisely what the builder lands.
const utxos = [
u(5_000_000n),
u(91_000_000n),
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(true);
expect(plan.collateralOutput).toBe(5_000_000n);
});
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
const plan = planConsolidate(fragmented);
expect(plan.alreadyHealthy).toBe(false);
// Model of what buildConsolidateWalletTx lands for this plan.
const afterConsolidation = [
u(plan.collateralOutput),
u(plan.workingOutput),
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
];
const replan = planConsolidate(afterConsolidation);
expect(replan.reason).toBe("already-healthy");
expect(replan.alreadyHealthy).toBe(true);
});
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
const utxos = [
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
u(1_100_000n), u(1_100_000n), u(1_100_000n),
];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
expect(plan.alreadyHealthy).toBe(false);
});
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("a UTxO carrying native assets is not a collateral candidate", () => {
const withToken: UtxoLike = {
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
};
const utxos = [withToken, u(4_000_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("healthy shape below the reshape threshold is healthy, not an error", () => {
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
// able to run script txs. The old order threw "balance too low" here
// on every tick (1,744 log lines in the soak).
const utxos = [u(5_000_000n), u(2_500_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
});
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
// below the reshape minimum — loud error, funds needed.
const utxos = [u(5_000_000n), u(400_000n)];
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
});
});

View File

@@ -0,0 +1,200 @@
/**
* 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);
});
});

View File

@@ -26,6 +26,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
# the whole workspace, so every package's manifest must be present even though
# --filter only installs what wallet-monitor needs.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY wallet-monitor/package.json ./wallet-monitor/package.json
COPY buyer/package.json ./buyer/package.json