29 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
FilipBl4gojevic
c391b98af8 fix(escrow): lock min-ada for the largest future datum so long-capability Submits settle
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The escrow locks payment + both bonds and the validator requires exact
value preservation (value_equal) on Claim and Submit. Submit grows the
inline datum ~43 bytes (submitted_at None->Some, result_receipt_hash
None->Some 32 bytes). When capability_id is long enough, the
Submitted-state output's min-ada exceeds the locked total; lucid bumps
the continuing output, value_equal fails, and the validator errors with
no traces (opaque evaluateTransaction 3010/3012). Measured on mainnet at
utxoCostPerByte 4310: a 20-byte chat capability clears a 2.2M lock by
49,310, a 33-byte OCR capability is over by 11,030 - so short-capability
escrows settle and long ones never can, and because Claim's datum is
smaller it always passes, making a Submit-only failure look chain-side.

escrowLockFloor() computes min-ada for the Submitted state plus a margin
and locks max(economic total, that floor) in the post builders. Buyer-
side only, no contract or supplier changes; lucid never bumps once the
lock is sufficient, and the surplus returns through the >= terminal
distribution. postOcrEscrow's identical change ships with the OCR
feature branch. New unit test covers the floor across capability lengths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:22:02 +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
david
a54fdcb24c feat(deploy): terminate TLS at traefik for the gateway host via Let's Encrypt
api.marketplace.vector.apexfusion.org is no longer Cloudflare-proxied, so
traefik now terminates TLS itself: a websecure router with the letsencrypt
ACME resolver, and the :80 router demoted to a permanent HTTPS redirect.
Other hosts on the box stay Cloudflare-terminated on :80.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 13:30:53 +02:00
FilipBl4gojevic
926300dee5 Merge pull request #6 from FilipBl4gojevic/brand-vocab-scrub
Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
2026-07-30 14:24:52 +02:00
FilipBl4gojevic
73700f983a Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
- docs/ARCHITECTURE.md: settlement-asset framing (AP3X bonded in escrow,
  settles on accepted work); remove references to external network
  adapters that are not part of the project
- docs/buyer + docs/supplier READMEs: commission/bond/settle vocabulary
- Buyer web UI: "Accept & Settle", escrow-framed labels, gateway-managed
  wallet wording; gateway landing title punctuation
- Delete EXECUTIVE_SUMMARY and local-llm-marketplace docs + exports:
  M1-era internal design snapshots superseded by ARCHITECTURE.md and the
  public docs site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:23:35 +02:00
david
2aa1688a17 fix(ci): green the test suite — pnpm 10 build scripts, libsodium ESM, stale time fixtures
CI has been red since the pnpm 10 migration. Four independent causes:

- better-sqlite3: pnpm 10 blocks dependency build scripts by default, so the
  native binding was never compiled on CI. Allowlist it via
  onlyBuiltDependencies in pnpm-workspace.yaml.
- libsodium-wrappers-sumo 0.7.16: the published ESM wrapper does
  `import "./libsodium-sumo.mjs"` but the tarball does not ship that file
  (it lives in the libsodium-sumo package). Patch the import to the bare
  specifier; local runs only worked because of a hand-copied file in
  node_modules.
- tx-accept/claim [live] tests: the live path checks deadlines against
  Date.now(), and the fixed fixture timestamps expired in June. Freeze the
  clock inside the fixture windows with vi.useFakeTimers.
- CML set_exunits panic ("RuntimeError: unreachable"): with
  localUPLCEval: false the provider asks Ogmios for ex-units; the test mocks
  answered `{}`, so the provider fell back to a spend redeemer at index 0
  while the escrow input sorts to index 1 — CML panics on the unknown
  redeemer key. Mock evaluateTransaction with the true index like real
  Ogmios.

Also regenerate the advert-datum golden: the fixture builders passed plain JS
numbers to cbor-x (minimal ints, float64 for the timestamp) while the
production codec — whose output is live on mainnet — emits full-width uint64.
Builders now encode integer fields as BigInt so builders, codec, and golden
agree byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:16:19 +02:00
dcejtlin-apex
3ae2a827c4 Merge pull request #5 from FilipBl4gojevic/gateway-ap3x-strings
Gateway: AP3X terminology in user-facing strings
2026-07-29 10:32:28 +02:00
dcejtlin-apex
fec0ac8d0a Merge pull request #4 from FilipBl4gojevic/dispute-resolution-naming
Naming: Dispute Resolution module
2026-07-29 10:32:16 +02:00
david
be9febffb1 fix(wallet-monitor): query wallets sequentially with retry to avoid Ogmios 500s
The public Ogmios endpoint only serves a few concurrent ledger-state
queries and returns 500 for the rest, so the Promise.all burst over the
(now 19-wallet) list reliably failed several wallets every run. Query
sequentially instead (full sweep ~2.5s) and retry each wallet once after
2s so a lone blip no longer posts a false "query failed" alert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:19:39 +00:00
david
e5793031f8 chore(deploy): track mainnet compose files for ds-flash-chat suppliers 1-3
These were live on the box but never committed. Chat-session replicas of
the deepseek-flash suppliers (OpenRouter deepseek-v4-flash, llm.chat.v1,
ticket settle mode); secrets stay in the gitignored supplier/.env files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:14:42 +02:00
FilipBl4gojevic
716a8bf2ef Gateway: AP3X terminology in user-facing strings and docs
The signup page said '~5 ADA collateral' and two API error messages
told agents to 'deposit a little more ADA' - the coin is AP3X. Strings,
comments, and docs/gateway.md updated; code identifiers unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:14:32 +02:00
david
b10706cf69 fix(wallet-monitor): post only on state changes, drop reminder re-alerts
Slack posts now fire only when a wallet newly drops below its threshold
or recovers above it; a wallet that stays low never re-posts. The
reminder_hours mechanism is removed entirely — per-wallet reminder
clocks drifted apart (Ogmios blips skip wallets from a run), so with
many chronically-low wallets some clock came due nearly every run,
collapsing the 6h cadence to the hourly cron interval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:12:45 +02:00
FilipBl4gojevic
1f587867c6 README: Dispute Resolution naming
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:06:14 +02:00
HalukG
0b253c73bb Merge pull request #3 from FilipBl4gojevic/docs-consolidation-readme
README: marketplace front door - status, flow, layout, links
2026-07-24 13:46:46 +03:00
FilipBl4gojevic
1648edeba7 README: vocabulary pass on Related line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:26:58 +02:00
FilipBl4gojevic
4090e064cb README: note internal audit coverage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:07:03 +02:00
FilipBl4gojevic
3fac04b00e README: marketplace front door - status, flow, layout, links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:57:27 +02:00
100 changed files with 5034 additions and 1008 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

