Compare commits
29 Commits
feat/huggi
...
feat/mainn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
304bbc62ad | ||
|
|
312f3e730e | ||
|
|
6156900fc2 | ||
|
|
7d348e762f | ||
|
|
7bf3252cdb | ||
|
|
feabd12876 | ||
|
|
2b2295b7ed | ||
|
|
069edcb076 | ||
|
|
d5349057b0 | ||
|
|
678ee862ea | ||
|
|
c391b98af8 | ||
|
|
6ca7f2927a | ||
|
|
4d94aa5040 | ||
|
|
36c5cfc6be | ||
|
|
a54fdcb24c | ||
|
|
926300dee5 | ||
|
|
73700f983a | ||
|
|
2aa1688a17 | ||
|
|
3ae2a827c4 | ||
|
|
fec0ac8d0a | ||
|
|
be9febffb1 | ||
|
|
e5793031f8 | ||
|
|
716a8bf2ef | ||
|
|
b10706cf69 | ||
|
|
1f587867c6 | ||
|
|
0b253c73bb | ||
|
|
1648edeba7 | ||
|
|
4090e064cb | ||
|
|
3fac04b00e |
69
.github/workflows/deploy-mainnet.yml
vendored
Normal file
69
.github/workflows/deploy-mainnet.yml
vendored
Normal 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
|
||||||
|
"
|
||||||
37
README.md
37
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
178
deploy/mainnet/deploy.sh
Executable 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal 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
|
||||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal 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
|
||||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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.
@@ -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
|
||||||
|
|||||||
@@ -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]`.
|
||||||
|
|||||||
@@ -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.
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(/\/+$/, ""))
|
||||||
|
|||||||
@@ -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
402
gateway/src/ocr/extract.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -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
572
gateway/test/ocr.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
257
packages/shared/src/tx/escrow/postOcrEscrow.ts
Normal file
257
packages/shared/src/tx/escrow/postOcrEscrow.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
7
patches/libsodium-wrappers-sumo@0.7.16.patch
Normal file
7
patches/libsodium-wrappers-sumo@0.7.16.patch
Normal file
File diff suppressed because one or more lines are too long
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ packages:
|
|||||||
- "indexer"
|
- "indexer"
|
||||||
- "indexer-ui"
|
- "indexer-ui"
|
||||||
- "wallet-monitor"
|
- "wallet-monitor"
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
|
|||||||
83
supplier/.env.chandra.example
Normal file
83
supplier/.env.chandra.example
Normal 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).
|
||||||
@@ -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
|
||||||
|
|||||||
21
supplier/chandra-detail.example.json
Normal file
21
supplier/chandra-detail.example.json
Normal 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)"
|
||||||
|
}
|
||||||
@@ -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
249
supplier/src/datalabOcr.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
271
supplier/src/ocrVision.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e356219020019ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaafb4279667e84f00000d87980
|
d8798d581cabcdef0123456789abcdef0123456789abcdef0123456789abcdef01546c6c6d2e746578742e67656e65726174652e76314c7177656e322e353a302e35621b00000000000002001b000000000000ea601b00000000001e84801b00000000000f42401b00000000000f4240581f68747470733a2f2f737570706c6965722e6578616d706c652e636f6d2f76315835697066733a2f2f516d597741504a7a7635435a736e4136323573335866326e656d7459675070486457457a37396f6a576e506264475820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1b0000019667e84f00d87980
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
34
tests/fixtures/supplier-side/sample-config.ts
vendored
34
tests/fixtures/supplier-side/sample-config.ts
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
217
tests/unit/supplier-datalab-ocr.test.ts
Normal file
217
tests/unit/supplier-datalab-ocr.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
164
tests/unit/supplier-ocr-vision.test.ts
Normal file
164
tests/unit/supplier-ocr-vision.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
176
tests/unit/supplier-routes-ocr.test.ts
Normal file
176
tests/unit/supplier-routes-ocr.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
84
tests/unit/tx-minada-floor.test.ts
Normal file
84
tests/unit/tx-minada-floor.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
200
tests/unit/tx-post-ocr-escrow.test.ts
Normal file
200
tests/unit/tx-post-ocr-escrow.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user