@@ -1,3 +1,36 @@
## Local Agents Marketplace # Local Agents Marketplace
Local Agents Marketplace MVP. See `docs/ARCHITECTURE.md` for the architecture spec. Monorepo managed with pnpm workspaces. Work-in-progress; not production-ready. Bonded-escrow coordination for AI agent work on **Vector**, the Apex Fusion eUTXO L2.
Buyers commission work. Suppliers claim it. Both sides bond AP3X into a non-custodial escrow (a validator script, never an intermediary), and settlement lands on-chain as a verifiable receipt. Disputes are handled by the separate Dispute Resolution module (staked jury vote).
**Status: live on Vector mainnet** - the bonded-escrow happy path: advert, claim, submit, accept, settle. The contracts have been through the internal audit pipeline (methodology published in [vector-ai-agents](https://github.com/Apex-Fusion/vector-ai-agents)); they have not yet undergone independent third-party audit.
## Flow
1. **Advert** - a buyer posts a job with escrow bonded
2. **Claim** - a supplier claims it, bonding their side
3. **Submit** - work is delivered
4. **Accept** - the buyer accepts the work
5. **Settle** - the escrow releases; the settlement is a signed on-chain receipt
## Repository layout
| Path | What |
|---|---|
| `contracts/advert`, `contracts/escrow`, `contracts/marketplace` | Aiken validators |
| `docs/ARCHITECTURE.md` | Architecture spec |
| `docs/buyer/`, `docs/supplier/` | Role guides |
| `deploy/` | Deployment and simulation tooling |
Monorepo managed with pnpm workspaces.
## Getting started
Developer docs: [Vector AI documentation](https://apex-fusion.github.io/vector-ai-documentation/). For the contract-level picture, start with [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Related
- [mcp-server](https://github.com/Apex-Fusion/mcp-server) - hosted MCP server for Vector (testnet and mainnet)
- [agent-sdk-py](https://github.com/Apex-Fusion/agent-sdk-py) / [agent-sdk-ts](https://github.com/Apex-Fusion/agent-sdk-ts) - Python and TypeScript SDKs
- [vector-agent-modules](https://github.com/Apex-Fusion/vector-agent-modules) - dispute resolution, reputation staking, self-improvement

View File

@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json COPY buyer/package.json ./buyer/package.json
COPY supplier/package.json ./supplier/package.json COPY supplier/package.json ./supplier/package.json

View File

@@ -73,8 +73,10 @@ import {
buildPostEscrowTx, buildPostEscrowTx,
buildPostTtsEscrowTx, buildPostTtsEscrowTx,
buildPostChatEscrowTx, buildPostChatEscrowTx,
buildPostOcrEscrowTx,
ttsPromptHash, ttsPromptHash,
chatSessionPromptHash, chatSessionPromptHash,
ocrPromptHash,
buildAcceptTx, buildAcceptTx,
buildReclaimTx, buildReclaimTx,
TxConstructionError, TxConstructionError,
@@ -88,6 +90,8 @@ import type {
SubmitPromptResult, SubmitPromptResult,
SubmitTtsOptions, SubmitTtsOptions,
SubmitTtsResult, SubmitTtsResult,
SubmitOcrOptions,
SubmitOcrResult,
StartChatOptions, StartChatOptions,
StartChatResult, StartChatResult,
ChatSettleMode, 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 ──────────── // ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
/** /**

View File

@@ -187,6 +187,35 @@ export interface SubmitTtsResult {
escrowRef: OutputReference; 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 * StartChatOptions / StartChatResult — Marketplace.startChat() for the
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and * `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and

View File

@@ -94,7 +94,7 @@ export default function JobEstimate({
<dd className="text-right">{est.mapCalls}</dd> <dd className="text-right">{est.mapCalls}</dd>
<dt>Reduce calls</dt> <dt>Reduce calls</dt>
<dd className="text-right">{est.reduceCalls}</dd> <dd className="text-right">{est.reduceCalls}</dd>
<dt className="font-medium">Total paid calls</dt> <dt className="font-medium">Total escrowed calls</dt>
<dd className="text-right font-medium">{est.totalCalls}</dd> <dd className="text-right font-medium">{est.totalCalls}</dd>
<dt className="font-medium">Est. max cost</dt> <dt className="font-medium">Est. max cost</dt>
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd> <dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
@@ -126,7 +126,7 @@ export default function JobEstimate({
disabled={blocked || starting} disabled={blocked || starting}
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400" className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
> >
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`} {starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
</button> </button>
</div> </div>
); );

View File

@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Upload a text-based PDF book. The buyer will chunk it and pay marketplace Upload a text-based PDF book. The buyer will chunk it and commission marketplace
suppliers to summarize each chunk, then synthesize a full-book summary. suppliers to summarize each chunk, then synthesize a full-book summary.
</p> </p>
<input <input

View File

@@ -114,7 +114,7 @@ export default function ApiKeys() {
<h1 className="text-2xl font-semibold">API Keys</h1> <h1 className="text-2xl font-semibold">API Keys</h1>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
custodial wallet — fund its deposit address with AP3X, then point any OpenAI SDK at{" "} gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
<code className="font-mono text-xs">{baseUrl}</code>. <code className="font-mono text-xs">{baseUrl}</code>.
</p> </p>
</div> </div>

View File

@@ -152,7 +152,7 @@ export default function BookSummarizer() {
<div> <div>
<h1 className="text-2xl font-semibold">Book Summarizer</h1> <h1 className="text-2xl font-semibold">Book Summarizer</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary. Upload a PDF → commission suppliers to summarize each chunk → get a full-book summary.
Jobs keep running if you navigate away; reopen them below. Jobs keep running if you navigate away; reopen them below.
</p> </p>
</div> </div>

View File

@@ -1,6 +1,6 @@
/** /**
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows * buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
* for this buyer, with an "Accept & Pay" action per row. * for this buyer, with an "Accept & Settle" action per row.
* *
* Data flow: * Data flow:
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh> * GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
@@ -190,7 +190,7 @@ export default function PendingReceipts() {
<th className="px-2 py-2">Submitted</th> <th className="px-2 py-2">Submitted</th>
<th className="px-2 py-2">Supplier</th> <th className="px-2 py-2">Supplier</th>
<th className="px-2 py-2">Capability</th> <th className="px-2 py-2">Capability</th>
<th className="px-2 py-2">Payment</th> <th className="px-2 py-2">Escrowed</th>
<th className="px-2 py-2">Window left</th> <th className="px-2 py-2">Window left</th>
<th className="px-2 py-2">Action</th> <th className="px-2 py-2">Action</th>
</tr> </tr>
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
: "bg-indigo-600 text-white hover:bg-indigo-700") : "bg-indigo-600 text-white hover:bg-indigo-700")
} }
> >
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"} {isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
</button> </button>
)} )}
</td> </td>

View File

@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
</> </>
)} )}
<span className="text-gray-500">·</span> <span className="text-gray-500">·</span>
<span>{fmtAda(lc.payment_lovelace)} payment</span> <span>{fmtAda(lc.payment_lovelace)} escrowed</span>
<span className="text-gray-500">·</span> <span className="text-gray-500">·</span>
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span> <span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
<span className="text-gray-500">·</span> <span className="text-gray-500">·</span>

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`, joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
starts talking to a real Ogmios, no compose changes are needed. 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

@@ -59,8 +59,18 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
# answered by traefik before the redirect applies.
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web" - "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)" - "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080" - "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -0,0 +1,80 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 1)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-1.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-1 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-1
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-1
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-1
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-1.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "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
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,80 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-2.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-2 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-2
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-2
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-2
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-2.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "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
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,80 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-3.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-3 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-3
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-3
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-3
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-3.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "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
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -61,6 +61,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -62,6 +62,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -62,6 +62,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -74,6 +74,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -75,6 +75,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -60,6 +60,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -8,13 +8,13 @@
## 1. Goal and scope ## 1. Goal and scope
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing. **What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
**What v1 is NOT.** **What v1 is NOT.**
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts. - Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM. - Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes. - Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork). - Not multi-chain. Vector only (testnet and mainnet).
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2. - Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
- Not streaming. `stream: true` is rejected by suppliers. - Not streaming. `stream: true` is rejected by suppliers.
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers. - Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
@@ -24,7 +24,7 @@
| # | Decision | | # | Decision |
|---|---| |---|---|
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. | | 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. | | 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. | | 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. | | 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
@@ -283,7 +283,6 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery | | **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle | | **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated | | **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
## 9. Open follow-ups (not blocking M0 / M1) ## 9. Open follow-ups (not blocking M0 / M1)
@@ -310,5 +309,3 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts` - Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
- Apex-dashboard test discipline: `apex-dashboard/tests/` - Apex-dashboard test discipline: `apex-dashboard/tests/`
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/` - Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
- Masumi docs: https://docs.masumi.network/
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003

View File

@@ -1,412 +0,0 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,11 @@
# Vector Buyer App # Vector Buyer App
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes. A self-contained app for commissioning AI compute on the Vector L2 testnet. You submit prompts, bond AP3X in on-chain escrow, and receive LLM responses from supplier nodes; the escrow settles in AP3X on accepted work.
## How It Works ## How It Works
1. You type a **prompt** and set a **payment** amount in the web UI 1. You type a **prompt** and set a **price** in the web UI
2. The app posts an **escrow transaction** on-chain, locking your payment + bond 2. The app posts an **escrow transaction** on-chain, locking price + bond
3. The app sends the task to a **supplier node** via HTTP 3. The app sends the task to a **supplier node** via HTTP
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain 4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
5. You see the response in the UI and can **accept** to release payment, or **dispute** 5. You see the response in the UI and can **accept** to release payment, or **dispute**
@@ -193,8 +193,8 @@ If you accept the result, you get your 1 AP3X bond back. Total cost = payment +
- **Never share your .env file** - it contains your wallet seed phrase - **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history - **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely. - The seed phrase controls your wallet. Back it up securely.
- Escrow protects you: the supplier cannot take your funds without delivering a result - Escrow protects you: the supplier cannot take your escrowed value without delivering a result
- You have a 10-minute dispute window after receiving a result - You have a 10-minute dispute window after receiving a result
## Troubleshooting ## Troubleshooting

View File

@@ -24,8 +24,8 @@ The audit confirmed 39 holes in the first draft (10 distinct themes). This docum
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) | | One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close | | Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline | | Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** | | Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 AP3X collateral + fee reserve** |
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick | | Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X collateral, working}` after settle / on a tick |
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop | | Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted | | Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk | | Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
@@ -41,7 +41,7 @@ New standalone pnpm package `gateway/` (add to `pnpm-workspace.yaml`). Boots **o
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`. - SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back. - Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this. - Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 ADA`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`. - Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks). - Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`. - SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`. - Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
## Request lifecycle — one-shot `/chat/completions` ## Request lifecycle — one-shot `/chat/completions`
All on-chain steps run inside the per-key `mutex.run(...)`. All on-chain steps run inside the per-key `mutex.run(...)`.
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`). 1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-ADA UTxO ≥5 ADA exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`. 2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt). 3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim). 4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`. 5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
@@ -121,7 +121,7 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
## Sweeper & wallet-health ## Sweeper & wallet-health
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted. - **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 ADA collateral UTxO. Skip if already healthy. - **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X collateral UTxO. Skip if already healthy.
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`) ## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`. no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
@@ -145,7 +145,7 @@ Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-U
## Verification (testnet, end-to-end) ## Verification (testnet, end-to-end)
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active. 1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`. 2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 ADA collateral + ~2 ADA fees; confirm via `GET /account` (`collateral_ok:true`). 3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists. 4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce). 5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
6. One-shot `stream:true` → chunks + final usage + `[DONE]`. 6. One-shot `stream:true` → chunks + final usage + `[DONE]`.

View File

@@ -1,412 +0,0 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

View File

@@ -164,8 +164,8 @@ If the buyer does NOT accept within the dispute window (10 minutes), you can col
- **Never share your .env file** - it contains your wallet seed phrase - **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history - **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely. - The seed phrase controls your wallet. Back it up securely.
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute. - The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
## Troubleshooting ## Troubleshooting

View File

@@ -22,6 +22,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json COPY buyer/package.json ./buyer/package.json
COPY gateway/package.json ./gateway/package.json COPY gateway/package.json ./gateway/package.json

View File

@@ -53,6 +53,9 @@ export interface GatewayConfig {
walletHealthIntervalMs: number; walletHealthIntervalMs: number;
sdkRegistryMax: number; sdkRegistryMax: number;
corsOrigins: string[]; 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 { 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), chatSettleMode: chatSettleMode(env),
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000), walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500), sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "") corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",") .split(",")
.map((o) => o.trim().replace(/\/+$/, "")) .map((o) => o.trim().replace(/\/+$/, ""))

View File

@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
const invokedDirectly = const invokedDirectly =
typeof process !== "undefined" && typeof process !== "undefined" &&
process.argv?.[1] !== 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) { if (invokedDirectly) {
try { 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. * gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
* *
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond + * The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends * supplier_bond, raised to the Submitted-state min-ada floor so long
* that also need a pure-ADA collateral UTxO ≥ 5 ADA (assertCollateralCandidate). * capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve, * see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
* AND the wallet must currently hold a pure-ADA UTxO ≥ collateral. We check both * postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
* up front and return a clean 402 instead of failing mid-flight. * are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
* and return a clean 402 instead of failing mid-flight.
*/ */
import type { ChainProvider, Utxo } from "@marketplace/shared/chain"; import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
import type { EscrowDatum } from "@marketplace/shared/cbor";
import { escrowLockFloor } from "@marketplace/shared/tx";
export const COLLATERAL_MIN_LOVELACE = 5_000_000n; export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
export const FEE_RESERVE_LOVELACE = 2_000_000n; export const FEE_RESERVE_LOVELACE = 2_000_000n;
@@ -18,7 +24,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
return utxos.reduce((sum, u) => sum + u.lovelace, 0n); return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
} }
/** True iff some UTxO is pure-ADA (no native assets) and ≥ collateral floor. */ /** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
export function hasCollateral(utxos: Utxo[]): boolean { export function hasCollateral(utxos: Utxo[]): boolean {
return utxos.some( return utxos.some(
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0, (u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
@@ -32,20 +38,48 @@ export interface PreflightResult {
collateralOk: boolean; collateralOk: boolean;
} }
export interface PreflightCost {
capabilityId: string;
priceLovelace: bigint;
buyerBondLovelace: bigint;
supplierBondLovelace: bigint;
}
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
* floor. Every field except capability_id and the cost amounts is a
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
* POSIX-ms timestamp) — capability_id's actual length is what drives the
* estimate, exactly as in escrowLockFloor's own contract. */
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
return {
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
advert_ref: { txHash: "a6".repeat(32), index: 0 },
capability_id: cost.capabilityId,
request_spec_hash: "1b".repeat(32),
prompt_hash: "23".repeat(32),
payment_lovelace: cost.priceLovelace,
buyer_bond_lovelace: cost.buyerBondLovelace,
supplier_bond_lovelace: cost.supplierBondLovelace,
deliver_by: 1_786_105_955_257,
posted_at: 1_786_105_625_257,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
}
export async function preflight( export async function preflight(
chain: ChainProvider, chain: ChainProvider,
address: string, address: string,
cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint }, cost: PreflightCost,
): Promise<PreflightResult> { ): Promise<PreflightResult> {
const utxos = await chain.queryUtxosByAddress(address); const utxos = await chain.queryUtxosByAddress(address);
const available = totalLovelace(utxos); const available = totalLovelace(utxos);
const collateralOk = hasCollateral(utxos); const collateralOk = hasCollateral(utxos);
const required = const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
cost.priceLovelace + const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
cost.buyerBondLovelace + const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
cost.supplierBondLovelace +
COLLATERAL_MIN_LOVELACE +
FEE_RESERVE_LOVELACE;
return { return {
ok: available >= required && collateralOk, ok: available >= required && collateralOk,
availableLovelace: available, availableLovelace: available,

View File

@@ -108,7 +108,7 @@ async function runOneShot(
throw paymentRequired( throw paymentRequired(
pf.collateralOk pf.collateralOk
? "insufficient balance for this request" ? "insufficient balance for this request"
: "wallet has no pure-ADA UTxO ≥ 5 ADA for collateral; deposit a little more ADA", : "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
{ {
x_vector: { x_vector: {
required_lovelace: pf.requiredLovelace.toString(), required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -93,7 +93,7 @@ export async function openSessionCore(
const pf = await preflight(deps.chain, ctx.walletKey.address, primary); const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
if (!pf.ok) { if (!pf.ok) {
throw paymentRequired( throw paymentRequired(
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 ADA pure-ADA collateral UTxO", pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
{ {
x_vector: { x_vector: {
required_lovelace: pf.requiredLovelace.toString(), required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -53,6 +53,9 @@ export function parseRef(ref: string): OutputReference | null {
export interface SelectSupplierOpts { export interface SelectSupplierOpts {
indexerUrl: string; 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; model: string;
capabilityId: string; capabilityId: string;
fetchFn?: typeof globalThis.fetch; fetchFn?: typeof globalThis.fetch;
@@ -82,7 +85,7 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
const candidates: SupplierCandidate[] = []; const candidates: SupplierCandidate[] = [];
for (const raw of body as SupplierView[]) { for (const raw of body as SupplierView[]) {
if (raw.capability_id !== opts.capabilityId) continue; 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.advert_status !== "Active") continue;
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) { if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
continue; continue;

View File

@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
import { sendError } from "./middleware/http.js"; import { sendError } from "./middleware/http.js";
import { notFound } from "./openai/errors.js"; import { notFound } from "./openai/errors.js";
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js"; import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
import { makeOcrExtractHandler } from "./ocr/extract.js";
import { makeModelsHandler } from "./openai/models.js"; import { makeModelsHandler } from "./openai/models.js";
import { import {
makeOpenSessionHandler, makeOpenSessionHandler,
@@ -56,7 +57,11 @@ export function createApp(deps: GatewayDeps): Express {
app.set("trust proxy", true); app.set("trust proxy", true);
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply. // CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
app.use(corsMiddleware(deps.config.corsOrigins)); 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) => { app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
@@ -80,6 +85,12 @@ export function createApp(deps: GatewayDeps): Express {
app.post("/account/withdraw", auth, makeWithdrawHandler(deps)); app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps)); app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
// (~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.get("/openai/v1/models", auth, makeModelsHandler(deps));
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps)); app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps)); app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));

View File

@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vector Marketplace — OpenAI Gateway</title> <title>Vector Marketplace - OpenAI Gateway</title>
<style> <style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; } body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; } h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
</style> </style>
</head> </head>
<body> <body>
<h1>Vector Marketplace — OpenAI-compatible Gateway</h1> <h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p> <p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
<h2>1. Create an API key</h2> <h2>1. Create an API key</h2>
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
<div id="signupOut"></div> <div id="signupOut"></div>
<h2>2. Fund your wallet</h2> <h2>2. Fund your wallet</h2>
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 ADA collateral + fees).</p> <p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
<h2>3. Account</h2> <h2>3. Account</h2>
<div class="row"> <div class="row">

View File

@@ -2,8 +2,8 @@
* gateway/src/walletHealth.ts — keep each custodial wallet usable. * gateway/src/walletHealth.ts — keep each custodial wallet usable.
* *
* After every settled request (and on a periodic tick) we re-shape the wallet to * After every settled request (and on a periodic tick) we re-shape the wallet to
* {≥5 ADA collateral UTxO, working UTxO} via the shared consolidate builder, so * {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA * the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
* collateral candidate. No-op when the wallet is already healthy. * collateral candidate. No-op when the wallet is already healthy.
*/ */

View File

@@ -46,6 +46,7 @@ function makeDeps(
chatSettleMode: overrides?.chatSettleMode ?? "full", chatSettleMode: overrides?.chatSettleMode ?? "full",
walletHealthIntervalMs: 600_000, walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100, sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [], corsOrigins: [],
}; };
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]); 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, chatSettleMode: opts.chatSettleMode,
walletHealthIntervalMs: 600_000, walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100, sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [], corsOrigins: [],
}; };
const chain = { queryUtxo: async () => null } as unknown as GatewayDeps["chain"]; 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 () => { it("preflight requires balance AND collateral", async () => {
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n }; const cost = {
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA capabilityId: "llm.text.generate.v1",
priceLovelace: 2_000_000n,
buyerBondLovelace: 1_000_000n,
supplierBondLovelace: 1_000_000n,
};
// required = escrowLockFloor(2 + 1 + 1) + 5 (collateral) + 2 (fee) = 11 ADA
// — at this economic total (4 ADA) the Submitted-state min-ada floor for a
// short capability id (~2.2 ADA) stays well under the raw total, so the
// floor is a no-op here; tx-minada-floor.test.ts covers the lifted case.
const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any; const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
const ok = await preflight(fakeChain, "a", cost); const ok = await preflight(fakeChain, "a", cost);
expect(ok.ok).toBe(true); expect(ok.ok).toBe(true);

View File

@@ -29,6 +29,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY indexer/package.json ./indexer/package.json COPY indexer/package.json ./indexer/package.json
COPY indexer-ui/package.json ./indexer-ui/package.json COPY indexer-ui/package.json ./indexer-ui/package.json

View File

@@ -37,5 +37,10 @@
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^2.1.8" "vitest": "^2.1.8"
},
"pnpm": {
"patchedDependencies": {
"libsodium-wrappers-sumo@0.7.16": "patches/libsodium-wrappers-sumo@0.7.16.patch"
}
} }
} }

View File

@@ -42,6 +42,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js"; import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface PostChatEscrowParams { export interface PostChatEscrowParams {
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical); const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = chatSessionPromptHash({ session_nonce }); const promptHash = chatSessionPromptHash({ session_nonce });
const totalLocked = const economicTotal =
advertDatum.price_lovelace + advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace + advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace; advertDatum.supplier_bond_lovelace;
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
state: "Open", 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 (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the capability-agnostic chat builder (messages = []). // Live path: reuse the capability-agnostic chat builder (messages = []).
// The on-chain commitment is the escrowDatum (carrying our session-init // The on-chain commitment is the escrowDatum (carrying our session-init
// prompt_hash); messages are only used for the mock cbor body, which we omit. // prompt_hash); messages are only used for the mock cbor body, which we omit.

View File

@@ -38,6 +38,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js"; import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface PostEscrowParams { export interface PostEscrowParams {
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
// supplier-side validation in M1-C can verify role/content/system integrity. // supplier-side validation in M1-C can verify role/content/system integrity.
const promptHash = sha256Utf8Hex(canonicalize(messages)); const promptHash = sha256Utf8Hex(canonicalize(messages));
const totalLocked = const economicTotal =
advertDatum.price_lovelace + advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace + advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace; advertDatum.supplier_bond_lovelace;
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
state: "Open", 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 (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: produce real Cardano CBOR via lucid-evolution. // Live path: produce real Cardano CBOR via lucid-evolution.
if (detectCborBackend(chain) === "live") { if (detectCborBackend(chain) === "live") {
const liveCborPath = "../internal/liveCbor.js"; const liveCborPath = "../internal/liveCbor.js";

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

@@ -31,6 +31,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js"; import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
/** TTS request envelope. The buyer-side hash and the supplier-side hash /** TTS request envelope. The buyer-side hash and the supplier-side hash
@@ -149,7 +150,7 @@ export async function buildPostTtsEscrowTx(
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical); const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = ttsPromptHash(request); const promptHash = ttsPromptHash(request);
const totalLocked = const economicTotal =
advertDatum.price_lovelace + advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace + advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace; advertDatum.supplier_bond_lovelace;
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
state: "Open", 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 (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the chat builder. The live cbor path is already // Live path: reuse the chat builder. The live cbor path is already
// capability-agnostic — `messages` is `void`-marked there. We pass an // capability-agnostic — `messages` is `void`-marked there. We pass an
// empty array so the type checker is satisfied; the actual on-chain // empty array so the type checker is satisfied; the actual on-chain

View File

@@ -7,6 +7,7 @@ export { TxConstructionError } from "./types.js";
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js"; export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js"; export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js"; export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
export type { Signer } from "./signer.js"; export type { Signer } from "./signer.js";
export { MockSigner } from "./signer.js"; export { MockSigner } from "./signer.js";
export type { Blueprint } from "./blueprint.js"; export type { Blueprint } from "./blueprint.js";
@@ -37,6 +38,15 @@ export {
} from "./escrow/postTtsEscrow.js"; } from "./escrow/postTtsEscrow.js";
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js"; export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
export { buildPostChatEscrowTx, chatSessionPromptHash } 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 type { ClaimParams } from "./escrow/claim.js";
export { buildClaimTx } from "./escrow/claim.js"; export { buildClaimTx } from "./escrow/claim.js";
export type { SubmitParams } from "./escrow/submit.js"; export type { SubmitParams } from "./escrow/submit.js";

View File

@@ -0,0 +1,67 @@
/**
* minAdaFloor.ts — min-ada floor for the escrow's LARGEST future datum.
*
* The escrow validator requires value_equal(continuing, in) on Claim and
* Submit, so the lovelace locked at POST time must already satisfy min-ada
* for every datum the output will ever carry. The largest is the Submitted
* state: submitted_at None -> Some(POSIX ms) and result_receipt_hash
* None -> Some(32 bytes) add ~43 bytes over Open/Claimed. If the posted
* lovelace sits below that state's min-ada, lucid bumps the Submit
* continuing output to min-ada, value_equal fails, and the validator
* errors with no traces (the 2026-08-07 mainnet incident: the 33-byte
* "ocr.page.extract.chandra-ocr-2.v1" capability_id pushed the Submitted
* output 11,030 lovelace over a 2,200,000 lock; the 20-byte chat
* capability stayed 49,310 under, which is why only OCR Submits failed).
*
* escrowLockFloor() returns the lovelace to lock: the economic total
* (payment + both bonds), raised to the Submitted-state min-ada plus a
* safety margin when the total alone is too small.
*/
import type { EscrowDatum } from "../../cbor/types.js";
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
/** Conway/Babbage utxoCostPerByte on Vector mainnet (protocol params). */
const UTXO_COST_PER_BYTE = 4310n;
/** Ledger constant: min-ada = (160 + serialized output bytes) * costPerByte. */
const OUTPUT_OVERHEAD_BYTES = 160n;
/** Covers CBOR framing drift between this estimate and the ledger's exact
* serialization (address, coin width, datum wrapper). Generous on purpose:
* 50k lovelace = 0.05 AP3X, dwarfed by per-leg tx fees. */
const SAFETY_MARGIN_LOVELACE = 50_000n;
/** Serialized-output byte estimate for an enterprise script address output
* carrying `lovelace` and an inline datum of `datumBytes`. Mirrors the
* arithmetic validated against mainnet on 2026-08-07 (minada-calc). */
function estimateOutputBytes(datumBytes: number, lovelace: bigint): bigint {
const addr = 1n + 29n;
const coin = 1n + (lovelace > 0xffffffffn ? 8n : 4n);
const datumField = 1n + 2n + 3n + BigInt(datumBytes);
const mapOverhead = 3n;
return addr + coin + datumField + mapOverhead;
}
/** Min-ada for the escrow output when it carries `datum`. */
export function minAdaForEscrowDatum(datum: EscrowDatum, lovelace: bigint): bigint {
const datumBytes = encodeEscrowDatum(datum).length / 2;
const size = estimateOutputBytes(datumBytes, lovelace);
return (OUTPUT_OVERHEAD_BYTES + size) * UTXO_COST_PER_BYTE;
}
/** The lovelace a POST must lock so the escrow output satisfies min-ada in
* every reachable state, including Submitted. `economicTotal` is
* payment + buyer_bond + supplier_bond. */
export function escrowLockFloor(openDatum: EscrowDatum, economicTotal: bigint): bigint {
const submittedShape: EscrowDatum = {
...openDatum,
// Representative worst-case Submit fields: a 13-digit POSIX ms stamp
// and a 32-byte receipt hash — sizes, not values, matter here.
submitted_at: openDatum.deliver_by,
result_receipt_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
state: "Submitted",
};
const floor = minAdaForEscrowDatum(submittedShape, economicTotal) + SAFETY_MARGIN_LOVELACE;
return floor > economicTotal ? floor : economicTotal;
}

View File

@@ -2,9 +2,22 @@
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow. * planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
* *
* Given a wallet's current UTxO set + a target collateral lovelace, decide * Given a wallet's current UTxO set + a target collateral lovelace, decide
* whether to consolidate (many → 2), split (1 → 2), or skip (already at * whether the wallet needs reshaping or is already usable for script spends.
* {≥collateral, working}). The same downstream tx-builder handles *
* consolidate and split — only the "reason" tag differs for logging. * 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. * 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_COLLATERAL_LOVELACE = 5_000_000n;
export const DEFAULT_FEE_RESERVE = 2_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; 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( export function planConsolidate(
utxos: UtxoLike[], utxos: UtxoLike[],
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE, collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
feeReserve: bigint = DEFAULT_FEE_RESERVE, feeReserve: bigint = DEFAULT_FEE_RESERVE,
maxUtxos: number = DEFAULT_MAX_UTXOS,
): PlannedConsolidate { ): PlannedConsolidate {
if (utxos.length === 0) { if (utxos.length === 0) {
throw new Error("wallet has no UTxOs — fund the address first"); throw new Error("wallet has no UTxOs — fund the address first");
@@ -42,6 +67,32 @@ export function planConsolidate(
0n, 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; const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
if (totalLovelaceIn < required) { if (totalLovelaceIn < required) {
throw new Error( 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; const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
return { return {

File diff suppressed because one or more lines are too long

9
pnpm-lock.yaml generated
View File

@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
libsodium-wrappers-sumo@0.7.16:
hash: 761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096
path: patches/libsodium-wrappers-sumo@0.7.16.patch
importers: importers:
.: .:
@@ -3166,7 +3171,7 @@ snapshots:
'@cardano-sdk/util': 0.16.0 '@cardano-sdk/util': 0.16.0
blake2b: 2.1.4 blake2b: 2.1.4
i: 0.3.7 i: 0.3.7
libsodium-wrappers-sumo: 0.7.16 libsodium-wrappers-sumo: 0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096)
lodash: 4.18.1 lodash: 4.18.1
npm: 9.9.4 npm: 9.9.4
pbkdf2: 3.1.5 pbkdf2: 3.1.5
@@ -4743,7 +4748,7 @@ snapshots:
libsodium-sumo@0.7.16: {} libsodium-sumo@0.7.16: {}
libsodium-wrappers-sumo@0.7.16: libsodium-wrappers-sumo@0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096):
dependencies: dependencies:
libsodium-sumo: 0.7.16 libsodium-sumo: 0.7.16

View File

@@ -6,3 +6,6 @@ packages:
- "indexer" - "indexer"
- "indexer-ui" - "indexer-ui"
- "wallet-monitor" - "wallet-monitor"
onlyBuiltDependencies:
- better-sqlite3

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. # Workspace manifests first to maximise layer cache reuse.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY supplier/package.json ./supplier/package.json COPY supplier/package.json ./supplier/package.json
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the # 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) * "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
* "tts" — `audio.synthesize.piper.v1` * "tts" — `audio.synthesize.piper.v1`
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns, * "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
* streaming). Requires LLM_BACKEND="openai". */ * streaming). Requires LLM_BACKEND="openai".
export type CapabilityKind = "chat" | "tts" | "chat-session"; * "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. */ /** LLM backend selected for the chat capability. */
export type LlmBackend = "ollama" | "openai"; export type LlmBackend = "ollama" | "openai";
@@ -123,6 +141,37 @@ export interface SupplierConfig {
maxChatSessions: number; maxChatSessions: number;
/** Chain settlement mode for chat sessions. Default "full". */ /** Chain settlement mode for chat sessions. Default "full". */
chatSettleMode: ChatSettleMode; 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). * When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes). * 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. // CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
// Default to "chat" so existing chat suppliers keep booting unchanged. // Default to "chat" so existing chat suppliers keep booting unchanged.
const capKindStr = env.CAPABILITY_KIND ?? "chat"; const capKindStr = env.CAPABILITY_KIND ?? "chat";
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") { if (
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"'); 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; const capabilityKind: CapabilityKind = capKindStr;
@@ -204,6 +258,19 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL"); openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? ""; ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_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") { } else if (capabilityKind === "chat") {
if (llmBackend === "openai") { if (llmBackend === "openai") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL"); openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
@@ -318,6 +385,52 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
} }
const chatSettleMode: ChatSettleMode = settleModeStr; 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; const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000; let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") { if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -362,6 +475,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
chatIdleTimeoutMs, chatIdleTimeoutMs,
maxChatSessions, maxChatSessions,
chatSettleMode, chatSettleMode,
ocrUpstream,
datalabApiKey,
datalabBaseUrl,
datalabMode,
datalabTimeoutMs,
ocrPromptOverride,
hfModelRevision,
revisionProbeUrl,
revisionProbeMode,
liveChain, liveChain,
walletHealthIntervalMs, 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 ollama from "./ollama.js";
import * as openai from "./openai.js"; import * as openai from "./openai.js";
import * as piper from "./piper.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 { import type {
JobStore, JobStore,
JobResponsePayload, JobResponsePayload,
ChatJobResponsePayload, ChatJobResponsePayload,
TtsJobResponsePayload, TtsJobResponsePayload,
OcrJobResponsePayload,
} from "./jobs.js"; } from "./jobs.js";
export interface RunChatJobDeps { 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; 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. * Discriminated union of every job-payload shape the store can hold.
* `kind` defaults to "chat" on the chat shape (older code paths don't * `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 * set it; new `runChatJob` writes can set kind: "chat" explicitly to make
* the discriminator narrowing reliable). * the discriminator narrowing reliable).
*/ */
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload; export type JobResponsePayload =
| ChatJobResponsePayload
| TtsJobResponsePayload
| OcrJobResponsePayload;
export interface JobFailure { export interface JobFailure {
httpStatus: number; 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, mockSlotToWallclockMs,
detectCborBackend, detectCborBackend,
normalizeChatMessage, normalizeChatMessage,
ocrPromptHash,
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "@marketplace/shared/tx"; } from "@marketplace/shared/tx";
import type { OcrRequest } from "@marketplace/shared/tx";
import type { SupplierState } from "./state.js"; import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js"; import type { SupplierConfig } from "./config.js";
import { JobStore } from "./jobs.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 { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js";
import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js"; import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js";
import { callOpenAiStream } from "./openai.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 // Defensive narrowing: this poll route only knows how to render the
// chat shape. A future shared JobStore could mix capabilities; we'd // chat shape. A future shared JobStore could mix capabilities; we'd
// want each kind's poll to be sure it's reading its own. // 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", 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({ return res.status(200).json({
choices: payload.choices, 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) ─── // ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ───
// //
// Escrow bookends with off-chain turns: // Escrow bookends with off-chain turns:
@@ -1077,7 +1381,11 @@ export function createApp(deps: SupplierDeps): Application {
const app = express(); const app = express();
app.disable("x-powered-by"); 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 // /healthz is mounted FIRST and uses no deps — it must succeed regardless
// of chain/state/config (independent of /status free/working/offline). // of chain/state/config (independent of /status free/working/offline).
@@ -1095,6 +1403,11 @@ export function createApp(deps: SupplierDeps): Application {
if (resolved.config.capabilityKind === "tts") { if (resolved.config.capabilityKind === "tts") {
app.post("/v1/audio/synthesize", makeTtsHandler(resolved)); app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(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") { } else if (resolved.config.capabilityKind === "chat-session") {
const chat = makeChatSessionHandlers(resolved); const chat = makeChatSessionHandlers(resolved);
app.post("/v1/chat/start", chat.start); app.post("/v1/chat/start", chat.start);

View File

@@ -7,15 +7,26 @@
* and the lane stalls until an operator runs `tx:consolidate-wallet`. * and the lane stalls until an operator runs `tx:consolidate-wallet`.
* *
* Two layers of self-healing: * Two layers of self-healing:
* startWalletHealthTicker — periodic background tick (every N ms). When * startWalletHealthTicker — periodic background tick (every N ms). Runs
* the supplier is idle, runs runConsolidateWallet. planConsolidate * runConsolidateWallet; planConsolidate's structural health predicate
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this * short-circuits without a tx when a collateral candidate exists and
* is a no-op in steady state — no tx, no fee. * fragmentation is bounded. A tick on a healthy wallet costs one UTxO
* query and nothing else.
* triggerOnFailureConsolidate — fire-and-forget consolidate from the * triggerOnFailureConsolidate — fire-and-forget consolidate from the
* Claim-failure path. Debounced so back-to-back failures don't queue * Claim-failure path. Debounced so back-to-back failures don't queue
* multiple consolidates. Returns 503 to the buyer for THIS request; the * multiple consolidates. Returns 503 to the buyer for THIS request; the
* next buyer retry (~30-60s later) finds the wallet healthy. * 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 * Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
* inside state.walletMutex — the same mutex that serializes Claims/Submits — * inside state.walletMutex — the same mutex that serializes Claims/Submits —
* queueing behind any in-flight chain op instead of skipping. Session slots * 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 inFlight = new WeakSet<SupplierState>();
const lastOnFailureAt = new WeakMap<SupplierState, number>(); 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 { function defaultLog(line: string): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[wallet-health] ${line}`); console.log(`[wallet-health] ${line}`);
@@ -73,6 +107,8 @@ async function consolidateOnce(
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void }, opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
): Promise<void> { ): Promise<void> {
const { state } = deps; const { state } = deps;
const guards = guardsFor(state);
if (guards.halted) return; // the halt already logged loudly, once
if (inFlight.has(state)) { if (inFlight.has(state)) {
opts.log("skip: another consolidate already in flight"); opts.log("skip: another consolidate already in flight");
return; return;
@@ -92,11 +128,36 @@ async function consolidateOnce(
}), }),
); );
if (result.reason === "already-healthy") { if (result.reason === "already-healthy") {
guards.consecutive = 0;
opts.log("already-healthy"); opts.log("already-healthy");
} else { } 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}`); 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) { } 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}`); opts.log(`consolidate failed: ${(err as Error).message}`);
} finally { } finally {
inFlight.delete(state); 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 { export function _resetWalletHealthForTests(state: SupplierState): void {
inFlight.delete(state); inFlight.delete(state);
lastOnFailureAt.delete(state); lastOnFailureAt.delete(state);
guardCounters.delete(state);
} }

View File

@@ -118,15 +118,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh), hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id), utf8Bytes(datum.capability_id),
utf8Bytes(datum.model), utf8Bytes(datum.model),
datum.max_output_tokens, // BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
datum.max_processing_ms, // numbers encode minimally (or as float64 past 2^32) and break golden parity.
BigInt(datum.max_output_tokens),
BigInt(datum.max_processing_ms),
datum.price_lovelace, datum.price_lovelace,
datum.supplier_bond_lovelace, datum.supplier_bond_lovelace,
datum.buyer_bond_lovelace, datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url), utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri), utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash), hexToBytes(datum.detail_hash),
datum.advertised_at, BigInt(datum.advertised_at),
statusTag, statusTag,
]); ]);

View File

@@ -1 +1 @@
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e356219020019ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafb4279667e84f00000d87980 d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e35621b00000000000002001b000000000000ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1b0000019667e84f00d87980

View File

@@ -107,15 +107,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh), hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id), utf8Bytes(datum.capability_id),
utf8Bytes(datum.model), utf8Bytes(datum.model),
datum.max_output_tokens, // BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
datum.max_processing_ms, // numbers encode minimally (or as float64 past 2^32) and break golden parity.
BigInt(datum.max_output_tokens),
BigInt(datum.max_processing_ms),
datum.price_lovelace, datum.price_lovelace,
datum.supplier_bond_lovelace, datum.supplier_bond_lovelace,
datum.buyer_bond_lovelace, datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url), utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri), utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash), hexToBytes(datum.detail_hash),
datum.advertised_at, BigInt(datum.advertised_at),
statusTag, statusTag,
]); ]);

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. */ /** Env map matching the sample config above, for loadConfig() unit tests. */
export function buildSampleEnv(): Record<string, string> { export function buildSampleEnv(): Record<string, string> {
return { return {

View File

@@ -86,28 +86,23 @@ function protocolParamsResponse() {
} }
/** /**
* Attempt to extract and decode the AdvertDatum from a submitted test-tx hex. * Attempt to extract and decode the AdvertDatum from a submitted tx CBOR hex.
* *
* The test tx format produced by encodeTxBody: * The live path emits real Cardano (Conway) CBOR. The advert datum rides in
* - First 8 hex chars: big-endian uint32 byte length of JSON payload * the script output as an inline datum: datum_option = [1, #6.24(bytes)], so
* - Next (length * 2) hex chars: JSON UTF-8 encoded payload * in hex it appears as "d818" (tag 24) followed by a definite-length byte
* string (0x58 <len> or 0x59 <len16>) holding the raw AdvertDatum CBOR.
* *
* The JSON has shape { type, outputs: [{..., datumHex: "<hex>"}], ... } * Returns null if no tag-24 wrapped datum is found or decoding fails.
* We extract the first output's datumHex and decode it with decodeAdvertDatum.
*
* Returns null if the hex is not in this format or has no datum.
*/ */
function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null { function extractAdvertisedAtFromTxCbor(txCborHex: string): number | null {
try { try {
if (txCborHex.length < 8) return null; const m = /d818(?:58([0-9a-f]{2})|59([0-9a-f]{4}))/i.exec(txCborHex);
const lenHex = txCborHex.slice(0, 8); if (!m) return null;
const jsonLen = parseInt(lenHex, 16); const byteLen = parseInt(m[1] ?? m[2]!, 16);
if (!Number.isFinite(jsonLen) || jsonLen <= 0) return null; const start = m.index + m[0].length;
const jsonHex = txCborHex.slice(8, 8 + jsonLen * 2); const datumHex = txCborHex.slice(start, start + byteLen * 2);
const jsonStr = Buffer.from(jsonHex, "hex").toString("utf8"); if (datumHex.length !== byteLen * 2) return null;
const body = JSON.parse(jsonStr) as { outputs?: Array<{ datumHex?: string }> };
const datumHex = body.outputs?.[0]?.datumHex;
if (!datumHex) return null;
const datum = decodeAdvertDatum(datumHex); const datum = decodeAdvertDatum(datumHex);
return datum.advertised_at; return datum.advertised_at;
} catch { } catch {

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

@@ -120,6 +120,13 @@ function setupAcceptLiveHappyPath(escrowDatumHex: string): void {
} }
// Tip slot is LOW — live path uses Date.now() for the window check // Tip slot is LOW — live path uses Date.now() for the window check
if (method === "queryNetwork/tip") return rpcOk({ slot: 100_000, id: "a".repeat(64) }); if (method === "queryNetwork/tip") return rpcOk({ slot: 100_000, id: "a".repeat(64) });
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -11,7 +11,7 @@
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path. * M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js"; import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js"; import { buildAcceptTx, ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js"; import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
@@ -36,8 +36,20 @@ const COLLATERAL_LOVELACE = 100_000_000;
const mockFetch = vi.fn(); const mockFetch = vi.fn();
// Freeze Date.now() inside the fixture's accept window: the live path checks
// submitted_at + ACCEPT_WINDOW_MS against wall-clock time, and the fixture
// timestamps are fixed POSIX values that would otherwise expire as real time
// passes.
const FAKE_NOW_MS = SUBMITTED_AT + 60_000;
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_MS);
});
afterEach(() => {
vi.useRealTimers();
}); });
function rpcOk<T>(result: T) { function rpcOk<T>(result: T) {
@@ -89,6 +101,13 @@ function setupAcceptHappyPath(escrowDatumHex: string): void {
return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, COLLATERAL_LOVELACE)]); return rpcOk([makeOgmiosUtxo("c".repeat(64), 0, buyer.address, COLLATERAL_LOVELACE)]);
} }
if (method === "queryNetwork/tip") return rpcOk({ slot: tipSlot, id: "a".repeat(64) }); if (method === "queryNetwork/tip") return rpcOk({ slot: tipSlot, id: "a".repeat(64) });
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -115,6 +115,13 @@ function setupClaimLiveHappyPath(escrowDatumHex: string): void {
if (method === "queryNetwork/tip") { if (method === "queryNetwork/tip") {
return rpcOk({ slot: 100_000, id: "a".repeat(64) }); return rpcOk({ slot: 100_000, id: "a".repeat(64) });
} }
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -10,7 +10,7 @@
* M1-F-4 RED — tests fail until Catherine implements the live CBOR path. * M1-F-4 RED — tests fail until Catherine implements the live CBOR path.
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js"; import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js"; import { buildClaimTx } from "../../packages/shared/src/tx/escrow/claim.js";
import { TxConstructionError } from "../../packages/shared/src/tx/types.js"; import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
@@ -32,8 +32,19 @@ const COLLATERAL_LOVELACE = 100_000_000;
const mockFetch = vi.fn(); const mockFetch = vi.fn();
// Freeze Date.now() inside the fixture's claim window: the live path checks
// deliver_by against wall-clock time, and the fixture deadlines are fixed
// POSIX timestamps that would otherwise expire as real time passes.
const FAKE_NOW_MS = DELIVER_BY - 30_000;
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(FAKE_NOW_MS);
});
afterEach(() => {
vi.useRealTimers();
}); });
function rpcOk<T>(result: T) { function rpcOk<T>(result: T) {
@@ -87,6 +98,13 @@ function setupClaimHappyPath(escrowDatumHex: string): void {
// Tip well before deliver_by // Tip well before deliver_by
return rpcOk({ slot: Math.floor((DELIVER_BY - 60_000) / 1000), id: "a".repeat(64) }); return rpcOk({ slot: Math.floor((DELIVER_BY - 60_000) / 1000), id: "a".repeat(64) });
} }
// Like real Ogmios, return the script-spend redeemer with its true index:
// the escrow input ("ff…") sorts after the wallet input ("cc…"), so the
// spend redeemer index is 1. The provider's index-0 fallback for empty
// mock responses makes CML's set_exunits panic ("unreachable").
if (method === "evaluateTransaction") {
return rpcOk([{ validator: { purpose: "spend", index: 1 }, budget: { memory: 1_000_000, cpu: 500_000_000 } }]);
}
if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } }); if (method === "submitTransaction") return rpcOk({ transaction: { id: "d".repeat(64) } });
return rpcOk({}); return rpcOk({});
}); });

View File

@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
expect(plan.workingOutput).toBe(8_650_000n); expect(plan.workingOutput).toBe(8_650_000n);
}); });
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => { it("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape. // {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 utxos = [u(3_000_000n), u(15_000_000n)];
const plan = planConsolidate(utxos); const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate"); expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(false); expect(plan.alreadyHealthy).toBe(true);
expect(plan.inputCount).toBe(2); expect(plan.collateralOutput).toBe(15_000_000n);
expect(plan.workingOutput).toBe(3_000_000n);
}); });
it("throws on 0 UTxOs", () => { it("throws on 0 UTxOs", () => {
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE); 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,84 @@
/**
* tx-minada-floor.test.ts — the escrow min-ada floor.
*
* The escrow output locks payment + both bonds and the validator requires
* exact value preservation (value_equal) on Claim and Submit. Submit grows
* the inline datum (submitted_at None->Some, result_receipt_hash None->Some
* 32 bytes), so the lovelace locked at POST time must already cover min-ada
* for the Submitted state. A capability_id long enough to push the
* Submitted output's min-ada past the economic total made lucid bump the
* continuing output, value_equal failed, and the validator errored with no
* traces — so escrows with short capability ids settled and long ones never
* could. escrowLockFloor closes that by locking the max of the economic
* total and the Submitted-state min-ada plus a margin.
*/
import { describe, it, expect } from "vitest";
import {
escrowLockFloor,
minAdaForEscrowDatum,
} from "../../packages/shared/src/tx/internal/minAdaFloor.js";
import type { EscrowDatum } from "../../packages/shared/src/cbor/types.js";
function openDatum(capabilityId: string): EscrowDatum {
return {
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
advert_ref: { txHash: "a6".repeat(32), index: 0 },
capability_id: capabilityId,
request_spec_hash: "1b".repeat(32),
prompt_hash: "23".repeat(32),
payment_lovelace: 200_000n,
buyer_bond_lovelace: 1_000_000n,
supplier_bond_lovelace: 1_000_000n,
deliver_by: 1_786_105_955_257,
posted_at: 1_786_105_625_257,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
}
const ECONOMIC_TOTAL = 2_200_000n; // 0.2 payment + 1.0 + 1.0 bonds
describe("escrowLockFloor", () => {
it("barely lifts a short-capability escrow (margin only, no capability-driven bump)", () => {
// The 20-byte chat capability's Submitted-state min-ada is ~2.15M, under
// the 2.2M economic total; only the safety margin nudges the lock, so it
// stays within a fraction of a percent of payment + bonds.
const datum = openDatum("llm.text.generate.v1");
const floor = escrowLockFloor(datum, ECONOMIC_TOTAL);
expect(floor).toBeGreaterThanOrEqual(ECONOMIC_TOTAL);
expect(floor).toBeLessThan(ECONOMIC_TOTAL + 100_000n); // << the OCR lift
});
it("raises a long-capability escrow above the economic total", () => {
// The 33-byte OCR capability that could not settle: its Submitted-state
// output needs more than 2.2M, so the floor must lift the lock.
const datum = openDatum("ocr.page.extract.chandra-ocr-2.v1");
const floor = escrowLockFloor(datum, ECONOMIC_TOTAL);
expect(floor).toBeGreaterThan(ECONOMIC_TOTAL);
// The locked value must satisfy the Submitted-state min-ada it is sized for.
const submitted: EscrowDatum = {
...datum,
submitted_at: datum.deliver_by,
result_receipt_hash: "ff".repeat(32),
state: "Submitted",
};
expect(floor).toBeGreaterThanOrEqual(minAdaForEscrowDatum(submitted, floor));
});
it("never returns less than the economic total", () => {
for (const cap of ["a", "x".repeat(64), "tts.speak.v1"]) {
expect(escrowLockFloor(openDatum(cap), ECONOMIC_TOTAL)).toBeGreaterThanOrEqual(
ECONOMIC_TOTAL,
);
}
});
it("the floor is monotonic in capability-id length", () => {
const short = escrowLockFloor(openDatum("a.b.v1"), ECONOMIC_TOTAL);
const long = escrowLockFloor(openDatum("a.very.long.capability.id.that.keeps.going.v1"), ECONOMIC_TOTAL);
expect(long).toBeGreaterThanOrEqual(short);
});
});

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

@@ -9,10 +9,9 @@ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
# Defaults to the mainnet public endpoint (also pinned in the compose file). # Defaults to the mainnet public endpoint (also pinned in the compose file).
# OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org # OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
# OPTIONAL — override the wallets.json default threshold / reminder cadence # OPTIONAL — override the wallets.json default threshold without editing the
# without editing the mounted wallets file. # mounted wallets file.
# DEFAULT_MIN_AP3X=10 # DEFAULT_MIN_AP3X=10
# REMINDER_HOURS=6
# OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose). # OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose).
# WALLETS_PATH=/repo/wallet-monitor/wallets.json # WALLETS_PATH=/repo/wallet-monitor/wallets.json

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 # the whole workspace, so every package's manifest must be present even though
# --filter only installs what wallet-monitor needs. # --filter only installs what wallet-monitor needs.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY wallet-monitor/package.json ./wallet-monitor/package.json COPY wallet-monitor/package.json ./wallet-monitor/package.json
COPY buyer/package.json ./buyer/package.json COPY buyer/package.json ./buyer/package.json

View File

@@ -2,12 +2,12 @@
One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box. One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box.
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**), Checks every wallet in `wallets.json` against a threshold (default **10 APEX**)
posts a combined Slack message when any wallet is below it, re-reminds every and posts a combined Slack message only when something **changed** since the
`reminder_hours` while a wallet stays low, and posts a recovery message when it last run — a wallet newly dropped below its threshold, or climbed back above
climbs back above. Each alert lists **every** monitored wallet — low (🔴) and it. A wallet that merely stays low never re-posts. Each alert lists **every**
healthy (🟢) — with its full, copy-pasteable address; it stays fully silent monitored wallet — low (🔴) and healthy (🟢) — with its full, copy-pasteable
when all wallets are healthy. Balances are read from Ogmios via address; it stays fully silent while nothing changes. Balances are read from Ogmios via
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind `ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`. the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`.

View File

@@ -8,12 +8,11 @@
* STATE_PATH — path to the dedup state file (read/written each run). * STATE_PATH — path to the dedup state file (read/written each run).
* Default /repo/wallet-monitor/data/state.json. * Default /repo/wallet-monitor/data/state.json.
* DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x. * DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x.
* REMINDER_HOURS — (optional) overrides wallets.json reminder_hours.
* TEST — "1" posts a one-line "configured" message to Slack and exits. * TEST — "1" posts a one-line "configured" message to Slack and exits.
* *
* wallets.json owns the wallet list + the default threshold / reminder cadence; * wallets.json owns the wallet list + the default threshold; the env override
* the env overrides exist only so an operator can retune without editing the * exists only so an operator can retune without editing the mounted wallets
* mounted wallets file. * file.
*/ */
const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org"; const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org";
@@ -21,7 +20,6 @@ const DEFAULT_WALLETS_PATH = "/repo/wallet-monitor/wallets.json";
const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json"; const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json";
const FALLBACK_DEFAULT_MIN_AP3X = 10; const FALLBACK_DEFAULT_MIN_AP3X = 10;
const FALLBACK_REMINDER_HOURS = 6;
export interface EnvConfig { export interface EnvConfig {
slackWebhookUrl: string; slackWebhookUrl: string;
@@ -30,8 +28,6 @@ export interface EnvConfig {
statePath: string; statePath: string;
/** When set, overrides the wallets-file default_min_ap3x. */ /** When set, overrides the wallets-file default_min_ap3x. */
defaultMinAp3xOverride: number | null; defaultMinAp3xOverride: number | null;
/** When set, overrides the wallets-file reminder_hours. */
reminderHoursOverride: number | null;
testMode: boolean; testMode: boolean;
} }
@@ -44,7 +40,6 @@ export interface WalletEntry {
export interface WalletsFile { export interface WalletsFile {
defaultMinAp3x: number; defaultMinAp3x: number;
reminderHours: number;
wallets: WalletEntry[]; wallets: WalletEntry[];
} }
@@ -81,7 +76,6 @@ export function loadConfig(env: Record<string, string | undefined>): EnvConfig {
walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH), walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH),
statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH), statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH),
defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"), defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"),
reminderHoursOverride: parseOptionalPositiveNumber(env.REMINDER_HOURS, "REMINDER_HOURS"),
testMode: env.TEST === "1", testMode: env.TEST === "1",
}; };
} }
@@ -113,11 +107,6 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
"default_min_ap3x", "default_min_ap3x",
FALLBACK_DEFAULT_MIN_AP3X, FALLBACK_DEFAULT_MIN_AP3X,
); );
const reminderHours = parsePositiveNumberField(
raw.reminder_hours,
"reminder_hours",
FALLBACK_REMINDER_HOURS,
);
const walletsRaw = raw.wallets; const walletsRaw = raw.wallets;
if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) { if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) {
@@ -152,5 +141,5 @@ export function parseWalletsFile(raw: unknown): WalletsFile {
return entry; return entry;
}); });
return { defaultMinAp3x, reminderHours, wallets }; return { defaultMinAp3x, wallets };
} }

View File

@@ -3,9 +3,9 @@
* *
* Runs once and exits (invoked hourly by cron via `docker compose run --rm`). * Runs once and exits (invoked hourly by cron via `docker compose run --rm`).
* For each wallet in wallets.json: query its on-chain balance via Ogmios, and * For each wallet in wallets.json: query its on-chain balance via Ogmios, and
* if it's below threshold post a combined Slack alert. A persisted state file * post a combined Slack alert ONLY when something changed since the last run —
* dedups so a still-low wallet only re-alerts every `reminder_hours`, and a * a wallet newly dropped below threshold, or climbed back above it. A wallet
* recovery message fires when a wallet climbs back above its threshold. * that merely stays low never re-posts (state persisted across runs).
* *
* Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same * Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same
* client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts. * client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts.
@@ -19,7 +19,6 @@ import { loadConfig, parseWalletsFile, type WalletEntry } from "./config.js";
import { postSlack } from "./slack.js"; import { postSlack } from "./slack.js";
const LOVELACE_PER_AP3X = 1_000_000; const LOVELACE_PER_AP3X = 1_000_000;
const MS_PER_HOUR = 3_600_000;
type Status = "ok" | "low"; type Status = "ok" | "low";
@@ -70,6 +69,31 @@ function saveState(path: string, state: StateFile): void {
renameSync(tmp, path); // atomic replace renameSync(tmp, path); // atomic replace
} }
const QUERY_ATTEMPTS = 2;
const QUERY_RETRY_DELAY_MS = 2_000;
async function checkWallet(
provider: ReadOnlyOgmiosProvider,
w: WalletEntry,
minLovelace: bigint,
): Promise<CheckedWallet> {
let lastError = "unknown error";
for (let attempt = 1; attempt <= QUERY_ATTEMPTS; attempt++) {
try {
const utxos = await provider.queryUtxosByAddress(w.address);
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
return { name: w.name, address: w.address, minLovelace, lovelace, error: null };
} catch (e) {
lastError = e instanceof Error ? e.message : String(e);
if (attempt < QUERY_ATTEMPTS) {
await new Promise((r) => setTimeout(r, QUERY_RETRY_DELAY_MS));
}
}
}
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${lastError}`);
return { name: w.name, address: w.address, minLovelace, lovelace: null, error: lastError };
}
// ── main ──────────────────────────────────────────────────────────────── // ── main ────────────────────────────────────────────────────────────────
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -94,24 +118,16 @@ async function main(): Promise<void> {
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown, JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
); );
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x; const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
const reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR;
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl }); const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
const checked: CheckedWallet[] = await Promise.all( // Query SEQUENTIALLY, not Promise.all: the public Ogmios endpoint only
walletsFile.wallets.map(async (w: WalletEntry): Promise<CheckedWallet> => { // serves a few concurrent ledger-state queries and 500s the rest, so a
const minLovelace = ap3xToLovelace(w.minAp3x ?? defaultMinAp3x); // burst over the full wallet list reliably fails several wallets.
try { const checked: CheckedWallet[] = [];
const utxos = await provider.queryUtxosByAddress(w.address); for (const w of walletsFile.wallets) {
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n); checked.push(await checkWallet(provider, w, ap3xToLovelace(w.minAp3x ?? defaultMinAp3x)));
return { name: w.name, address: w.address, minLovelace, lovelace, error: null }; }
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${error}`);
return { name: w.name, address: w.address, minLovelace, lovelace: null, error };
}
}),
);
const anyQueryFailed = checked.some((c) => c.error !== null); const anyQueryFailed = checked.some((c) => c.error !== null);
@@ -119,11 +135,11 @@ async function main(): Promise<void> {
const now = Date.now(); const now = Date.now();
const nextState: StateFile = { ...prevState }; const nextState: StateFile = { ...prevState };
// Classify each successfully-queried wallet, advancing dedup state. The // Classify each successfully-queried wallet. The decision to POST is driven
// decision to POST is driven only by transitions (a newly-low wallet, a low // only by state CHANGES (a wallet newly dropping low, or recovering) — a
// wallet past its reminder window, or a recovery). But when we DO post, the // wallet that stays low never re-posts. But when we DO post, the message
// message lists EVERY monitored wallet for full context (built below). // lists EVERY monitored wallet for full context (built below).
let anyDueLow = false; let anyNewlyLow = false;
let anyRecovered = false; let anyRecovered = false;
const recoveredNames = new Set<string>(); const recoveredNames = new Set<string>();
@@ -136,14 +152,12 @@ async function main(): Promise<void> {
const isLow = c.lovelace < c.minLovelace; const isLow = c.lovelace < c.minLovelace;
if (isLow) { if (isLow) {
const wasLow = prev?.status === "low"; if (prev?.status === "low") {
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs; // Still low — already alerted when it dropped; stay silent.
if (due) { nextState[c.name] = prev;
anyDueLow = true;
nextState[c.name] = { status: "low", lastAlertMs: now };
} else { } else {
// Still low but the reminder window hasn't elapsed — keep prev as-is. anyNewlyLow = true;
nextState[c.name] = prev ?? { status: "low", lastAlertMs: now }; nextState[c.name] = { status: "low", lastAlertMs: now };
} }
} else { } else {
if (prev?.status === "low") { if (prev?.status === "low") {
@@ -154,7 +168,7 @@ async function main(): Promise<void> {
} }
} }
const needPost = anyDueLow || anyRecovered; const needPost = anyNewlyLow || anyRecovered;
if (needPost) { if (needPost) {
const anyLow = checked.some( const anyLow = checked.some(
@@ -200,12 +214,12 @@ async function main(): Promise<void> {
).length; ).length;
console.log( console.log(
lowCount > 0 lowCount > 0
? `[wallet-monitor] ${lowCount} wallet(s) low but already alerted within the reminder window — no Slack post` ? `[wallet-monitor] ${lowCount} wallet(s) still low (already alerted) — no change, no Slack post`
: "[wallet-monitor] all wallets healthy — no Slack post", : "[wallet-monitor] all wallets healthy — no Slack post",
); );
} }
// Persist the new state (records ok/low statuses + advanced reminder stamps). // Persist the new state (records ok/low statuses + alert stamps).
// Only reached when there was nothing to post or the post succeeded. // Only reached when there was nothing to post or the post succeeded.
saveState(cfg.statePath, nextState); saveState(cfg.statePath, nextState);

View File

@@ -1,6 +1,5 @@
{ {
"default_min_ap3x": 10, "default_min_ap3x": 10,
"reminder_hours": 6,
"wallets": [ "wallets": [
{ "name": "buyer", "address": "addr1_REPLACE_ME" }, { "name": "buyer", "address": "addr1_REPLACE_ME" },
{ "name": "supplier-ollama", "address": "addr1_REPLACE_ME" }, { "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },