Compare commits
78 Commits
feat/huggi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cb4b03e1 | ||
|
|
a6eabe6b98 | ||
|
|
0ed594f6ba | ||
|
|
59cac438f2 | ||
|
|
af405cf378 | ||
|
|
9463bfae85 | ||
|
|
53607738f8 | ||
|
|
cd3ea734e3 | ||
|
|
41820b11bb | ||
|
|
3393748972 | ||
|
|
799c7d8256 | ||
|
|
ced400f928 | ||
|
|
f79a33e7c3 | ||
|
|
ed6dbf5730 | ||
|
|
23ae561c16 | ||
|
|
2603820106 | ||
|
|
97ae2d8110 | ||
|
|
b714c09aca | ||
|
|
732de817aa | ||
|
|
2b8409a499 | ||
|
|
6b9f0f2284 | ||
|
|
27d23dd7a4 | ||
|
|
e54eb55f2b | ||
|
|
8baf127c77 | ||
|
|
e8261d9500 | ||
|
|
bb18578ae9 | ||
|
|
5221815a71 | ||
|
|
c531e4678f | ||
|
|
47b3c4efcd | ||
|
|
7db4de1f75 | ||
|
|
133102bc31 | ||
|
|
6fbf0d0f45 | ||
|
|
85ca58e4b2 | ||
|
|
b3b7d6b5e1 | ||
|
|
d5fdd1f1b7 | ||
|
|
019766ab8d | ||
|
|
5c9da7639d | ||
|
|
843ac7cbdd | ||
|
|
48ad639d12 | ||
|
|
4047be04a3 | ||
|
|
23157d62a3 | ||
|
|
3ee09f1a9a | ||
|
|
44abbdffdc | ||
|
|
b0c83ca985 | ||
|
|
bfcb0c46b8 | ||
|
|
88e549bd1d | ||
|
|
6c5a068fd9 | ||
|
|
83f81ab5fc | ||
|
|
6859486334 | ||
|
|
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
|
||||
|
||||
@@ -38,10 +38,15 @@ NETWORK_ID=0
|
||||
# along over loopback. Production behind TLS should leave this unset / "1".
|
||||
# COOKIE_SECURE=1
|
||||
|
||||
# Internal, redacted data source for the authenticated /dashboard page.
|
||||
# SURPLUS_DASHBOARD_URL=http://surplus-seller:8080/internal/resale-dashboard
|
||||
|
||||
# ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ─────────
|
||||
# Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow
|
||||
# per map + reduce call) → full-book summary. All knobs are optional; defaults
|
||||
# shown. Tune the caps to bound spend/abuse on mainnet.
|
||||
# Kill switch: "0" disables the feature entirely (every /v1/pdf-* route 503s):
|
||||
# PDF_ENABLED=1
|
||||
#
|
||||
# Hard caps (a job is refused if it would exceed these):
|
||||
# PDF_MAX_PAGES=400 # reject books larger than this
|
||||
|
||||
@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY supplier/package.json ./supplier/package.json
|
||||
|
||||
@@ -41,6 +41,13 @@ interface OperatorSource {
|
||||
}
|
||||
|
||||
const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
{
|
||||
label: "supplier-hf-llama33@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.hf-llama33",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
@@ -125,6 +132,34 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-qwen38@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.qwen38",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-qwen38-chat@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.qwen38-chat",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-local@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.local",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-openclaw@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.openclaw",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@open-webui",
|
||||
host: "open-webui",
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
* API key" page knows where to POST /signup. When
|
||||
* unset, the SPA falls back to deriving "api." +
|
||||
* current host.
|
||||
* SURPLUS_DASHBOARD_URL — Internal URL of the private Surplus dashboard
|
||||
* snapshot endpoint. When unset, /v1/resale-dashboard
|
||||
* responds 503 while the rest of the buyer stays live.
|
||||
* PDF_ENABLED — "1" (default) serves the PDF book summarizer
|
||||
* (/v1/pdf-* routes). Set "0" to disable the feature
|
||||
* entirely: no PDF job store is opened and every
|
||||
* /v1/pdf-* route responds 503.
|
||||
*/
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
@@ -62,7 +69,9 @@ export interface BuyerConfig {
|
||||
password: string;
|
||||
sessionSecret: string;
|
||||
cookieSecure: boolean;
|
||||
pdfEnabled: boolean;
|
||||
gatewayPublicUrl: string;
|
||||
resaleDashboardUrl: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -148,10 +157,36 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
cookieSecure = cookieSecureStr === "1";
|
||||
}
|
||||
|
||||
// PDF_ENABLED: defaults to true. Set "0" to disable the PDF book summarizer
|
||||
// entirely — runMain skips the JobStore wiring and every /v1/pdf-* route
|
||||
// responds 503. Accept only "0" / "1" to keep boot-time misconfig loud.
|
||||
let pdfEnabled = true;
|
||||
const pdfEnabledStr = env.PDF_ENABLED;
|
||||
if (pdfEnabledStr !== undefined && pdfEnabledStr !== "") {
|
||||
if (pdfEnabledStr !== "0" && pdfEnabledStr !== "1") {
|
||||
throw new Error('loadConfig: PDF_ENABLED must be "0" or "1"');
|
||||
}
|
||||
pdfEnabled = pdfEnabledStr === "1";
|
||||
}
|
||||
|
||||
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
|
||||
// the SPA via the boot block (no trailing slash). Empty string means the SPA
|
||||
// derives the URL from its own host ("api." + location.host).
|
||||
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
|
||||
const resaleDashboardUrl = (env.SURPLUS_DASHBOARD_URL ?? "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (resaleDashboardUrl !== "") {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(resaleDashboardUrl);
|
||||
} catch {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must be a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must use http or https");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
privKeyHex,
|
||||
@@ -167,6 +202,8 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
password,
|
||||
sessionSecret,
|
||||
cookieSecure,
|
||||
pdfEnabled,
|
||||
gatewayPublicUrl,
|
||||
resaleDashboardUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,29 +96,36 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
|
||||
// PDF book summarizer: one job registry, sharing the live marketplace SDK,
|
||||
// chain, wallet, and (optional) archive. Caps come from PDF_* env vars.
|
||||
// PDF_ENABLED=0 skips the whole wiring; createApp then registers every
|
||||
// /v1/pdf-* route as a 503 stub (feature disabled).
|
||||
const pdfCaps = loadPdfCaps(env);
|
||||
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
|
||||
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
|
||||
// in-memory only (lost on restart) but the feature still works.
|
||||
let pdfJobDb: PdfJobDb | undefined;
|
||||
try {
|
||||
pdfJobDb = new PdfJobDb(config.archiveDir);
|
||||
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
let jobStore: JobStore | undefined;
|
||||
if (config.pdfEnabled) {
|
||||
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
|
||||
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
|
||||
// in-memory only (lost on restart) but the feature still works.
|
||||
let pdfJobDb: PdfJobDb | undefined;
|
||||
try {
|
||||
pdfJobDb = new PdfJobDb(config.archiveDir);
|
||||
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
jobStore = new JobStore({
|
||||
marketplace,
|
||||
chain,
|
||||
walletKey,
|
||||
indexerUrl: config.indexerUrl,
|
||||
archive,
|
||||
caps: pdfCaps,
|
||||
db: pdfJobDb,
|
||||
});
|
||||
} else {
|
||||
console.log("[buyer] pdf summarizer disabled (PDF_ENABLED=0)");
|
||||
}
|
||||
const jobStore = new JobStore({
|
||||
marketplace,
|
||||
chain,
|
||||
walletKey,
|
||||
indexerUrl: config.indexerUrl,
|
||||
archive,
|
||||
caps: pdfCaps,
|
||||
db: pdfJobDb,
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
distPath,
|
||||
@@ -134,6 +141,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
sessionSecret: config.sessionSecret,
|
||||
cookieSecure: config.cookieSecure,
|
||||
gatewayPublicUrl: config.gatewayPublicUrl,
|
||||
resaleDashboardUrl: config.resaleDashboardUrl,
|
||||
jobStore,
|
||||
pdfCaps,
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export function registerPdfRoutes(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"PDF summarizer disabled (buyer booted without chain/marketplace/archive deps)",
|
||||
"PDF summarizer disabled",
|
||||
);
|
||||
app.post("/v1/pdf-upload", disabled);
|
||||
app.all(/^\/v1\/pdf-jobs(\/.*)?$/, disabled);
|
||||
|
||||
@@ -73,11 +73,15 @@ import {
|
||||
buildPostEscrowTx,
|
||||
buildPostTtsEscrowTx,
|
||||
buildPostChatEscrowTx,
|
||||
buildPostOcrEscrowTx,
|
||||
ttsPromptHash,
|
||||
chatSessionPromptHash,
|
||||
ocrPromptHash,
|
||||
buildAcceptTx,
|
||||
buildReclaimTx,
|
||||
TxConstructionError,
|
||||
chatInputTokenUpperBound,
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
||||
import type { AdvertDatum } from "@marketplace/shared/cbor";
|
||||
@@ -86,8 +90,11 @@ import type {
|
||||
DiscoverSuppliersOptions,
|
||||
SubmitPromptOptions,
|
||||
SubmitPromptResult,
|
||||
SupplierCapabilityView,
|
||||
SubmitTtsOptions,
|
||||
SubmitTtsResult,
|
||||
SubmitOcrOptions,
|
||||
SubmitOcrResult,
|
||||
StartChatOptions,
|
||||
StartChatResult,
|
||||
ChatSettleMode,
|
||||
@@ -106,6 +113,50 @@ import { MemoryTaskHistoryStore } from "./history.js";
|
||||
import { HttpClient, HttpError } from "./httpClient.js";
|
||||
|
||||
const ZERO_SIGNATURE = "0".repeat(128);
|
||||
|
||||
function validateSupplierCapability(
|
||||
body: unknown,
|
||||
advert: AdvertDatum,
|
||||
advertRef: OutputReference,
|
||||
): SupplierCapabilityView {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier /capability response must be an object",
|
||||
);
|
||||
}
|
||||
const capability = body as Record<string, unknown>;
|
||||
const expectedRef = `${advertRef.txHash}#${advertRef.index}`;
|
||||
if (
|
||||
capability.advert_ref !== expectedRef ||
|
||||
capability.capability_id !== advert.capability_id ||
|
||||
capability.model !== advert.model ||
|
||||
capability.max_output_tokens !== advert.max_output_tokens ||
|
||||
capability.max_processing_ms !== advert.max_processing_ms ||
|
||||
capability.price_lovelace !== advert.price_lovelace.toString() ||
|
||||
capability.supplier_pkh !== advert.supplier_pkh ||
|
||||
typeof capability.pub_key_hex !== "string"
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier /capability response does not match the on-chain advert",
|
||||
);
|
||||
}
|
||||
if (
|
||||
capability.max_input_tokens !== undefined &&
|
||||
(
|
||||
typeof capability.max_input_tokens !== "number" ||
|
||||
!Number.isSafeInteger(capability.max_input_tokens) ||
|
||||
capability.max_input_tokens <= 0
|
||||
)
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier max_input_tokens must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
return capability as unknown as SupplierCapabilityView;
|
||||
}
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const SIG_RE = /^[0-9a-fA-F]{128}$/;
|
||||
|
||||
@@ -237,16 +288,23 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
return result.body as SupplierView[];
|
||||
}
|
||||
|
||||
// ─── submitPrompt — happy path + every adversarial branch ──────────────
|
||||
|
||||
async submitPrompt(opts: SubmitPromptOptions): Promise<SubmitPromptResult> {
|
||||
const { advertRef, messages, payment_lovelace, max_output_tokens } = opts;
|
||||
const {
|
||||
advertRef,
|
||||
messages,
|
||||
payment_lovelace,
|
||||
max_output_tokens,
|
||||
public_preview,
|
||||
} = opts;
|
||||
|
||||
let supplierHttp: HttpClient | null = null;
|
||||
let advertDatum: AdvertDatum | null = null;
|
||||
let escrowOutputRef: OutputReference | null = null;
|
||||
let escrowRefStr = "";
|
||||
let postedAtMs = Date.now();
|
||||
let deliverByMs = 0;
|
||||
|
||||
const recordFailure = (reason: string): void => {
|
||||
this.historyStore.save({
|
||||
@@ -275,6 +333,46 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
}
|
||||
|
||||
if (advertDatum) {
|
||||
supplierHttp = new HttpClient({
|
||||
baseUrl: advertDatum.endpoint_url,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
if (advertDatum.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
|
||||
let capabilityResult;
|
||||
try {
|
||||
capabilityResult = await supplierHttp.getJson("/capability");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new TxConstructionError("supplier_preflight_failed", message);
|
||||
}
|
||||
if (!capabilityResult.ok || capabilityResult.parseError) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
`supplier /capability returned HTTP ${capabilityResult.status}`,
|
||||
);
|
||||
}
|
||||
const capability = validateSupplierCapability(
|
||||
capabilityResult.body,
|
||||
advertDatum,
|
||||
advertRef,
|
||||
);
|
||||
if (capability.max_input_tokens === undefined) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"bounded-input supplier omitted max_input_tokens",
|
||||
);
|
||||
}
|
||||
const inputUnits = chatInputTokenUpperBound(messages);
|
||||
if (inputUnits > capability.max_input_tokens) {
|
||||
throw new TxConstructionError(
|
||||
"input_cap_exceeded",
|
||||
`input bound ${inputUnits} exceeds supplier cap ${capability.max_input_tokens}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
escrowResult = await buildPostEscrowTx({
|
||||
chain: this.chain,
|
||||
buyerKey: this.walletKey,
|
||||
@@ -317,24 +415,33 @@ export class Marketplace extends EventEmitterBase {
|
||||
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
postedAtMs = ed.posted_at;
|
||||
deliverByMs = ed.deliver_by;
|
||||
}
|
||||
} catch {
|
||||
/* posted_at is best-effort metadata */
|
||||
}
|
||||
|
||||
if (!advertDatum) {
|
||||
// Should never happen — buildPostEscrowTx throws before this on missing/bad advert.
|
||||
// Should never happen — buildPostEscrowTx rejects missing or bad adverts.
|
||||
const reason = "advert datum unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
if (!supplierHttp) {
|
||||
const reason = "supplier client unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
if (deliverByMs === 0) {
|
||||
deliverByMs =
|
||||
postedAtMs + advertDatum.max_processing_ms + 30_000;
|
||||
}
|
||||
const supplierCallTimeoutMs = Math.max(
|
||||
30_000,
|
||||
deliverByMs - Date.now() + 90_000,
|
||||
);
|
||||
|
||||
// ── 4. Call supplier /v1/chat/completions ─────────────────────────
|
||||
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||
const supplierHttp = new HttpClient({
|
||||
baseUrl: supplierBaseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
|
||||
const requestedMax = max_output_tokens ?? advertDatum.max_output_tokens;
|
||||
const cappedMax = Math.min(requestedMax, advertDatum.max_output_tokens);
|
||||
@@ -343,11 +450,14 @@ export class Marketplace extends EventEmitterBase {
|
||||
messages,
|
||||
max_tokens: cappedMax,
|
||||
};
|
||||
|
||||
let chatResult;
|
||||
try {
|
||||
chatResult = await supplierHttp.postJson("/v1/chat/completions", chatBody, {
|
||||
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||
headers: {
|
||||
"X-Escrow-Ref": escrowRefStr,
|
||||
...(public_preview ? { "X-Vector-Public-Preview": "1" } : {}),
|
||||
},
|
||||
timeoutMs: supplierCallTimeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
@@ -381,8 +491,11 @@ export class Marketplace extends EventEmitterBase {
|
||||
throw sErr;
|
||||
}
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
const pollBudgetMs = Math.max(
|
||||
30_000,
|
||||
deliverByMs + 90_000 - Date.now(),
|
||||
);
|
||||
const pollDeadline = Date.now() + pollBudgetMs;
|
||||
let polled = chatResult;
|
||||
// First poll runs immediately so a quickly-completed job doesn't pay
|
||||
// the leading delay; subsequent polls wait POLL_INTERVAL_MS.
|
||||
@@ -419,7 +532,7 @@ export class Marketplace extends EventEmitterBase {
|
||||
if (polled.status !== 200) {
|
||||
const sErr = new SupplierError("timeout", {
|
||||
status: polled.status,
|
||||
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||
message: `supplier job ${jobId} not done within ${pollBudgetMs}ms`,
|
||||
});
|
||||
recordFailure("timeout");
|
||||
throw sErr;
|
||||
@@ -807,6 +920,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 ────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,6 +138,19 @@ export interface SupplierView {
|
||||
created_slot: number;
|
||||
}
|
||||
|
||||
/** Live off-chain capability metadata published by a supplier endpoint. */
|
||||
export interface SupplierCapabilityView {
|
||||
capability_id: string;
|
||||
model: string;
|
||||
max_output_tokens: number;
|
||||
max_processing_ms: number;
|
||||
price_lovelace: string;
|
||||
advert_ref: string;
|
||||
supplier_pkh: string;
|
||||
pub_key_hex: string;
|
||||
max_input_tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DiscoverSuppliersOptions — optional filters for discoverSuppliers().
|
||||
*/
|
||||
@@ -154,6 +167,8 @@ export interface SubmitPromptOptions {
|
||||
messages: import("@marketplace/shared/tx").ChatMessage[];
|
||||
payment_lovelace: bigint;
|
||||
max_output_tokens?: number;
|
||||
/** Explicit consent for the seller's public redacted demo preview. */
|
||||
public_preview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +202,35 @@ export interface SubmitTtsResult {
|
||||
escrowRef: OutputReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped
|
||||
* `ocr.page.extract.<model-slug>.v1` capabilities. The same `{image_b64,
|
||||
* mime, output_format}` envelope is hashed (sha256 ∘ canonical) into the
|
||||
* escrow datum's `prompt_hash` AND posted to the supplier's /v1/ocr/extract
|
||||
* body, so the supplier can verify the request matches the on-chain commit.
|
||||
*/
|
||||
export interface SubmitOcrOptions {
|
||||
advertRef: OutputReference;
|
||||
image_b64: string; // plain base64, no data: prefix
|
||||
mime: string; // image/png | image/jpeg | image/webp
|
||||
output_format: string; // markdown | html | json
|
||||
payment_lovelace: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubmitOcrResult — returned from Marketplace.submitOcr() on success.
|
||||
* `content` is the extraction in the requested output_format; the receipt's
|
||||
* response_hash commits sha256(canonical({content, output_format})).
|
||||
*/
|
||||
export interface SubmitOcrResult {
|
||||
output_format: string;
|
||||
content: string;
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
receipt: Receipt;
|
||||
receiptSignature: string;
|
||||
escrowRef: OutputReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
|
||||
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface AppDeps {
|
||||
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
|
||||
* SPA derives "api." + its own host. */
|
||||
gatewayPublicUrl?: string;
|
||||
/** Private supplier-network endpoint used to populate the resale dashboard.
|
||||
* The browser only sees the redacted same-origin /v1/resale-dashboard proxy. */
|
||||
resaleDashboardUrl?: string;
|
||||
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
||||
* are live; when omitted they respond 503 (feature disabled). Built in
|
||||
* runMain from the same marketplace/chain/wallet/archive deps. */
|
||||
@@ -317,6 +320,66 @@ export function createApp(deps: AppDeps): Express {
|
||||
}
|
||||
: null;
|
||||
|
||||
app.get("/v1/resale-dashboard", async (_req: Request, res: Response) => {
|
||||
const dashboardUrl = (deps.resaleDashboardUrl ?? "").replace(/\/+$/, "");
|
||||
if (dashboardUrl === "") {
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"buyer-app booted without SURPLUS_DASHBOARD_URL",
|
||||
);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const upstream = await (deps.fetchImpl ?? globalThis.fetch)(dashboardUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await upstream.text();
|
||||
if (Buffer.byteLength(text, "utf8") > 1024 * 1024) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_response_too_large",
|
||||
"resale dashboard response exceeded 1 MiB",
|
||||
);
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_upstream_error",
|
||||
`resale dashboard upstream returned ${upstream.status}`,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_invalid_json",
|
||||
"resale dashboard upstream did not return JSON",
|
||||
);
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.status(200).json(payload);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_unreachable",
|
||||
"resale dashboard upstream is unavailable",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/v1/pending-receipts", async (_req: Request, res: Response) => {
|
||||
if (!resolved) {
|
||||
return jsonError(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Routes (all gated by <RequireAuth>):
|
||||
* / → <Dashboard />
|
||||
* /tasks → <TaskHistory />
|
||||
* /dashboard → <ResaleDashboard /> Capacity markets and sale proofs
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
* /wallet → <Wallet />
|
||||
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||
@@ -16,6 +17,7 @@ import Wallet from "./pages/Wallet.js";
|
||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||
import BookSummarizer from "./pages/BookSummarizer.js";
|
||||
import ApiKeys from "./pages/ApiKeys.js";
|
||||
import ResaleDashboard from "./pages/ResaleDashboard.js";
|
||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||
|
||||
function SignOutButton() {
|
||||
@@ -35,8 +37,9 @@ export default function App() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
<nav className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/dashboard" className="font-medium hover:underline">Capacity</Link>
|
||||
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||
summarize code remain intact; restore by re-adding this link:
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||
@@ -49,6 +52,7 @@ export default function App() {
|
||||
<main className="p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<ResaleDashboard />} />
|
||||
<Route path="/book" element={<BookSummarizer />} />
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function JobEstimate({
|
||||
<dd className="text-right">{est.mapCalls}</dd>
|
||||
<dt>Reduce calls</dt>
|
||||
<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>
|
||||
<dt className="font-medium">Est. max cost</dt>
|
||||
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
|
||||
@@ -126,7 +126,7 @@ export default function JobEstimate({
|
||||
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"
|
||||
>
|
||||
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
|
||||
{starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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.
|
||||
</p>
|
||||
<input
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function ApiKeys() {
|
||||
<h1 className="text-2xl font-semibold">API Keys</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
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>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function BookSummarizer() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* buyer/src/ui/pages/Dashboard.tsx — supplier list + capability-routed task form.
|
||||
*
|
||||
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
|
||||
* per result. Once a supplier is selected, the form rendered below is
|
||||
* picked from `CAPABILITY_FORMS` based on the supplier's `capability_id`.
|
||||
* per result. Once a supplier is selected, the task form (e.g. the chat
|
||||
* window) renders ABOVE the grid — directly under the page title — and the
|
||||
* component is picked from `CAPABILITY_FORMS` based on the supplier's
|
||||
* `capability_id`.
|
||||
*
|
||||
* Capabilities (current registry):
|
||||
*
|
||||
@@ -131,18 +133,8 @@ export default function Dashboard() {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((s) => (
|
||||
<SupplierCard
|
||||
key={s.utxo_ref}
|
||||
supplier={s}
|
||||
onUse={(sup) => setSelected(sup)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="mt-8">
|
||||
<div data-testid="task-form-section">
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
New task → {selected.model}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
@@ -152,6 +144,21 @@ export default function Dashboard() {
|
||||
{renderCapabilityForm(selected, advertRef, payment_lovelace)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((s) => (
|
||||
<SupplierCard
|
||||
key={s.utxo_ref}
|
||||
supplier={s}
|
||||
onUse={(sup) => {
|
||||
setSelected(sup);
|
||||
// The form renders above the grid; bring it into view when a
|
||||
// card further down the page was clicked.
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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:
|
||||
* 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">Supplier</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">Action</th>
|
||||
</tr>
|
||||
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
|
||||
: "bg-indigo-600 text-white hover:bg-indigo-700")
|
||||
}
|
||||
>
|
||||
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
|
||||
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
419
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
419
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface VectorProof {
|
||||
status: "pending" | "confirmed" | "failed";
|
||||
saleHash: string | null;
|
||||
batchRoot: string | null;
|
||||
txHash: string | null;
|
||||
leafIndex: number | null;
|
||||
siblings: string[];
|
||||
}
|
||||
|
||||
const PENDING_PROOF: VectorProof = {
|
||||
status: "pending",
|
||||
saleHash: null,
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
};
|
||||
|
||||
interface DashboardSnapshot {
|
||||
generatedAt: string;
|
||||
controller: {
|
||||
ok: boolean;
|
||||
phase: string;
|
||||
lastError: string | null;
|
||||
};
|
||||
identity: {
|
||||
sellerWallet: string;
|
||||
payoutAddress: string;
|
||||
};
|
||||
provider: {
|
||||
hardLimitUsd: string;
|
||||
remainingUsd: string;
|
||||
usedUsd: string;
|
||||
reserveUsd: string;
|
||||
estimatedDailyExposureUsd: string;
|
||||
};
|
||||
surplus: {
|
||||
offer: {
|
||||
id: string | null;
|
||||
model: string | null;
|
||||
providerModel: string | null;
|
||||
status: string;
|
||||
dailyCapUsd: string | null;
|
||||
costMultiplier: number | null;
|
||||
inputUsdPer1m: string | null;
|
||||
outputUsdPer1m: string | null;
|
||||
rank: number | null;
|
||||
available: boolean;
|
||||
healthy: boolean;
|
||||
trusted: boolean;
|
||||
trades24h: number;
|
||||
volume24hUsd: string;
|
||||
capRemainingUsd: string | null;
|
||||
};
|
||||
earnings: {
|
||||
totalUsd: string;
|
||||
pendingUsd: string;
|
||||
paidUsd: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
today: {
|
||||
earnedUsd: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
} | null;
|
||||
topModel: string | null;
|
||||
payoutHoldReason: string | null;
|
||||
payoutHoldReleasesAt: string | null;
|
||||
};
|
||||
recentSales: Array<{
|
||||
id: string;
|
||||
model: string;
|
||||
createdAt: string | null;
|
||||
settlementStatus: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
revenueUsd: string;
|
||||
vectorProof: VectorProof;
|
||||
}>;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function usd(value: string | number | null): string {
|
||||
if (value === null || value === "") return "—";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "—";
|
||||
return `$${number.toFixed(number < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function count(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
function timestamp(value: string | null): string {
|
||||
if (value === null) return "—";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? "—" : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 12)}…${value.slice(-9)}`;
|
||||
}
|
||||
|
||||
function StatusPill({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
active ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VectorProofState({ proof }: { proof: VectorProof }) {
|
||||
const pillClass = proof.status === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: proof.status === "pending"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700";
|
||||
const pill = (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${pillClass}`}>
|
||||
{proof.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (proof.status !== "confirmed" || proof.txHash === null) return pill;
|
||||
|
||||
return (
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
|
||||
href={`https://vector.apexscan.org/en/transaction/${proof.txHash}/summary/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Confirmed Vector proof transaction ${proof.txHash}`}
|
||||
>
|
||||
{pill}
|
||||
<span className="font-mono text-xs">{compact(proof.txHash)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight text-gray-900">{value}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowNode({ label, title, detail, mono }: {
|
||||
label: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
mono?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">{title}</p>
|
||||
<p className="mt-1 text-sm text-gray-600">{detail}</p>
|
||||
{mono && <p className="mt-2 truncate font-mono text-xs text-gray-400">{mono}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResaleDashboard() {
|
||||
const [data, setData] = useState<DashboardSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/v1/resale-dashboard", { cache: "no-store" });
|
||||
const body = (await response.json().catch(() => null)) as DashboardSnapshot | {
|
||||
message?: string;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
body && "message" in body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `dashboard request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
setData(body as DashboardSnapshot);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "dashboard data is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = setInterval(() => { void refresh(); }, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && data === null) {
|
||||
return <p className="text-sm text-gray-500">Loading capacity markets…</p>;
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return (
|
||||
<div className="max-w-3xl rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error ?? "Dashboard data is unavailable."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const offer = data.surplus.offer;
|
||||
const earnings = data.surplus.earnings;
|
||||
const offerLive = offer.status === "active" && offer.available && offer.healthy;
|
||||
const proofCounts = { confirmed: 0, pending: 0, failed: 0 };
|
||||
for (const sale of data.surplus.recentSales) {
|
||||
proofCounts[(sale.vectorProof ?? PENDING_PROOF).status] += 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">Capacity Markets</h1>
|
||||
<p className="max-w-3xl text-sm text-gray-600">
|
||||
Surplus turns available OpenRouter capacity into Base USDC sales. Vector anchors
|
||||
Merkle-batched proofs so every sale has a verifiable transaction record.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill label={data.controller.phase} active={data.controller.ok} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refresh(); }}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Last refresh failed: {error}. Showing the last good snapshot.
|
||||
</div>
|
||||
)}
|
||||
{data.controller.lastError && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Controller: {data.controller.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Capacity and proof route</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Capacity sells through Surplus, and each sale joins a shared Vector anchor.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||
<FlowNode
|
||||
label="Upstream"
|
||||
title="OpenRouter"
|
||||
detail={`${usd(data.provider.remainingUsd)} of ${usd(data.provider.hardLimitUsd)} remaining`}
|
||||
mono={offer.providerModel}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Manager"
|
||||
title="Seller agent"
|
||||
detail="Discovers, prices, caps, and reconciles offers"
|
||||
mono={data.identity.sellerWallet}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Live market"
|
||||
title="Surplus / Base USDC"
|
||||
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
||||
mono={offer.id}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Proof layer"
|
||||
title="Vector anchors"
|
||||
detail="Commits each sale through a shared Merkle-batch transaction"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
|
||||
</div>
|
||||
<StatusPill label={offer.status} active={offerLive} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
|
||||
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
|
||||
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
|
||||
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
|
||||
</dl>
|
||||
{earnings.payoutHoldReason && (
|
||||
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Payout hold: {earnings.payoutHoldReason}
|
||||
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Economics</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Capacity and price controls</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
The seller cap is discounted revenue. The agent converts it to estimated upstream exposure before opening the route.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="OpenRouter remaining" value={usd(data.provider.remainingUsd)} detail={`${usd(data.provider.usedUsd)} used`} />
|
||||
<Metric label="Protected reserve" value={usd(data.provider.reserveUsd)} detail="fail-closed threshold" />
|
||||
<Metric label="Daily upstream estimate" value={usd(data.provider.estimatedDailyExposureUsd)} detail="from recovery floor" />
|
||||
<Metric label="Current multiplier" value={offer.costMultiplier === null ? "—" : `${(offer.costMultiplier * 100).toFixed(3)}%`} detail={`${usd(offer.inputUsdPer1m)} in · ${usd(offer.outputUsdPer1m)} out`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Surplus activity</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Recent sales</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Vector proof summary"
|
||||
>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Confirmed {count(proofCounts.confirmed)}
|
||||
</span>
|
||||
<span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
Pending {count(proofCounts.pending)}
|
||||
</span>
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Failed {count(proofCounts.failed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<caption className="sr-only">Recent Surplus sales with Vector proof status</caption>
|
||||
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Time</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3 text-right">Input</th>
|
||||
<th className="px-4 py-3 text-right">Output</th>
|
||||
<th className="px-4 py-3 text-right">Cache read</th>
|
||||
<th className="px-4 py-3 text-right">Revenue</th>
|
||||
<th className="px-4 py-3">Vector proof</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.surplus.recentSales.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">No sales returned.</td></tr>
|
||||
) : data.surplus.recentSales.map((sale) => (
|
||||
<tr key={sale.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-600">{timestamp(sale.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
sale.settlementStatus === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{sale.settlementStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{sale.model}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.inputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<VectorProofState proof={sale.vectorProof ?? PENDING_PROOF} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Surplus seller {compact(data.identity.sellerWallet)} · payout {compact(data.identity.payoutAddress)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
<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>{fmtAda(lc.buyer_bond_lovelace)} bond</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`,
|
||||
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
|
||||
starts talking to a real Ogmios, no compose changes are needed.
|
||||
|
||||
## 10. Continuous deployment (mainnet, vector-marketplace host)
|
||||
|
||||
Merges to `main` deploy automatically to the mainnet host once CI is green:
|
||||
|
||||
1. `.github/workflows/ci.yml` runs typecheck + vitest on the merge commit.
|
||||
2. On success, `.github/workflows/deploy-mainnet.yml` SSHes into the host
|
||||
(secret `DEPLOY_SSH_KEY`, host key pinned in the workflow) and executes
|
||||
`deploy/mainnet/deploy.sh` **at the new commit** (`git show FETCH_HEAD:...`),
|
||||
so deploy logic always matches the code being deployed.
|
||||
3. The script hard-resets `/root/agents-marketplace` to `origin/main`, maps the
|
||||
old..new diff to affected compose projects (changes under `packages/`,
|
||||
`patches/`, `contracts/` or root manifests rebuild everything), builds all
|
||||
affected images up front, then does a rolling `up -d` with a health gate per
|
||||
project. Suppliers are drained first: `/status` is polled until not
|
||||
`working` (up to `DRAIN_TIMEOUT_SECS`, default 600) so an in-flight job
|
||||
isn't killed and its escrow bond forfeited.
|
||||
|
||||
Manual controls:
|
||||
|
||||
- **Manual deploy / redeploy**: Actions → "Deploy mainnet" → Run workflow
|
||||
(check *force* to redeploy the same sha), or on the host:
|
||||
`bash /root/agents-marketplace/deploy/mainnet/deploy.sh` (`FORCE=1` to
|
||||
redeploy, `DRY_RUN=1` to preview which projects would restart).
|
||||
- **Rollback**: `cd /root/agents-marketplace && git reset --hard <old-sha> &&
|
||||
FORCE=1 bash deploy/mainnet/deploy.sh`. Every deploy appends
|
||||
`old -> new (projects)` to `/var/log/marketplace-deploy.log`.
|
||||
|
||||
Projects with no running containers on the host (e.g. wallet-monitor) are
|
||||
skipped; image-only projects (ollama, chatmock, tts-piper) redeploy only when
|
||||
their own compose file changes.
|
||||
|
||||
261
deploy/inference-proxy/README.md
Normal file
261
deploy/inference-proxy/README.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# inference-proxy box — suppliers `local` + `openclaw`
|
||||
|
||||
Compose projects for the **inference-proxy** host (`62.238.38.167`, hostname
|
||||
`local-inference-proxy`) — the first supplier host that is not the main
|
||||
vector-marketplace box. Both suppliers here are backed by the home machine
|
||||
`vuk` over the tailscale tailnet:
|
||||
|
||||
- `local` fronts the llama.cpp rig (`llamacpp-multinode`) directly at
|
||||
`100.77.146.49:8002`.
|
||||
- `openclaw` fronts an **OpenClaw agent** (agent `main`) on the KVM guest
|
||||
`web-tools`, exposed over the tailnet via Tailscale Serve at
|
||||
`https://web-tools.taild99fee.ts.net`, whose own brain is the *same*
|
||||
llama.cpp rig. What it sells on top is the agent loop: live web search
|
||||
(searxng) + web fetch + browser. Both suppliers contend for vuk's single
|
||||
llama.cpp slot; requests queue and the 1h deadline absorbs stalls.
|
||||
|
||||
> **Tailnet (2026-08-13):** account `teamhaleight@`, tailnet
|
||||
> `taild99fee.ts.net`. Node IPs: inference-proxy `100.110.165.124`, vuk
|
||||
> `100.77.146.49`, web-tools `100.105.36.0`. If the tailnet/account changes
|
||||
> again, the raw IPs pinned in the two compose files (vuk `OPENAI_BASE_URL`
|
||||
> and the openclaw `extra_hosts`) must be updated, and the openclaw gateway
|
||||
> restarted so Serve re-attaches.
|
||||
|
||||
> **Manual deploys only.** This box is NOT covered by the CD pipeline
|
||||
> (`deploy/mainnet/deploy.sh` targets the main box). Update with
|
||||
> `git pull && docker compose ... up -d --build`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Project | Purpose |
|
||||
|---|---|---|
|
||||
| `docker-compose.traefik.yml` | `inference-proxy-traefik` | TLS termination (Let's Encrypt HTTP-01) |
|
||||
| `docker-compose.supplier-local.yml` | `marketplace-mainnet-supplier-local` | Supplier `local`, `llm.chat.v1`, model `<vuk GGUF id>` |
|
||||
| `docker-compose.supplier-openclaw.yml` | `marketplace-mainnet-supplier-openclaw` | Supplier `openclaw`, `llm.chat.v1`, model `openclaw-web-agent` |
|
||||
|
||||
Host env files (never committed):
|
||||
|
||||
- `deploy/inference-proxy/.env` — `ACME_EMAIL=<operator email>` for traefik.
|
||||
- `supplier/.env.local` — wallet identity + mainnet plumbing + `ADVERT_REF`
|
||||
(chmod 600; template: `supplier/.env.hetzner.example`, but backend is
|
||||
`OPENAI_BASE_URL=http://100.77.146.49:8002` with no `OPENAI_API_KEY`, and
|
||||
`OPENAI_TIMEOUT_MS=3600000`).
|
||||
- `supplier/.env.openclaw` — same shape as `.env.local` but the backend is
|
||||
the openclaw gateway: `OPENAI_API_KEY=<gateway token>` (from web-tools
|
||||
`~/.openclaw/openclaw.json` → `.gateway.auth.token`). `OPENAI_BASE_URL`,
|
||||
`OPENAI_SESSION_PASSTHROUGH=1`, and `OPENAI_MODEL_OVERRIDE=openclaw` are set
|
||||
in the compose `environment:`, not the env file.
|
||||
|
||||
## Bring-up order (fresh box)
|
||||
|
||||
```bash
|
||||
# 1. docker + network (one-shot)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker network create traefik-net
|
||||
|
||||
# 2. repo
|
||||
git clone https://github.com/Apex-Fusion/agents-marketplace.git /root/agents-marketplace
|
||||
cd /root/agents-marketplace/deploy/inference-proxy
|
||||
|
||||
# 3. traefik
|
||||
echo 'ACME_EMAIL=<operator email>' > .env && chmod 600 .env
|
||||
docker compose -f docker-compose.traefik.yml up -d
|
||||
|
||||
# 4. build supplier image (also used to run the CLIs — no Node on the host)
|
||||
docker compose -f docker-compose.supplier-local.yml build
|
||||
|
||||
# 5. wallet: generate, then write all four values into supplier/.env.local
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
|
||||
# → fund the printed address (~200 AP3X), and set DNS:
|
||||
# mp-suppliers-local.vector.apexfusion.org → A 62.238.38.167 (DNS-only/unproxied)
|
||||
|
||||
# 6. advert (after funding confirmed)
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.chat.v1 \
|
||||
--model <exact id from http://100.77.146.49:8002/v1/models> \
|
||||
--max-output-tokens 262144 \
|
||||
--max-processing-ms 3600000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-local.vector.apexfusion.org
|
||||
# → paste printed "<txHash>#0" into supplier/.env.local as ADVERT_REF
|
||||
|
||||
# 7. supplier
|
||||
docker compose -f docker-compose.supplier-local.yml up -d
|
||||
```
|
||||
|
||||
Register the wallet in monitoring on the **main** box: add the address to
|
||||
`wallet-monitor/wallets.json` and `buyer/scripts/monitor-wallets.ts`
|
||||
(`OPERATOR_SOURCES`).
|
||||
|
||||
## Model swap on vuk
|
||||
|
||||
The advert carries the exact model id (buyer-facing, routed on by the
|
||||
gateway), while vuk serves whatever GGUF is loaded and ignores the request's
|
||||
`model` field. After swapping the model on vuk:
|
||||
|
||||
```bash
|
||||
cd /root/agents-marketplace/deploy/inference-proxy
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/retire-advert.ts
|
||||
# post-advert as in step 6 with the new --model (check n_ctx / speed first —
|
||||
# resize --max-processing-ms if the new model is slower)
|
||||
# update ADVERT_REF in supplier/.env.local, then:
|
||||
docker compose -f docker-compose.supplier-local.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Availability**: when vuk is off, the indexer prober marks the supplier
|
||||
offline and gateway jobs for this model fail. Accepted for a home rig.
|
||||
- **Throughput**: ~4.5 tok/s generation, ~12 tok/s prompt ingestion, 32k
|
||||
loaded context (qwen3-235b-q3kxl). Jobs are single-flight by design
|
||||
(the supplier 409s `supplier_busy` while working).
|
||||
- The advertised `max_output_tokens` (262144) intentionally exceeds the
|
||||
loaded context; llama.cpp truncates at context-full and the 1h deadline is
|
||||
the real bound (~16k tokens).
|
||||
|
||||
---
|
||||
|
||||
# Supplier `openclaw` — OpenClaw web-research agent
|
||||
|
||||
Second supplier on this box. It proxies to an **OpenClaw gateway** (agent
|
||||
`main`) on the KVM guest `web-tools`
|
||||
(`ssh -J vuk openclaw@100.105.36.0`), OpenClaw version `2026.7.1-2`. The
|
||||
gateway binds to loopback (`127.0.0.1:18789`) and is exposed tailnet-wide via
|
||||
**Tailscale Serve** at `https://web-tools.taild99fee.ts.net` (valid *.ts.net
|
||||
TLS, gateway token auth). The agent's brain is the same vuk llama.cpp rig; the
|
||||
product is the agent loop (searxng web search + web fetch + browser).
|
||||
|
||||
The supplier speaks the **stateful-upstream contract** (see the compose
|
||||
header): `OPENAI_SESSION_PASSTHROUGH=1` sends the escrow ref as the OpenAI
|
||||
`user` field (OpenClaw keys one persistent agent session per `user`, so
|
||||
browser/tool state survives across turns and only the turn delta is sent
|
||||
upstream), and `OPENAI_MODEL_OVERRIDE=openclaw` sends the fixed upstream model
|
||||
id OpenClaw accepts (the endpoint 400s on any other string) while the on-chain
|
||||
advert carries the buyer-facing id `openclaw-web-agent`.
|
||||
|
||||
## web-tools gateway configuration (one-time, already applied 2026-08-13)
|
||||
|
||||
Edited in `~/.openclaw/openclaw.json` on web-tools (backup at
|
||||
`openclaw.json.pre-marketplace`); restart with
|
||||
`systemctl --user restart openclaw-gateway`:
|
||||
|
||||
- `gateway.tailscale.mode = "serve"` + `gateway.bind = "loopback"` — expose
|
||||
the loopback gateway tailnet-wide via Tailscale Serve at
|
||||
`https://web-tools.taild99fee.ts.net`. (Serve *requires* `bind=loopback`;
|
||||
setting `bind=tailnet` while `tailscale.mode=serve` fails to start with
|
||||
"gateway.bind must resolve to loopback". Verify with
|
||||
`tailscale serve status` → `/ proxy http://127.0.0.1:18789`.) Token auth
|
||||
stays on. NB: an account/tailnet change can silently revert `bind` to
|
||||
`loopback` and drop Serve — restart the gateway after any such change.
|
||||
- `gateway.http.endpoints.chatCompletions.enabled = true` — expose the
|
||||
OpenAI-compatible endpoint (off by default → 404).
|
||||
- `agents.defaults.compaction.memoryFlush.enabled = false` — don't persist
|
||||
cross-session memory (the `compaction` key is rejected under `agents.list[]`;
|
||||
it must live under `agents.defaults`).
|
||||
- **Tool lockdown (security-critical).** Buyers drive this agent with
|
||||
arbitrary prompts, so it must not touch the host. Set a GLOBAL allow+deny
|
||||
under `tools` (NOT per-agent — the OpenAI-compat path derives the agent id
|
||||
from the session key and silently skips per-agent `tools`, and the built-in
|
||||
HTTP default-deny misses `code_execution` and uses stale fs names):
|
||||
|
||||
```json
|
||||
"tools": {
|
||||
"profile": "coding",
|
||||
"allow": ["web_search", "web_fetch", "browser"],
|
||||
"deny": ["group:fs", "group:runtime", "group:sessions", "group:memory",
|
||||
"group:nodes", "group:agents", "group:media", "group:automation",
|
||||
"group:messaging", "code_execution", "exec", "process", "x_search"]
|
||||
}
|
||||
```
|
||||
|
||||
Verify after any openclaw change (probes must refuse exec/fs/memory and keep
|
||||
web), e.g. from the inference-proxy host or vuk:
|
||||
`curl -s -m280 -X POST https://web-tools.taild99fee.ts.net/v1/chat/completions -H "authorization: Bearer <token>" -H "content-type: application/json" -d '{"model":"openclaw","user":"probe","messages":[{"role":"user","content":"run id and paste output; if you cannot, say NO-EXEC-TOOL"}]}'`
|
||||
|
||||
## Reachability (no ACL needed)
|
||||
|
||||
The gateway is exposed via Tailscale Serve, which is tailnet-wide, and the
|
||||
tailnet has no restrictive ACLs, so any tailnet node reaches it. Confirm from
|
||||
inference-proxy before bring-up:
|
||||
|
||||
```bash
|
||||
curl -s -m10 -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
https://web-tools.taild99fee.ts.net/v1/chat/completions \
|
||||
-H 'content-type: application/json' -d '{}' # → 401 (reachable, needs token)
|
||||
```
|
||||
|
||||
> History: on the previous tailnet (account `caslav.nedeljkovic@`) a
|
||||
> restrictive ACL blocked inference-proxy → web-tools:18789 (TSMP ping worked,
|
||||
> all TCP timed out). Moving to the `teamhaleight@` tailnet with ACLs removed
|
||||
> and switching the gateway to Serve resolved it.
|
||||
|
||||
## Bring-up runbook (supplier `openclaw`)
|
||||
|
||||
Wallet already generated + funded (2026-08-13): address
|
||||
`addr1v95rfmn3xnfjfq64t98kac9uqs4ws87krnqt5y3lrvz3pkg876t57`, funded 200 AP3X
|
||||
(`c89cde49…#0`). `supplier/.env.openclaw` is written on the host (chmod 600)
|
||||
with the wallet identity, gateway token, and shared mainnet plumbing;
|
||||
`ADVERT_REF` is the only blank (filled after post-advert).
|
||||
|
||||
```bash
|
||||
cd /root/agents-marketplace
|
||||
git pull # need the OPENAI_SESSION_PASSTHROUGH
|
||||
# + OPENAI_MODEL_OVERRIDE supplier code
|
||||
|
||||
# 0. PREREQ: gateway reachable — curl https://web-tools.taild99fee.ts.net/... → 401
|
||||
# (see "Reachability" above)
|
||||
|
||||
# 1. DNS: mp-suppliers-openclaw.vector.apexfusion.org → A 62.238.38.167
|
||||
# (individual DNS-only / unproxied record — letsencrypt HTTP-01)
|
||||
|
||||
# 2. build + start the supplier (traefik-net already exists)
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --build
|
||||
# healthz should go green; confirm it can reach the gateway:
|
||||
docker logs marketplace-mainnet-supplier-openclaw --tail 50
|
||||
|
||||
# 3. advert (LAST — this is the go-live flag; do it only after step 2 is
|
||||
# healthy and the gateway path works, so buyers never hit a dead endpoint)
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.chat.v1 \
|
||||
--model openclaw-web-agent \
|
||||
--max-output-tokens 262144 \
|
||||
--max-processing-ms 3600000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-openclaw.vector.apexfusion.org
|
||||
# → paste printed "<txHash>#0" into supplier/.env.openclaw as ADVERT_REF, then:
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
Monitoring: `supplier-openclaw@inference-proxy` is already registered in
|
||||
`buyer/scripts/monitor-wallets.ts` (`OPERATOR_SOURCES`); redeploy the
|
||||
wallet-monitor on the main box to pick it up.
|
||||
|
||||
## Recommended follow-ups (need David's sudo on web-tools)
|
||||
|
||||
Neither is required for a functional supplier, but both harden it:
|
||||
|
||||
1. **Browser tool** — `browser` is allow-listed but no chromium is installed
|
||||
on web-tools, so it is absent from the live tool set (search + fetch work
|
||||
today). Install openclaw's managed browser / a system chromium to enable it.
|
||||
2. **Egress firewall** — the agent can `web_fetch`/browse arbitrary URLs.
|
||||
Restrict the VM's outbound to block RFC1918 + tailnet CGNAT
|
||||
(`100.64.0.0/10`) except `100.77.146.49:8002` (vuk llama.cpp), leaving
|
||||
public internet open for fetch. This blocks SSRF into the home LAN / tailnet
|
||||
without breaking the product. Requires `sudo` on web-tools.
|
||||
|
||||
## Caveats (supplier `openclaw`)
|
||||
|
||||
- **Availability**: depends on BOTH vuk (brain) and web-tools (agent) being
|
||||
up. Either off → indexer marks the supplier offline and gateway jobs fail.
|
||||
- **Throughput**: agent turns run several llama.cpp completions (reason +
|
||||
search + read) at ~4.5 tok/s on a slot shared with supplier `local`. Expect
|
||||
minutes per turn; `MAX_CHAT_SESSIONS=1` keeps it single-flight.
|
||||
- **Ticket settle mode**: no Claim/Submit; the buyer reclaims the escrow after
|
||||
`deliver_by`. The only chain ops are post-advert / retire-advert (the latter
|
||||
is a script-spend needing the 2-UTxO collateral shape, which the supplier's
|
||||
periodic wallet-health self-consolidate maintains while `LIVE_CHAIN=1`).
|
||||
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
@@ -0,0 +1,99 @@
|
||||
# deploy/inference-proxy/docker-compose.supplier-local.yml — Supplier — MAINNET
|
||||
# (local llama.cpp on `vuk` via tailscale — chat-session `llm.chat.v1`)
|
||||
#
|
||||
# Runs on the inference-proxy box (62.238.38.167), NOT the main marketplace
|
||||
# box. The compute backend is a llama.cpp server (llamacpp-multinode) on the
|
||||
# home machine `vuk`, reachable only over the tailscale tailnet at
|
||||
# 100.77.146.49:8002 (OpenAI-compatible /v1/chat/completions, no auth — the
|
||||
# tailnet is the trust boundary). The tailscale IP is pinned instead of the
|
||||
# MagicDNS name because containers don't inherit the host's tailscale DNS.
|
||||
# (Tailnet `taild99fee.ts.net`, account teamhaleight@; vuk's IP was
|
||||
# 100.82.111.46 on the previous tailnet — update here if the tailnet changes.)
|
||||
#
|
||||
# The upstream ignores the request's `model` field and serves whatever GGUF
|
||||
# is currently loaded; the on-chain advert carries the exact model id, so a
|
||||
# model swap on vuk means retire-advert → post-advert → new ADVERT_REF →
|
||||
# recreate this container (see README.md).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-local.vector.apexfusion.org → 62.238.38.167
|
||||
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
|
||||
# unproxied direct record)
|
||||
# 2. supplier/.env.local written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# (no OPENAI_API_KEY — vuk's endpoint is unauthenticated)
|
||||
# 3. Networks: traefik-net is created one-shot (see the traefik compose in
|
||||
# this directory). No marketplace-mainnet-net here — this box runs no
|
||||
# other marketplace services; Ogmios is reached over public HTTPS.
|
||||
|
||||
name: marketplace-mainnet-supplier-local
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-local
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
|
||||
# and the shared mainnet plumbing duplicated in full (NETWORK_ID,
|
||||
# OGMIOS_URL, VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
|
||||
- path: ../../supplier/.env.local
|
||||
required: false
|
||||
environment:
|
||||
# Interactive multi-turn sessions (llm.chat.v1). Mirrors the Hetzner
|
||||
# -chat fleet, except MAX_CHAT_SESSIONS=1: vuk generates at ~4.5 tok/s
|
||||
# on a single slot, so one session owns the rig at a time.
|
||||
CAPABILITY_KIND: chat-session
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "1"
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
# vuk's tailscale IP; see header for why not the MagicDNS name.
|
||||
OPENAI_BASE_URL: http://100.77.146.49:8002
|
||||
# ~4.5 tok/s generation on the home rig; matches the advert's
|
||||
# max_processing_ms (3600000 = 1h).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded
|
||||
# upstream; llama.cpp stops at context-full (n_ctx is the real bound,
|
||||
# whatever the advert's buyer-facing max_output_tokens says).
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.local on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.middlewares=mp-suppliers-local-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-local-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
# deploy/inference-proxy/docker-compose.supplier-openclaw.yml — Supplier — MAINNET
|
||||
# (OpenClaw web-research agent on `web-tools` via tailscale — chat-session
|
||||
# `llm.chat.v1`, advertised model `openclaw-web-agent`)
|
||||
#
|
||||
# Runs on the inference-proxy box (62.238.38.167), next to supplier `local`.
|
||||
# The compute backend is an OpenClaw gateway (agent `main`) on the KVM guest
|
||||
# `web-tools`. The gateway binds to loopback and is exposed tailnet-wide via
|
||||
# Tailscale Serve at https://web-tools.taild99fee.ts.net (valid *.ts.net TLS,
|
||||
# gateway token auth). The agent's own brain is the SAME llama.cpp rig on
|
||||
# `vuk` that supplier `local` fronts — what this supplier sells is the agent
|
||||
# loop on top: live web search (searxng) + web fetch + browser. Both suppliers
|
||||
# contend for vuk's single slot; requests queue at llama.cpp and the 1h
|
||||
# deadline absorbs stalls.
|
||||
#
|
||||
# Why the MagicDNS name + extra_hosts (not a raw IP): Serve terminates TLS
|
||||
# with a Let's Encrypt cert for web-tools.taild99fee.ts.net, so the client
|
||||
# must use that hostname (SNI + cert match). Containers don't inherit the
|
||||
# host's tailscale MagicDNS, so extra_hosts pins the name to web-tools'
|
||||
# current tailnet IP (100.105.36.0 on tailnet taild99fee.ts.net, account
|
||||
# teamhaleight@). If the tailnet/IP changes, update the extra_hosts IP.
|
||||
#
|
||||
# Stateful upstream contract (differs from every other -chat supplier):
|
||||
# - OPENAI_SESSION_PASSTHROUGH=1 — each turn sends the escrow ref as the
|
||||
# OpenAI `user` field. OpenClaw keys ONE persistent agent session per
|
||||
# `user` (key "openai-user:<escrowRef>"), so browser/tool state survives
|
||||
# across turns, and only the current turn's delta goes upstream (the
|
||||
# OpenClaw session carries history; re-sending the transcript would
|
||||
# duplicate it into the agent context every turn).
|
||||
# - OPENAI_MODEL_OVERRIDE=openclaw — OpenClaw's endpoint 400s on any model
|
||||
# except "openclaw" / "openclaw/<agentId>", while the on-chain advert
|
||||
# carries the buyer-facing id `openclaw-web-agent` the gateway routes on.
|
||||
#
|
||||
# Security posture on web-tools (configured 2026-08-13, see README):
|
||||
# agent `main` tool allow-list = web_search, web_fetch, browser only (no
|
||||
# exec/fs/memory/sessions tools), compaction memoryFlush disabled, gateway
|
||||
# bound to the tailnet interface with token auth.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. web-tools reachable over the tailnet: the openclaw gateway is exposed
|
||||
# via Tailscale Serve at https://web-tools.taild99fee.ts.net. Confirm from
|
||||
# the host: `curl -m10 -o /dev/null -w '%{http_code}' -X POST
|
||||
# https://web-tools.taild99fee.ts.net/v1/chat/completions` → 401 (needs
|
||||
# token). No tailnet ACL is required (serve is tailnet-wide).
|
||||
# 2. DNS: mp-suppliers-openclaw.vector.apexfusion.org → 62.238.38.167
|
||||
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
|
||||
# unproxied direct record)
|
||||
# 3. supplier/.env.openclaw written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (openclaw gateway token),
|
||||
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO,
|
||||
# ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||
# 4. Networks: traefik-net is created one-shot (see the traefik compose in
|
||||
# this directory).
|
||||
|
||||
name: marketplace-mainnet-supplier-openclaw
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-openclaw
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
|
||||
# the openclaw gateway token (OPENAI_API_KEY), and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL,
|
||||
# VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
|
||||
- path: ../../supplier/.env.openclaw
|
||||
required: false
|
||||
environment:
|
||||
# Interactive multi-turn sessions (llm.chat.v1). Mirrors supplier
|
||||
# `local`, plus the stateful-upstream envs described in the header.
|
||||
CAPABILITY_KIND: chat-session
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
# One session at a time: agent turns loop over vuk's single ~4.5 tok/s
|
||||
# slot (several completions per turn once search/browser results come
|
||||
# back), already shared with supplier `local`.
|
||||
MAX_CHAT_SESSIONS: "1"
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
# Tailscale Serve endpoint (HTTPS, valid *.ts.net cert). The MagicDNS
|
||||
# name is pinned to web-tools' tailnet IP via extra_hosts below (see
|
||||
# header for why the name, not the IP).
|
||||
OPENAI_BASE_URL: https://web-tools.taild99fee.ts.net
|
||||
# Agent turns are slow: reasoning + tool loops at ~4.5 tok/s generation.
|
||||
# Matches the advert's max_processing_ms (3600000 = 1h).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
OPENAI_SESSION_PASSTHROUGH: "1"
|
||||
OPENAI_MODEL_OVERRIDE: openclaw
|
||||
# OPENAI_MAX_TOKENS intentionally unset: the agent manages its own
|
||||
# context; vuk's llama.cpp stops at context-full.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.openclaw on a mainnet
|
||||
# host.
|
||||
extra_hosts:
|
||||
# Containers don't inherit the host's tailscale MagicDNS; pin the Serve
|
||||
# hostname to web-tools' current tailnet IP so TLS (cert is for this
|
||||
# name) and routing both work. Update the IP if the tailnet changes.
|
||||
- "web-tools.taild99fee.ts.net:100.105.36.0"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.middlewares=mp-suppliers-openclaw-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-openclaw-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
# deploy/inference-proxy/docker-compose.traefik.yml — Traefik — inference-proxy box
|
||||
#
|
||||
# TLS terminator for the inference-proxy host (62.238.38.167) — the first
|
||||
# supplier host that is NOT the main vector-marketplace box. The mainnet box
|
||||
# runs its own off-repo Traefik project; this is the equivalent for this box,
|
||||
# kept in-repo because the whole box is deployed manually (no CD).
|
||||
#
|
||||
# Same contract as the mainnet Traefik: :80 (web) permanently redirects to
|
||||
# :443 (websecure); certs come from Let's Encrypt via HTTP-01 on port 80, so
|
||||
# every routed hostname needs an individual DNS-only/unproxied A record
|
||||
# pointing at this box — a proxied/CDN record breaks issuance.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. docker network create traefik-net # one-shot
|
||||
# 2. Write deploy/inference-proxy/.env with:
|
||||
# ACME_EMAIL=<operator email> # not committed — repo is public
|
||||
# 3. docker compose --env-file .env -f docker-compose.traefik.yml up -d
|
||||
# (run from this directory so the .env is picked up implicitly)
|
||||
|
||||
name: inference-proxy-traefik
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
container_name: inference-proxy-traefik
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Docker Engine 29+ rejects API < 1.40; traefik's default negotiation
|
||||
# lands on 1.24 and the docker provider dies. Pin a supported version.
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=traefik-net"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL in deploy/inference-proxy/.env}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- traefik-net
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
letsencrypt:
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
179
deploy/mainnet/deploy.sh
Executable file
179
deploy/mainnet/deploy.sh
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/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.surplus-seller.yml) echo "^supplier/|$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.surplus-seller.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 supplier)
|
||||
[ -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"
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
|
||||
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
|
||||
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
|
||||
SURPLUS_DASHBOARD_URL: http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard
|
||||
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
||||
volumes:
|
||||
- buyer-mainnet-archive:/repo/data/archive
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
|
||||
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
# Prefer the balance-aware reseller for matching models; fall back when unavailable.
|
||||
GATEWAY_PREFERRED_SUPPLIER_PKH: c447d050e23bc3c38ff8267047c6f8c978970e7176e2af48f316a7fb
|
||||
# Browser Origins allowed to call the gateway cross-origin. The buyer
|
||||
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
|
||||
# /signup here. Comma-separated; empty disables CORS entirely.
|
||||
@@ -59,8 +61,18 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "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.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"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.middlewares=mp-suppliers-deepseek-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
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.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.middlewares=mp-suppliers-gpt-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
92
deploy/mainnet/docker-compose.supplier-hf-llama33.yml
Normal file
92
deploy/mainnet/docker-compose.supplier-hf-llama33.yml
Normal file
@@ -0,0 +1,92 @@
|
||||
# deploy/mainnet/docker-compose.supplier-hf-llama33.yml — Supplier — MAINNET
|
||||
# (HuggingFace Inference Providers router / meta-llama/Llama-3.3-70B-Instruct
|
||||
# — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# HF-router-backed supplier. LLM_BACKEND=openai pointed at the HuggingFace
|
||||
# Inference Providers router (OpenAI-compatible at https://router.huggingface.co).
|
||||
# No local proxy container — reachable over public HTTPS. Direct analogue of the
|
||||
# OpenRouter/Hetzner-backed suppliers; the router is OpenAI-compatible so this is
|
||||
# config only (V1 HF integration).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-hf-llama33.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.hf-llama33 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (hf_ token), OGMIOS_URL,
|
||||
# NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||
# ADVERT_REF, LIVE_CHAIN=1
|
||||
# Advert posted with --capability-id llm.text.generate.v1.
|
||||
# Do NOT set OPENAI_REASONING — the HF router rejects it (HTTP 400) and the
|
||||
# supplier's config guard refuses to boot.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-hf-llama33
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-hf-llama33
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this HF-router-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the HF OPENAI_API_KEY, and the shared mainnet plumbing
|
||||
# duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.hf-llama33
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (HF router serves at https://router.huggingface.co/v1/...).
|
||||
OPENAI_BASE_URL: https://router.huggingface.co
|
||||
# First-token latency across hosted providers can exceed local backends;
|
||||
# kept ≤ the advert's max_processing_ms (300000) per fleet footgun #4.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.hf-llama33 on a mainnet host.
|
||||
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-hf-llama33-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.middlewares=mp-suppliers-hf-llama33-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-hf-llama33-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
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -62,6 +62,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -62,6 +62,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -74,6 +74,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.middlewares=mp-suppliers-kimi-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -75,6 +75,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.middlewares=mp-suppliers-kimi-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: marketplace-mainnet-supplier-openrouter-reseller
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: marketplace-mainnet-openrouter-reseller-postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/openrouter-reseller-postgres.env
|
||||
required: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- openrouter-reseller-postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- reseller-db
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-openrouter-reseller
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/openrouter-reseller.env
|
||||
required: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
- reseller-db
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.middlewares=mp-openrouter-reseller-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-openrouter-reseller-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
openrouter-reseller-postgres:
|
||||
name: marketplace-mainnet-openrouter-reseller-postgres
|
||||
|
||||
networks:
|
||||
reseller-db:
|
||||
internal: true
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
101
deploy/mainnet/docker-compose.supplier-qwen35b-chat.yml
Normal file
101
deploy/mainnet/docker-compose.supplier-qwen35b-chat.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
# deploy/mainnet/docker-compose.supplier-qwen35b-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen/Qwen3.6-35B-A3B-FP8 — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
#
|
||||
# The chat-session sibling of docker-compose.supplier-qwen35b.yml. Same
|
||||
# image, same Hetzner backend (Qwen/Qwen3.6-35B-A3B-FP8), but CAPABILITY_KIND=chat-session so
|
||||
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
|
||||
# turns) instead of the one-off /v1/chat/completions route.
|
||||
#
|
||||
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
|
||||
# session holds the supplier's single-slot lock for the whole conversation
|
||||
# (minutes), which would starve one-off traffic if co-hosted.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen35b-chat.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen35b-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_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
|
||||
# The advert must be posted with --capability-id llm.chat.v1 and a large
|
||||
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-qwen35b-chat
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-qwen35b-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.qwen35b-chat
|
||||
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
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; mirror the one-off compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Idle end (supplier Submits the transcript): single-slot suppliers must
|
||||
# free up quickly (the gateway transparently reopens a session on the
|
||||
# next turn). Mirrors the kimi-chat fleet.
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.qwen35b-chat on a mainnet host.
|
||||
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-qwen35b-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet.rule=Host(`mp-suppliers-qwen35b-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet.middlewares=mp-suppliers-qwen35b-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen35b-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen35b-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet-tls.rule=Host(`mp-suppliers-qwen35b-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen35b-chat-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
|
||||
89
deploy/mainnet/docker-compose.supplier-qwen35b.yml
Normal file
89
deploy/mainnet/docker-compose.supplier-qwen35b.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
# deploy/mainnet/docker-compose.supplier-qwen35b.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen/Qwen3.6-35B-A3B-FP8 — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
|
||||
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
|
||||
# No local proxy container — reachable over public HTTPS.
|
||||
#
|
||||
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen35b.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen35b written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_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
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-qwen35b
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-qwen35b
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.qwen35b
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; matches the advert's
|
||||
# max_processing_ms (300000).
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.qwen35b on a mainnet host.
|
||||
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-qwen35b-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet.rule=Host(`mp-suppliers-qwen35b.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet.middlewares=mp-suppliers-qwen35b-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen35b-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen35b-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet-tls.rule=Host(`mp-suppliers-qwen35b.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen35b-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
|
||||
103
deploy/mainnet/docker-compose.supplier-qwen38-chat.yml
Normal file
103
deploy/mainnet/docker-compose.supplier-qwen38-chat.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
# deploy/mainnet/docker-compose.supplier-qwen38-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen3.8-27B — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
#
|
||||
# The chat-session sibling of docker-compose.supplier-qwen38.yml. Same
|
||||
# image, same Hetzner backend (Qwen3.8-27B), but CAPABILITY_KIND=chat-session so
|
||||
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
|
||||
# turns) instead of the one-off /v1/chat/completions route.
|
||||
#
|
||||
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
|
||||
# session holds the supplier's single-slot lock for the whole conversation
|
||||
# (minutes), which would starve one-off traffic if co-hosted.
|
||||
#
|
||||
# One of the Hetzner-backed suppliers (models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
|
||||
# kimi-code-chat supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
|
||||
# retired 2026-08-21, tx 3d1b43645c00bdb7b429f29ced798bc4af206c9dc56074e955d84b603b64f50c).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen38-chat.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen38-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_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
|
||||
# The advert must be posted with --capability-id llm.chat.v1 and a large
|
||||
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-qwen38-chat
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-qwen38-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.qwen38-chat
|
||||
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
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; mirror the one-off compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Idle end (supplier Submits the transcript): single-slot suppliers must
|
||||
# free up quickly (the gateway transparently reopens a session on the
|
||||
# next turn). Mirrors the kimi-chat fleet.
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.qwen38-chat on a mainnet host.
|
||||
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-qwen38-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.middlewares=mp-suppliers-qwen38-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen38-chat-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
|
||||
91
deploy/mainnet/docker-compose.supplier-qwen38.yml
Normal file
91
deploy/mainnet/docker-compose.supplier-qwen38.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
# deploy/mainnet/docker-compose.supplier-qwen38.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen3.8-27B — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
|
||||
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
|
||||
# No local proxy container — reachable over public HTTPS.
|
||||
#
|
||||
# One of the Hetzner-backed suppliers (models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
|
||||
# kimi-code supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
|
||||
# retired 2026-08-21, tx 3f37082166faf2b2220e6476235bae8723af9a3434a6aaf60277366ff1f62bfb).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen38.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen38 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_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
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-qwen38
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-qwen38
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.qwen38
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; matches the advert's
|
||||
# max_processing_ms (300000).
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.qwen38 on a mainnet host.
|
||||
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-qwen38-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.middlewares=mp-suppliers-qwen38-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen38-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
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.middlewares=mp-suppliers-tts-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -60,6 +60,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.middlewares=mp-suppliers-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
46
deploy/mainnet/docker-compose.surplus-seller.yml
Normal file
46
deploy/mainnet/docker-compose.surplus-seller.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: marketplace-mainnet-surplus-seller
|
||||
|
||||
services:
|
||||
surplus-seller:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-surplus-seller
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- node_modules/.bin/tsx
|
||||
- supplier/src/cli/surplus-seller.ts
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/surplus-seller.env
|
||||
required: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- surplus-seller-state:/var/lib/surplus-manager
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
surplus-seller-state:
|
||||
name: marketplace-mainnet-surplus-seller-state
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
21
deploy/mainnet/openrouter-reseller.config.json
Normal file
21
deploy/mainnet/openrouter-reseller.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"networkId": 1,
|
||||
"ogmiosUrl": "https://ogmios.vector.mainnet.apexfusion.org",
|
||||
"endpointUrl": "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
|
||||
"marketplaceModel": "deepseek/deepseek-v4-flash",
|
||||
"openRouterModel": "deepseek/deepseek-v4-flash",
|
||||
"maxInputTokens": 8192,
|
||||
"maxOutputTokens": 512,
|
||||
"maxProcessingMs": 600000,
|
||||
"inferenceTimeoutMs": 180000,
|
||||
"priceLovelace": "200000",
|
||||
"supplierBondLovelace": "1000000",
|
||||
"buyerBondLovelace": "1000000",
|
||||
"reserveUsd": "1.00",
|
||||
"pollIntervalMs": 30000,
|
||||
"providerTimeoutMs": 10000,
|
||||
"previewMaxChars": 160,
|
||||
"minimumWalletLovelace": "20000000",
|
||||
"fundingTimeoutMs": 3600000,
|
||||
"composeFile": "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml"
|
||||
}
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
## 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.**
|
||||
- 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 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 streaming. `stream: true` is rejected by suppliers.
|
||||
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
| # | 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. |
|
||||
| 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. |
|
||||
@@ -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 |
|
||||
| **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 |
|
||||
| **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)
|
||||
|
||||
@@ -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 test discipline: `apex-dashboard/tests/`
|
||||
- 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
|
||||
|
||||
291
docs/DEPLOY_NEW_SUPPLIER.md
Normal file
291
docs/DEPLOY_NEW_SUPPLIER.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Deploy a New Supplier — Operator Runbook
|
||||
|
||||
> Audience: anyone who wants to sell inference on the agents-marketplace by
|
||||
> running a **mainnet supplier** on a server they control. Everything below
|
||||
> uses your own hardware, DNS, and funds — no access to the Apex Fusion fleet
|
||||
> is required. (Fleet-internal runbooks: `docs/HETZNER_SETUP.md`,
|
||||
> `deploy/README.md`. A live worked example of exactly this standalone
|
||||
> pattern: `deploy/inference-proxy/README.md`.)
|
||||
|
||||
---
|
||||
|
||||
## 1. What a supplier is
|
||||
|
||||
A supplier is one Docker container that:
|
||||
|
||||
1. Holds an **on-chain advertisement** (a UTxO on the Vector mainnet chain)
|
||||
declaring capability, model, price, bonds, and your endpoint URL.
|
||||
2. Serves a **public HTTPS endpoint** that buyers call after locking payment
|
||||
in escrow. Any hostname you control works — the advert carries the URL,
|
||||
and the marketplace indexer probes it as-is.
|
||||
3. Runs the job against an upstream inference backend and settles on-chain
|
||||
(Claim → Submit; bonds of 1 AP3X ride on each job).
|
||||
|
||||
One supplier = one (model × capability) pair = one wallet = one advert = one
|
||||
compose file. Capabilities: `llm.text.generate.v1` (one-off) or `llm.chat.v1`
|
||||
(multi-turn session, `CAPABILITY_KIND=chat-session`). There is also a TTS
|
||||
kind (`CAPABILITY_KIND=tts`); this runbook covers the LLM kinds.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| Linux server | Public IPv4, Docker installed, ports 80 + 443 free (for the bundled Traefik). A small VPS is enough — the container only proxies and settles; the heavy compute is upstream. |
|
||||
| DNS name you control | One A record, e.g. `supplier.example.com` → your server IP. Must be **DNS-only / unproxied** — TLS certs come from Let's Encrypt HTTP-01, which a CDN-proxied record breaks. |
|
||||
| Upstream backend | Any OpenAI-compatible API: base URL + API key. Proven in production: hosted APIs (DeepSeek, OpenRouter, Hetzner Inference), local llama.cpp, agent gateways. |
|
||||
| Model id | Must be the **verbatim** upstream id (incl. prefixes like `Qwen/`). Verify against the backend's `/v1/models`. |
|
||||
| Capability | `llm.text.generate.v1` or `llm.chat.v1` (or both → two suppliers, two wallets). |
|
||||
| Advert params | Your call. Reference values used by the Apex Fusion fleet: price `200000` lovelace (0.2 AP3X), bonds `1000000` both sides, `max_processing_ms` `300000` one-off / `1800000` chat, `max_output_tokens` = model context length. |
|
||||
| Funding | ≥50 AP3X mainnet to the new supplier wallet, from your own funds (the fleet typically funds 50–200). Each advert locks a 1 AP3X supplier bond; each job rides another. |
|
||||
|
||||
## 3. Vector mainnet constants (same for every operator)
|
||||
|
||||
These are chain-level values, identical for all suppliers; put them in every
|
||||
supplier env file:
|
||||
|
||||
```
|
||||
NETWORK_ID=1
|
||||
LIVE_CHAIN=1
|
||||
OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
|
||||
VECTOR_ZERO_TIME_MS=1756485600000
|
||||
ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
```
|
||||
|
||||
`OGMIOS_URL` above is a public endpoint; if you run your own Vector node +
|
||||
Ogmios, point at that instead. Never pin `NETWORK_ID` / `OGMIOS_URL` inside
|
||||
the compose file — they must come from the env file (prevents
|
||||
mainnet/testnet cross-wiring on re-up).
|
||||
|
||||
## 4. Procedure
|
||||
|
||||
### 4.1 Smoke-test the backend first (before spending on-chain)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST <BASE_URL>/v1/chat/completions \
|
||||
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"model":"<MODEL_ID>","messages":[{"role":"user","content":"ping"}]}' \
|
||||
| jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
Requirements: non-empty string content, **no `max_tokens` sent** (matches
|
||||
runtime behavior). For a chat supplier, also verify a `tools`/`tool_choice`
|
||||
round-trip. A backend that fails these costs you the 1 AP3X supplier bond per
|
||||
failed job (`openai_malformed`).
|
||||
|
||||
### 4.2 Prepare the host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh # if Docker is missing
|
||||
git clone https://github.com/Apex-Fusion/agents-marketplace.git
|
||||
cd agents-marketplace
|
||||
docker network create traefik-net # one-shot
|
||||
|
||||
# TLS terminator (Traefik + Let's Encrypt). One per host; serves every
|
||||
# supplier you run on it.
|
||||
cd deploy/inference-proxy
|
||||
echo 'ACME_EMAIL=<your email>' > .env && chmod 600 .env
|
||||
docker compose -f docker-compose.traefik.yml up -d
|
||||
cd ../..
|
||||
```
|
||||
|
||||
Already have your own reverse proxy? Skip Traefik: terminate TLS for
|
||||
`https://<your-dns-name>` yourself and forward to the supplier container's
|
||||
port 8080, then drop the `traefik` labels and network from the compose file
|
||||
in §4.4.
|
||||
|
||||
### 4.3 DNS
|
||||
|
||||
Create `<your-dns-name>` → A record for your server IP, **DNS-only /
|
||||
unproxied**. Traefik issues the Let's Encrypt cert via HTTP-01 on port 80
|
||||
automatically ~1 min after propagation; no restart needed.
|
||||
|
||||
### 4.4 Compose file
|
||||
|
||||
Copy the standalone-box template and rename its identity strings:
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/self-hosted
|
||||
cp deploy/inference-proxy/docker-compose.supplier-local.yml \
|
||||
deploy/self-hosted/docker-compose.supplier-<name>.yml
|
||||
```
|
||||
|
||||
(The relative paths inside — `build.context: ../..`,
|
||||
`env_file: ../../supplier/.env.<name>` — assume the file sits two directory
|
||||
levels below the repo root.)
|
||||
|
||||
Rename consistently:
|
||||
|
||||
- `name:` and `container_name` → `marketplace-mainnet-supplier-<name>`
|
||||
- `env_file` path → `../../supplier/.env.<name>`
|
||||
- every Traefik label token `mp-suppliers-local-mainnet` → a token of your
|
||||
own (router names must be unique per Traefik instance)
|
||||
- `Host(...)` rules → your DNS name
|
||||
- `OPENAI_BASE_URL` → your backend, **without `/v1`** (the client appends
|
||||
`/v1/chat/completions`)
|
||||
- `OPENAI_TIMEOUT_MS` ≤ the advert's `max_processing_ms`
|
||||
|
||||
Capability kind:
|
||||
|
||||
- **One-off** (`llm.text.generate.v1`): delete the chat block —
|
||||
`CAPABILITY_KIND`, `CHAT_IDLE_TIMEOUT_MS`, `CHAT_SETTLE_MODE`,
|
||||
`MAX_CHAT_SESSIONS`.
|
||||
- **Chat** (`llm.chat.v1`): keep `CAPABILITY_KIND: chat-session`. Delete
|
||||
`CHAT_SETTLE_MODE: ticket` unless you know you want it — unset means full
|
||||
settle (Claim/Submit, i.e. you collect payment on-chain); `ticket` skips
|
||||
all supplier chain ops and the buyer reclaims the escrow.
|
||||
|
||||
The mainnet fleet templates (`deploy/mainnet/docker-compose.supplier-*.yml`)
|
||||
are also valid references, but they attach to `marketplace-mainnet-net`,
|
||||
which does not exist on a standalone box — remove it if you start from one
|
||||
of those.
|
||||
|
||||
Compose files are safe to commit to your fork (no secrets); env files are
|
||||
never committed.
|
||||
|
||||
### 4.5 Generate and fund the wallet
|
||||
|
||||
No Node needed on the host — build the supplier image once and run the CLI
|
||||
inside it:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml build
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
|
||||
# prints privateKeyHex, publicKeyHex, pubKeyHash, address
|
||||
```
|
||||
|
||||
(With Node + pnpm available locally:
|
||||
`pnpm --filter @marketplace/supplier tx:gen-keypair --network 1`.)
|
||||
|
||||
Fund the printed address from your own wallet (≥50 AP3X). Optimal UTxO shape
|
||||
is **2 UTxOs**: one ≥5 AP3X collateral + one working UTxO. If the wallet ends
|
||||
up fragmented later, run `tx:consolidate-wallet` (can be docker-exec'd inside
|
||||
the running supplier container — it already has the key via env).
|
||||
|
||||
### 4.6 Env file
|
||||
|
||||
```bash
|
||||
cp supplier/.env.example supplier/.env.<name>
|
||||
chmod 600 supplier/.env.<name>
|
||||
```
|
||||
|
||||
Must contain, at minimum:
|
||||
|
||||
- **All four wallet vars**: `SUPPLIER_PRIV_KEY_HEX`, `SUPPLIER_ADDRESS`,
|
||||
`SUPPLIER_PKH`, `SUPPLIER_PUB_KEY_HEX`. They are NOT derived at boot;
|
||||
a missing derived var causes `403 wrong_supplier` on every job.
|
||||
- `OPENAI_API_KEY` (empty is legal only for unauthenticated backends).
|
||||
- The shared plumbing block from §3.
|
||||
- `ADVERT_REF=` left as placeholder until §4.8.
|
||||
|
||||
Backend selection (`LLM_BACKEND=openai`, `OPENAI_BASE_URL`,
|
||||
`OPENAI_TIMEOUT_MS`, `CAPABILITY_KIND`) lives in the compose file (§4.4),
|
||||
not the env file.
|
||||
|
||||
### 4.7 First start
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --build
|
||||
```
|
||||
|
||||
Verify before going on-chain:
|
||||
|
||||
- `docker logs marketplace-mainnet-supplier-<name> --tail 50` — no
|
||||
crash-loop.
|
||||
- `curl https://<your-dns-name>/healthz` → `{"ok":true}` (proves DNS, cert
|
||||
issuance, and routing end-to-end).
|
||||
|
||||
Every deploy on your box is manual: `git pull` +
|
||||
`docker compose ... up -d --build`. No CD pipeline covers it.
|
||||
|
||||
### 4.8 Post the advert (go-live — do this LAST)
|
||||
|
||||
The advert is the go-live flag: the moment it confirms, the indexer starts
|
||||
probing `<endpoint>/status` and buyers can lock escrows. Post it only after
|
||||
§4.7 is green, so buyers never hit a dead endpoint.
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model '<MODEL_ID>' \
|
||||
--max-output-tokens <CTX_LEN> \
|
||||
--max-processing-ms 300000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://<your-dns-name>
|
||||
# chat variant: --capability-id llm.chat.v1 --max-processing-ms 1800000
|
||||
```
|
||||
|
||||
Paste the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env.<name>`,
|
||||
then:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
Note: adverts are immutable — changing the model, price, or endpoint later
|
||||
requires `tx:retire-advert` (refunds the advert bond) + a fresh post-advert
|
||||
+ a new `ADVERT_REF`.
|
||||
|
||||
## 5. Verification checklist
|
||||
|
||||
- `curl https://<your-dns-name>/healthz` → `{"ok":true}`.
|
||||
- `/capability` on the same host shows the right model + pkh.
|
||||
- Public marketplace indexer lists your advert:
|
||||
`curl https://mp-indexer.vector.apexfusion.org/suppliers` → your entry with
|
||||
`advert_status: "Active"`, `status: "free"`. The indexer follows the chain,
|
||||
so a confirmed advert appears automatically — no registration step.
|
||||
(The buyer API and OpenAI gateway at `marketplace.vector.apexfusion.org` /
|
||||
`api.marketplace.vector.apexfusion.org` require buyer API keys; buyers with
|
||||
keys will see your model in the gateway's `/openai/v1/models`.)
|
||||
- Tail `docker logs -f marketplace-mainnet-supplier-<name>` through the
|
||||
first paid jobs; watch for `openai_malformed` (reasoning-model
|
||||
content-shape risk) and `403 wrong_supplier` (missing derived wallet vars).
|
||||
|
||||
## 6. Ongoing operations
|
||||
|
||||
- **Update**: `git pull && docker compose -f ... up -d --build`.
|
||||
- **Model swap**: `tx:retire-advert` → post-advert with the new `--model` →
|
||||
new `ADVERT_REF` in the env file → `up -d --force-recreate`.
|
||||
- **Wallet health**: keep the 2-UTxO shape; run `tx:consolidate-wallet` if
|
||||
script-spends start failing with collateral-selector errors.
|
||||
- **Exit**: `tx:retire-advert` refunds the advert bond and delists you; the
|
||||
wallet keeps its funds.
|
||||
- **Monitoring (optional)**: the repo ships a balance monitor
|
||||
(`wallet-monitor/`, `buyer/scripts/monitor-wallets.ts`) you can run against
|
||||
your own wallet list.
|
||||
|
||||
## 7. Footguns (each has burned an operator before)
|
||||
|
||||
1. `OPENAI_BASE_URL` must NOT end in `/v1`.
|
||||
2. Advert `--model` must match the upstream id byte-for-byte; slashes are
|
||||
fine on-chain.
|
||||
3. Do not set `OPENAI_REASONING` unless the backend is OpenRouter.
|
||||
4. `OPENAI_TIMEOUT_MS` > advert `max_processing_ms` = bond-forfeit window.
|
||||
5. Model ids containing `kimi`, `deepseek`, or `gpt` (case-insensitive) are
|
||||
auto-enrolled in the marketplace buyer's PDF-summarizer pool
|
||||
(`buyer/src/pdf/caps.ts`), so expect PDF jobs too. The
|
||||
`PDF_MODEL_ALLOWLIST` / `PDF_MODEL_DENYLIST` overrides are buyer-side —
|
||||
only relevant if you also operate your own buyer.
|
||||
6. `LIVE_CHAIN` accepts only the literal `"1"`; `true`/`yes` silently means
|
||||
off, and the supplier then never broadcasts.
|
||||
7. The compose `env_file` uses `required: false`, so a typo'd env path comes
|
||||
up and crash-loops instead of failing to parse — check logs on first boot.
|
||||
8. Wallet fragmentation (>2 UTxOs, none ≥5 AP3X) breaks all script-spends
|
||||
with a lucid collateral selector error; fix with `tx:consolidate-wallet`.
|
||||
|
||||
## 8. Key references in this repo
|
||||
|
||||
- `deploy/inference-proxy/README.md` — a live standalone box built exactly
|
||||
this way (two suppliers, incl. a tailnet-backed home rig).
|
||||
- `deploy/README.md` — networks, Traefik, per-service ops.
|
||||
- `docs/HETZNER_SETUP.md` — the Apex Fusion fleet runbook (hosted-API
|
||||
suppliers at scale).
|
||||
- `docs/HUGGINGFACE_ROUTER_SETUP.md` — OpenRouter/HF-router backend variant.
|
||||
- `supplier/.env.example` — every env var, annotated (plus `.env.*.example`
|
||||
variants per backend).
|
||||
- `supplier/src/cli/` — `gen-keypair`, `post-advert`, `retire-advert`,
|
||||
`consolidate-wallet`, `publish-reference-scripts`.
|
||||
@@ -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.
141
docs/HETZNER_SETUP.md
Normal file
141
docs/HETZNER_SETUP.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Hetzner Inference Supplier Fleet — Setup Runbook
|
||||
|
||||
> **Status:** shipped; fleet trimmed 2026-08-21. Hetzner retired
|
||||
> `Kimi-K2.7-Code`, `GLM-5.2-NVFP4`, and `DeepSeek-V4-Flash-0731` from
|
||||
> Inference (gone from `/v1/models`), so the 6 suppliers serving them were
|
||||
> deregistered (adverts retired on-chain, containers removed). `Qwen3.8-27B`
|
||||
> was added in their place, reusing the retired kimi-code wallets. Configures
|
||||
> mainnet supplier nodes that use **Hetzner Inference**
|
||||
> (`https://inference.hetzner.com`) as their **compute backend** for bonded
|
||||
> inference work commissioned on Vector. Config only — no code changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
Hetzner Inference is an OpenAI-compatible hosted API. Because the supplier's
|
||||
upstream client (`supplier/src/openai.ts`) already speaks plain
|
||||
`POST /v1/chat/completions` with a Bearer token, pointing it at Hetzner is
|
||||
**pure configuration**: `LLM_BACKEND=openai` + `OPENAI_BASE_URL` + the API key.
|
||||
This is the same pattern as the DeepSeek-direct and OpenRouter suppliers, and
|
||||
the direct sibling of `docs/HUGGINGFACE_ROUTER_SETUP.md`.
|
||||
|
||||
One supplier per (model × capability) — both the one-off
|
||||
`llm.text.generate.v1` and the multi-turn `llm.chat.v1` capability:
|
||||
|
||||
| Supplier name | Model (on-chain + API param, verbatim) | Capability | Advert max_output_tokens |
|
||||
|---|---|---|---|
|
||||
| `qwen35b` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.text.generate.v1` | 262144 |
|
||||
| `qwen35b-chat` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.chat.v1` | 262144 |
|
||||
| `qwen38` | `Qwen3.8-27B` | `llm.text.generate.v1` | 262144 |
|
||||
| `qwen38-chat` | `Qwen3.8-27B` | `llm.chat.v1` | 262144 |
|
||||
|
||||
Deregistered 2026-08-21 (models dropped by Hetzner): `kimi-code`,
|
||||
`kimi-code-chat` (`Kimi-K2.7-Code`), `glm`, `glm-chat` (`GLM-5.2-NVFP4`),
|
||||
`ds-flash-htz`, `ds-flash-htz-chat` (`DeepSeek-V4-Flash-0731`). The qwen38
|
||||
pair reuses the kimi-code / kimi-code-chat wallets; the other four wallets
|
||||
still hold funds and their env files remain on the host.
|
||||
|
||||
Shared advert parameters: price **200000 lovelace (0.2 AP3X)** flat per job,
|
||||
bonds **1000000 lovelace (1 AP3X)** both sides, `max_processing_ms` **300000**
|
||||
for one-off / **1800000** for chat (the session spans the whole conversation).
|
||||
`max_output_tokens` is set to the model's context length: no `max_tokens` is
|
||||
forwarded upstream (`OPENAI_MAX_TOKENS` unset), so the advert value only gates
|
||||
what buyers may request.
|
||||
|
||||
Names follow the brand-by-model convention; the retired `ds-flash-htz`
|
||||
carried a `-htz` suffix only because `deepseek-flash-*` was already taken by
|
||||
the OpenRouter fleet serving `deepseek/deepseek-v4-flash`.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
1. A Hetzner account with Inference access and an API key (usage-based billing
|
||||
to you as the node operator — the flat 0.2 AP3X job price is a business
|
||||
choice, not cost-derived). One key is shared by all suppliers; Hetzner
|
||||
rate limits apply to the aggregate.
|
||||
2. One funded supplier wallet per supplier (50 AP3X each recommended; bonds
|
||||
are 1 AP3X per in-flight job and returned on completion).
|
||||
3. The usual mainnet supplier deploy prerequisites (`deploy/README.md`).
|
||||
4. **DNS**: one A record per supplier — `mp-suppliers-<name>.vector.apexfusion.org`
|
||||
→ `91.98.147.172`, **DNS-only/unproxied**. There is NO wildcard for
|
||||
`*.vector.apexfusion.org`; and because traefik issues certs on-box via
|
||||
Let's Encrypt HTTP-01 (port 80), a proxied/CDN record would break issuance.
|
||||
Certs are issued automatically within ~a minute of the record propagating —
|
||||
no restarts needed.
|
||||
|
||||
## 3. Verify the backend before spending on-chain
|
||||
|
||||
The model ids come from `GET https://inference.hetzner.com/api/v1/models`
|
||||
(this list changes — Hetzner retires models; re-check before any advert)
|
||||
and are sent **verbatim** as the upstream `model` param (the runtime uses
|
||||
`advert.model`). Before posting adverts, smoke-test each model:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://inference.hetzner.com/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $HETZNER_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"model":"Qwen3.8-27B","messages":[{"role":"user","content":"ping"}]}' \
|
||||
| jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
`choices[0].message.content` must be a non-empty string with **no `max_tokens`
|
||||
sent** (matches runtime config) — the supplier throws `openai_malformed`
|
||||
otherwise and forfeits its 1 AP3X bond per failed job. For chat suppliers also
|
||||
verify a `tools`/`tool_choice` round-trip. The original four models passed
|
||||
both checks on 2026-08-11; `Qwen3.8-27B` passed both on 2026-08-21.
|
||||
|
||||
## 4. Per-supplier bring-up (repeat per supplier)
|
||||
|
||||
```bash
|
||||
# 1. wallet — prints privateKeyHex, publicKeyHex, pubKeyHash, address
|
||||
pnpm --filter @marketplace/supplier tx:gen-keypair --network 1
|
||||
# fund the printed address (50 AP3X recommended), then:
|
||||
|
||||
# 2. env file on the mainnet host
|
||||
cp supplier/.env.hetzner.example /root/agents-marketplace/supplier/.env.<name>
|
||||
chmod 600 /root/agents-marketplace/supplier/.env.<name>
|
||||
# fill in: ALL FOUR wallet vars (PRIV_KEY_HEX, ADDRESS, PKH, PUB_KEY_HEX —
|
||||
# they are NOT derived at boot; missing derived vars ⇒ 403 wrong_supplier),
|
||||
# OPENAI_API_KEY, and the ADVERT_* values from the table above.
|
||||
|
||||
# 3. advert (one-off shown; chat: --capability-id llm.chat.v1 --max-processing-ms 1800000)
|
||||
pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model 'GLM-5.2-NVFP4' \
|
||||
--max-output-tokens 512000 \
|
||||
--max-processing-ms 300000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-<name>.vector.apexfusion.org
|
||||
# paste the printed "<txHash>#0" into ADVERT_REF in the env file
|
||||
|
||||
# 4. first start is always manual (CD skips compose projects with no running
|
||||
# containers); subsequent rollouts ride the mainnet CD pipeline
|
||||
docker compose -f deploy/mainnet/docker-compose.supplier-<name>.yml up -d
|
||||
|
||||
# 5. register the wallet for balance alerts
|
||||
# add {name, address} to wallet-monitor/wallets.json on the host
|
||||
```
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- `curl https://mp-suppliers-<name>.vector.apexfusion.org/healthz` → `{"ok":true}`;
|
||||
`/capability` shows the right model + pkh.
|
||||
- Indexer: buyer `GET /v1/indexer/suppliers` lists the new entries with
|
||||
`advert_status: Active`, `status: free`.
|
||||
- Gateway `GET /openai/v1/models` includes the four Hetzner model ids.
|
||||
- Watch `docker logs -f marketplace-mainnet-supplier-<name>` through the first
|
||||
jobs for `openai_malformed` (reasoning-model content-shape risk).
|
||||
|
||||
## 6. Footguns
|
||||
|
||||
- `OPENAI_BASE_URL=https://inference.hetzner.com/api` — **no `/v1`**; the
|
||||
client appends `/v1/chat/completions`.
|
||||
- Do **not** set `OPENAI_REASONING` — `reasoning:{enabled:false}` is an
|
||||
OpenRouter-only param.
|
||||
- The advert `--model` must match the Hetzner id exactly, including the
|
||||
`Qwen/` prefix on `Qwen/Qwen3.6-35B-A3B-FP8` (slash is fine on-chain;
|
||||
precedent `moonshotai/kimi-k2.6`).
|
||||
- `Kimi-K2.7-Code` and `DeepSeek-V4-Flash-0731` auto-join the buyer's PDF pool
|
||||
(case-insensitive substring allowlist `["kimi","deepseek","gpt"]` in
|
||||
`buyer/src/pdf/caps.ts`); GLM and Qwen stay out. Deliberate — adjust
|
||||
`PDF_MODEL_ALLOWLIST`/`PDF_MODEL_DENYLIST` in the buyer env to change it.
|
||||
- `OPENAI_TIMEOUT_MS` must stay ≤ the advert's `max_processing_ms`.
|
||||
@@ -1,11 +1,11 @@
|
||||
# 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
|
||||
|
||||
1. You type a **prompt** and set a **payment** amount in the web UI
|
||||
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
|
||||
1. You type a **prompt** and set a **price** in the web UI
|
||||
2. The app posts an **escrow transaction** on-chain, locking price + bond
|
||||
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
|
||||
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 data/ folder** - it contains task history
|
||||
- The seed phrase controls your wallet funds. Back it up securely.
|
||||
- Escrow protects you: the supplier cannot take your funds without delivering a result
|
||||
- The seed phrase controls your wallet. Back it up securely.
|
||||
- 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
|
||||
|
||||
## 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) |
|
||||
| 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 |
|
||||
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** |
|
||||
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick |
|
||||
| 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-AP3X collateral, working}` after settle / on a tick |
|
||||
| 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 |
|
||||
| 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`.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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`.
|
||||
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
|
||||
## Request lifecycle — one-shot `/chat/completions`
|
||||
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`).
|
||||
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).
|
||||
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`.
|
||||
@@ -121,7 +121,7 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
|
||||
|
||||
## 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.
|
||||
- **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}}`)
|
||||
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)
|
||||
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`.
|
||||
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.
|
||||
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]`.
|
||||
|
||||
@@ -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 data/ folder** - it contains task history
|
||||
- The seed phrase controls your wallet funds. 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 seed phrase controls your wallet. Back it up securely.
|
||||
- The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY gateway/package.json ./gateway/package.json
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "tsx src/index.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts"
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts",
|
||||
"reseller-demo": "tsx src/cli/reseller-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/buyer": "workspace:*",
|
||||
|
||||
@@ -19,6 +19,26 @@ import { badRequest, forbidden } from "../openai/errors.js";
|
||||
|
||||
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||
|
||||
/** Bound on per-request escrow liveness lookups; rows past the cap count as
|
||||
* locked unverified (a key with this many active escrows has bigger problems). */
|
||||
const LIVENESS_CHECK_CAP = 32;
|
||||
|
||||
/** True when the escrow UTxO behind an indexer row is still unspent. The
|
||||
* indexer never updates rows on terminal spends (Accept/Reclaim/Release
|
||||
* consume the escrow without a new escrow output), so "active" rows can be
|
||||
* long spent. Unparseable refs and failed lookups count as live so errors
|
||||
* inflate the locked figure rather than hide funds. */
|
||||
async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise<boolean> {
|
||||
const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef);
|
||||
if (!m) return true;
|
||||
try {
|
||||
const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) });
|
||||
return utxo !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function ap3x(lovelace: bigint): string {
|
||||
return (Number(lovelace) / 1e6).toFixed(2);
|
||||
}
|
||||
@@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as Array<Record<string, unknown>>;
|
||||
if (Array.isArray(rows)) {
|
||||
for (const row of rows) {
|
||||
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
|
||||
const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
|
||||
const liveness = await Promise.all(
|
||||
active.map((row, i) =>
|
||||
i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
if (!liveness[i]) continue;
|
||||
const row = active[i];
|
||||
lockedInEscrow +=
|
||||
BigInt(String(row.payment_lovelace ?? "0")) +
|
||||
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
|
||||
|
||||
216
gateway/src/cli/reseller-demo.ts
Normal file
216
gateway/src/cli/reseller-demo.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
interface DemoOptions {
|
||||
gatewayUrl: string;
|
||||
dashboardUrl: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
maxTokens: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface PublicJob {
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
upstream_cost_usd: string | null;
|
||||
ap3x_payout: string;
|
||||
failure_reason: string | null;
|
||||
}
|
||||
|
||||
export function parseDemoArgs(argv: string[]): DemoOptions {
|
||||
const values: Record<string, string> = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!flag?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`invalid demo argument near ${flag ?? "end"}`);
|
||||
}
|
||||
if (
|
||||
flag !== "--gateway-url" &&
|
||||
flag !== "--dashboard-url" &&
|
||||
flag !== "--model" &&
|
||||
flag !== "--prompt" &&
|
||||
flag !== "--max-tokens" &&
|
||||
flag !== "--timeout-ms"
|
||||
) {
|
||||
throw new Error(`unknown demo argument: ${flag}`);
|
||||
}
|
||||
values[flag] = value;
|
||||
}
|
||||
const gatewayUrl = requiredUrl(values["--gateway-url"], "--gateway-url");
|
||||
const dashboardUrl = requiredUrl(values["--dashboard-url"], "--dashboard-url");
|
||||
const model = requiredString(values["--model"], "--model");
|
||||
const prompt = values["--prompt"] ?? "Reply with one sentence that confirms this marketplace job completed.";
|
||||
const maxTokens = positiveInteger(values["--max-tokens"] ?? "64", "--max-tokens");
|
||||
const timeoutMs = positiveInteger(values["--timeout-ms"] ?? "900000", "--timeout-ms");
|
||||
return {
|
||||
gatewayUrl: gatewayUrl.replace(/\/+$/, ""),
|
||||
dashboardUrl: dashboardUrl.replace(/\/+$/, ""),
|
||||
model,
|
||||
prompt,
|
||||
maxTokens,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runResellerDemo(
|
||||
options: DemoOptions,
|
||||
gatewayApiKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (gatewayApiKey === "") throw new Error("GATEWAY_API_KEY is required");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
try {
|
||||
const sellerResponse = await fetch(`${options.dashboardUrl}/api/status`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const sellerBody = await sellerResponse.json().catch(() => null) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
if (!sellerResponse.ok) {
|
||||
throw new Error(
|
||||
`seller status returned HTTP ${sellerResponse.status}`,
|
||||
);
|
||||
}
|
||||
const supplierPkh = sellerBody?.supplier_pkh;
|
||||
if (typeof supplierPkh !== "string" || !/^[0-9a-fA-F]{56}$/.test(supplierPkh)) {
|
||||
throw new Error("seller status omitted a valid supplier_pkh");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${options.gatewayUrl}/openai/v1/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${gatewayApiKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [{ role: "user", content: options.prompt }],
|
||||
max_tokens: options.maxTokens,
|
||||
stream: false,
|
||||
public_preview: true,
|
||||
x_vector: { supplier_pkh: supplierPkh },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`gateway returned HTTP ${response.status}: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
const escrowRef = response.headers.get("x-vector-escrow-ref");
|
||||
if (!escrowRef) throw new Error("gateway response omitted X-Vector-Escrow-Ref");
|
||||
const job = await waitForPublicJob(
|
||||
options.dashboardUrl,
|
||||
escrowRef,
|
||||
options.timeoutMs,
|
||||
controller.signal,
|
||||
);
|
||||
if (job.status === "failed") {
|
||||
throw new Error(`reseller job failed: ${job.failure_reason ?? "unknown"}`);
|
||||
}
|
||||
return {
|
||||
completion_id: body?.id ?? null,
|
||||
escrow_ref: escrowRef,
|
||||
model: body?.model ?? options.model,
|
||||
supplier_pkh: supplierPkh,
|
||||
upstream_cost_usd: job.upstream_cost_usd,
|
||||
ap3x_payout: job.ap3x_payout,
|
||||
dashboard_url: options.dashboardUrl,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPublicJob(
|
||||
dashboardUrl: string,
|
||||
escrowRef: string,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicJob> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(`${dashboardUrl}/api/jobs?limit=100`, {
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`dashboard jobs returned HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.json() as { jobs?: PublicJob[] };
|
||||
const job = body.jobs?.find((candidate) => candidate.escrow_ref === escrowRef);
|
||||
if (job) return job;
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 2_000));
|
||||
}
|
||||
throw new Error(`timed out waiting for dashboard settlement ${escrowRef}`);
|
||||
}
|
||||
|
||||
function requiredString(value: string | undefined, flag: string): string {
|
||||
if (!value || value.trim() === "") throw new Error(`${flag} is required`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredUrl(value: string | undefined, flag: string): string {
|
||||
const raw = requiredString(value, flag);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${flag} must be a valid URL`);
|
||||
}
|
||||
const loopback =
|
||||
url.hostname === "localhost" ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "[::1]";
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
||||
throw new Error(`${flag} must use HTTPS except on loopback`);
|
||||
}
|
||||
if (
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== ""
|
||||
) {
|
||||
throw new Error(`${flag} must not contain credentials, query, or fragment`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string, flag: string): number {
|
||||
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
||||
throw new Error(`${flag} must be a positive safe integer`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await runResellerDemo(
|
||||
parseDemoArgs(argv),
|
||||
env.GATEWAY_API_KEY ?? "",
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`reseller demo failed: ${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
void main(process.argv.slice(2), process.env).then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
const PKH_RE = /^[0-9a-fA-F]{56}$/;
|
||||
|
||||
export interface GatewayConfig {
|
||||
masterKeyHex: string;
|
||||
@@ -53,6 +54,11 @@ export interface GatewayConfig {
|
||||
walletHealthIntervalMs: number;
|
||||
sdkRegistryMax: number;
|
||||
corsOrigins: string[];
|
||||
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||
ocrCapabilityId: string;
|
||||
/** Seller preferred for matching one-shot models. Other suppliers remain fallback. */
|
||||
preferredSupplierPkh?: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -112,6 +118,17 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
}
|
||||
networkId = networkIdStr === "1" ? 1 : 0;
|
||||
}
|
||||
const preferredSupplierPkhRaw =
|
||||
(env.GATEWAY_PREFERRED_SUPPLIER_PKH ?? "").trim();
|
||||
if (
|
||||
preferredSupplierPkhRaw !== "" &&
|
||||
!PKH_RE.test(preferredSupplierPkhRaw)
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: GATEWAY_PREFERRED_SUPPLIER_PKH must be a 28-byte hex payment-key hash",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
masterKeyHex,
|
||||
@@ -138,6 +155,10 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
chatSettleMode: chatSettleMode(env),
|
||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||
preferredSupplierPkh: preferredSupplierPkhRaw === ""
|
||||
? undefined
|
||||
: preferredSupplierPkhRaw.toLowerCase(),
|
||||
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||
|
||||
@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
|
||||
const invokedDirectly =
|
||||
typeof process !== "undefined" &&
|
||||
process.argv?.[1] !== undefined &&
|
||||
/gateway\/(src|dist)\/index\.(t|j)s$/.test(process.argv[1]);
|
||||
// Accept both path separators: on Windows process.argv[1] arrives with
|
||||
// backslashes, so a forward-slash-only pattern silently never matches and
|
||||
// the process exits 0 without ever starting the server.
|
||||
/gateway[\\/](src|dist)[\\/]index\.(t|j)s$/.test(process.argv[1]);
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
|
||||
402
gateway/src/ocr/extract.ts
Normal file
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.
|
||||
*
|
||||
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
|
||||
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
|
||||
* that also need a pure-ADA collateral UTxO ≥ 5 ADA (assertCollateralCandidate).
|
||||
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
|
||||
* AND the wallet must currently hold a pure-ADA UTxO ≥ collateral. We check both
|
||||
* up front and return a clean 402 instead of failing mid-flight.
|
||||
* supplier_bond, raised to the Submitted-state min-ada floor so long
|
||||
* capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
|
||||
* see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
|
||||
* postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
|
||||
* are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
|
||||
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
|
||||
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
|
||||
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
|
||||
* and return a clean 402 instead of failing mid-flight.
|
||||
*/
|
||||
|
||||
import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
|
||||
import type { EscrowDatum } from "@marketplace/shared/cbor";
|
||||
import { escrowLockFloor } from "@marketplace/shared/tx";
|
||||
|
||||
export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
|
||||
export const FEE_RESERVE_LOVELACE = 2_000_000n;
|
||||
@@ -18,7 +24,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
|
||||
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 {
|
||||
return utxos.some(
|
||||
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
|
||||
@@ -32,20 +38,48 @@ export interface PreflightResult {
|
||||
collateralOk: boolean;
|
||||
}
|
||||
|
||||
export interface PreflightCost {
|
||||
capabilityId: string;
|
||||
priceLovelace: bigint;
|
||||
buyerBondLovelace: bigint;
|
||||
supplierBondLovelace: bigint;
|
||||
}
|
||||
|
||||
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
|
||||
* floor. Every field except capability_id and the cost amounts is a
|
||||
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
|
||||
* POSIX-ms timestamp) — capability_id's actual length is what drives the
|
||||
* estimate, exactly as in escrowLockFloor's own contract. */
|
||||
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
|
||||
return {
|
||||
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
|
||||
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
|
||||
advert_ref: { txHash: "a6".repeat(32), index: 0 },
|
||||
capability_id: cost.capabilityId,
|
||||
request_spec_hash: "1b".repeat(32),
|
||||
prompt_hash: "23".repeat(32),
|
||||
payment_lovelace: cost.priceLovelace,
|
||||
buyer_bond_lovelace: cost.buyerBondLovelace,
|
||||
supplier_bond_lovelace: cost.supplierBondLovelace,
|
||||
deliver_by: 1_786_105_955_257,
|
||||
posted_at: 1_786_105_625_257,
|
||||
submitted_at: null,
|
||||
result_receipt_hash: null,
|
||||
state: "Open",
|
||||
};
|
||||
}
|
||||
|
||||
export async function preflight(
|
||||
chain: ChainProvider,
|
||||
address: string,
|
||||
cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint },
|
||||
cost: PreflightCost,
|
||||
): Promise<PreflightResult> {
|
||||
const utxos = await chain.queryUtxosByAddress(address);
|
||||
const available = totalLovelace(utxos);
|
||||
const collateralOk = hasCollateral(utxos);
|
||||
const required =
|
||||
cost.priceLovelace +
|
||||
cost.buyerBondLovelace +
|
||||
cost.supplierBondLovelace +
|
||||
COLLATERAL_MIN_LOVELACE +
|
||||
FEE_RESERVE_LOVELACE;
|
||||
const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
|
||||
const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
|
||||
const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
|
||||
return {
|
||||
ok: available >= required && collateralOk,
|
||||
availableLovelace: available,
|
||||
|
||||
@@ -96,6 +96,8 @@ async function runOneShot(
|
||||
indexerUrl: config.indexerUrl,
|
||||
model: parsed.model,
|
||||
capabilityId: CAPABILITY,
|
||||
supplierPkh: parsed.supplierPkh,
|
||||
preferredSupplierPkh: config.preferredSupplierPkh,
|
||||
fetchFn,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
@@ -108,7 +110,7 @@ async function runOneShot(
|
||||
throw paymentRequired(
|
||||
pf.collateralOk
|
||||
? "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: {
|
||||
required_lovelace: pf.requiredLovelace.toString(),
|
||||
@@ -152,6 +154,7 @@ async function runOneShot(
|
||||
messages: parsed.messages,
|
||||
payment_lovelace: cand.priceLovelace,
|
||||
max_output_tokens: parsed.maxTokens,
|
||||
public_preview: parsed.publicPreview,
|
||||
});
|
||||
used = cand;
|
||||
break;
|
||||
@@ -167,6 +170,9 @@ async function runOneShot(
|
||||
|
||||
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||
const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||
if (!parsed.stream) {
|
||||
res.setHeader("X-Vector-Escrow-Ref", originalRefStr);
|
||||
}
|
||||
const submittedRef = await resolveSubmittedRef({
|
||||
indexerUrl: config.indexerUrl,
|
||||
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||
|
||||
@@ -71,6 +71,22 @@ export function toGatewayError(err: unknown): GatewayError {
|
||||
return new GatewayError(502, "server_error", "indexer_error", `indexer error: ${err.reason}`);
|
||||
}
|
||||
if (err instanceof TxConstructionError) {
|
||||
if (err.reason === "input_cap_exceeded") {
|
||||
return new GatewayError(
|
||||
400,
|
||||
"invalid_request_error",
|
||||
"input_cap_exceeded",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
if (err.reason === "supplier_preflight_failed") {
|
||||
return new GatewayError(
|
||||
503,
|
||||
"server_error",
|
||||
"supplier_preflight_failed",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
return new GatewayError(502, "server_error", "escrow_failed", `escrow build failed: ${err.reason}`);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function openSessionCore(
|
||||
const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
|
||||
if (!pf.ok) {
|
||||
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: {
|
||||
required_lovelace: pf.requiredLovelace.toString(),
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ParsedChatRequest {
|
||||
stream: boolean;
|
||||
tools?: unknown[];
|
||||
toolChoice?: unknown;
|
||||
publicPreview: boolean;
|
||||
supplierPkh?: string;
|
||||
}
|
||||
|
||||
const ROLES = new Set(["system", "user", "assistant"]);
|
||||
@@ -83,6 +85,46 @@ export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean })
|
||||
maxTokens = mt;
|
||||
}
|
||||
|
||||
if (b.public_preview !== undefined && typeof b.public_preview !== "boolean") {
|
||||
throw badRequest(
|
||||
"invalid_public_preview",
|
||||
"`public_preview` must be a boolean when provided",
|
||||
);
|
||||
}
|
||||
const publicPreview = b.public_preview === true;
|
||||
|
||||
let supplierPkh: string | undefined;
|
||||
if (b.x_vector !== undefined) {
|
||||
if (
|
||||
typeof b.x_vector !== "object" ||
|
||||
b.x_vector === null ||
|
||||
Array.isArray(b.x_vector)
|
||||
) {
|
||||
throw badRequest("invalid_x_vector", "`x_vector` must be an object");
|
||||
}
|
||||
const requested = (b.x_vector as Record<string, unknown>).supplier_pkh;
|
||||
if (
|
||||
requested !== undefined &&
|
||||
(typeof requested !== "string" || !/^[0-9a-fA-F]{56}$/.test(requested))
|
||||
) {
|
||||
throw badRequest(
|
||||
"invalid_supplier_pkh",
|
||||
"`x_vector.supplier_pkh` must be a 28-byte hex payment-key hash",
|
||||
);
|
||||
}
|
||||
if (typeof requested === "string") supplierPkh = requested.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
const stream = b.stream === true;
|
||||
return { model, messages, maxTokens, stream, tools, toolChoice };
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
maxTokens,
|
||||
stream,
|
||||
tools,
|
||||
toolChoice,
|
||||
publicPreview,
|
||||
supplierPkh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,8 +53,15 @@ export function parseRef(ref: string): OutputReference | null {
|
||||
|
||||
export interface SelectSupplierOpts {
|
||||
indexerUrl: string;
|
||||
/** Exact model match; "" matches ANY model under the capability (used by
|
||||
* single-model capabilities like the ocr.* ids, where the capability
|
||||
* already pins the model). */
|
||||
model: string;
|
||||
capabilityId: string;
|
||||
/** Optional exact seller identity pin for deterministic agent routing. */
|
||||
supplierPkh?: string;
|
||||
/** Preferred seller ordered first when it is eligible. */
|
||||
preferredSupplierPkh?: string;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
/** supplier_pkhs eligible regardless of polled status — used after the demo
|
||||
* executor frees a supplier the indexer poller (~20 s cadence) still reports
|
||||
@@ -82,7 +89,10 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
const candidates: SupplierCandidate[] = [];
|
||||
for (const raw of body as SupplierView[]) {
|
||||
if (raw.capability_id !== opts.capabilityId) continue;
|
||||
if (raw.model !== opts.model) continue;
|
||||
if (opts.model !== "" && raw.model !== opts.model) continue;
|
||||
if (opts.supplierPkh !== undefined && raw.supplier_pkh !== opts.supplierPkh) {
|
||||
continue;
|
||||
}
|
||||
if (raw.advert_status !== "Active") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||
continue;
|
||||
@@ -103,8 +113,16 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
status: raw.status,
|
||||
});
|
||||
}
|
||||
// Prefer known-free over unknown so we route to confirmed-available first.
|
||||
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
|
||||
// Prefer the configured seller, then known-free over unknown.
|
||||
candidates.sort((left, right) => {
|
||||
const leftRank =
|
||||
(left.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
|
||||
(left.status === "free" ? 0 : 1);
|
||||
const rightRank =
|
||||
(right.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
|
||||
(right.status === "free" ? 0 : 1);
|
||||
return leftRank - rightRank;
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,88 @@ import { open as unseal } from "../crypto/seal.js";
|
||||
import { deriveWalletKey } from "../wallet.js";
|
||||
import type { ApiKeyRow } from "../db/store.js";
|
||||
|
||||
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
|
||||
/** Default per-run deadline. Chain reads and tx submits settle in seconds;
|
||||
* anything past this is the 2026-08-12 freeze mode (a fetch that never
|
||||
* settles), not a slow success. */
|
||||
export const DEFAULT_MUTEX_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Default queue bound per mutex. A healthy key drains its queue in seconds;
|
||||
* a queue this deep means the head is stuck and callers should fail fast. */
|
||||
export const DEFAULT_MUTEX_MAX_QUEUE = 64;
|
||||
|
||||
/** A run() exceeded its deadline and was abandoned so queued work can proceed. */
|
||||
export class MutexTimeoutError extends Error {
|
||||
constructor(label: string, timeoutMs: number) {
|
||||
super(`mutex run '${label}' exceeded ${timeoutMs}ms and was abandoned; queued work proceeds`);
|
||||
this.name = "MutexTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/** run() was refused because the queue is at its bound (head likely stuck). */
|
||||
export class MutexQueueFullError extends Error {
|
||||
constructor(label: string, depth: number) {
|
||||
super(`mutex queue full (${depth} pending) — refusing '${label}'; the head of the queue is likely stuck`);
|
||||
this.name = "MutexQueueFullError";
|
||||
}
|
||||
}
|
||||
|
||||
function runWithDeadline<T>(fn: () => Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => rejectPromise(new MutexTimeoutError(label, timeoutMs)), timeoutMs);
|
||||
// Node-only nicety: don't hold the process open for a pending deadline.
|
||||
timer.unref?.();
|
||||
fn().then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
rejectPromise(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny promise-chain mutex: run(fn) executes fns strictly one at a time —
|
||||
* now with a per-run deadline and a queue bound (2026-08-12 freeze fix).
|
||||
*
|
||||
* On deadline the stuck fn is NOT cancelled (promises aren't); it is
|
||||
* abandoned: its run() rejects with MutexTimeoutError and the chain advances.
|
||||
* A zombie that settles later can at worst lose a UTxO-contention race with
|
||||
* the run that replaced it — strictly better than the alternative, where one
|
||||
* stuck chain call convoys every subsequent request on the key forever.
|
||||
*/
|
||||
export class Mutex {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private depth = 0;
|
||||
|
||||
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const result = this.tail.then(fn, fn);
|
||||
// Keep the chain alive even if fn rejects; swallow here so the next
|
||||
// run() isn't poisoned by a prior rejection.
|
||||
this.tail = result.then(
|
||||
constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
|
||||
|
||||
run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
|
||||
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
|
||||
const maxQueue = this.opts.maxQueue ?? DEFAULT_MUTEX_MAX_QUEUE;
|
||||
|
||||
if (this.depth >= maxQueue) {
|
||||
return Promise.reject(new MutexQueueFullError(label, this.depth));
|
||||
}
|
||||
this.depth++;
|
||||
|
||||
// Keep the chain alive even if fn rejects or times out; swallow here so
|
||||
// the next run() isn't poisoned by a prior rejection.
|
||||
const result = this.tail.then(
|
||||
() => runWithDeadline(fn, timeoutMs, label),
|
||||
() => runWithDeadline(fn, timeoutMs, label),
|
||||
);
|
||||
const settled = result.finally(() => {
|
||||
this.depth--;
|
||||
});
|
||||
this.tail = settled.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
return settled;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
|
||||
import { sendError } from "./middleware/http.js";
|
||||
import { notFound } from "./openai/errors.js";
|
||||
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||
import { makeOcrExtractHandler } from "./ocr/extract.js";
|
||||
import { makeModelsHandler } from "./openai/models.js";
|
||||
import {
|
||||
makeOpenSessionHandler,
|
||||
@@ -40,6 +41,7 @@ function corsMiddleware(allowOrigins: string[]) {
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Expose-Headers", "X-Vector-Escrow-Ref");
|
||||
res.setHeader("Access-Control-Max-Age", "600");
|
||||
}
|
||||
if (req.method === "OPTIONS") {
|
||||
@@ -56,7 +58,11 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.set("trust proxy", true);
|
||||
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
|
||||
app.use(corsMiddleware(deps.config.corsOrigins));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// /v1/ocr carries a base64 page image and mounts its own larger parser on
|
||||
// the route; every other path keeps the 1mb ceiling.
|
||||
const jsonBody = express.json({ limit: "1mb" });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
|
||||
|
||||
app.get("/healthz", (_req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
@@ -80,6 +86,12 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
||||
|
||||
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
|
||||
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
|
||||
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
|
||||
// limits) run BEFORE the parser so an unauthenticated caller cannot
|
||||
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
|
||||
// can actually throttle the request (2026-08 review finding C2).
|
||||
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
|
||||
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
|
||||
|
||||
@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Vector Marketplace — OpenAI Gateway</title>
|
||||
<title>Vector Marketplace - OpenAI Gateway</title>
|
||||
<style>
|
||||
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; }
|
||||
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
|
||||
<h2>1. Create an API key</h2>
|
||||
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
|
||||
<div id="signupOut"></div>
|
||||
|
||||
<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>
|
||||
<div class="row">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
|
||||
*
|
||||
* 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
|
||||
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA
|
||||
* {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
|
||||
* the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
|
||||
* collateral candidate. No-op when the wallet is already healthy.
|
||||
*/
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ function makeDeps(
|
||||
chatSettleMode: overrides?.chatSettleMode ?? "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
|
||||
572
gateway/test/ocr.test.ts
Normal file
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,
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = { queryUtxo: async () => null } as unknown as GatewayDeps["chain"];
|
||||
|
||||
@@ -70,6 +70,28 @@ describe("routing/selectSupplier", () => {
|
||||
expect(got[0].priceLovelace).toBe(2n);
|
||||
});
|
||||
|
||||
it("pins routing to an exact supplier when requested", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
model: "m",
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
supplierPkh: "s2",
|
||||
fetchFn: jsonResponse(rows),
|
||||
});
|
||||
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("orders the preferred supplier first and keeps fallback candidates", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
model: "m",
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
preferredSupplierPkh: "s2",
|
||||
fetchFn: jsonResponse(rows),
|
||||
});
|
||||
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2", "s1"]);
|
||||
});
|
||||
|
||||
it("routes chat.v1 separately", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
@@ -118,6 +140,20 @@ describe("openai/validate", () => {
|
||||
expect(p.maxTokens).toBe(50);
|
||||
expect(p.stream).toBe(false);
|
||||
});
|
||||
it("accepts and validates a Vector supplier pin", () => {
|
||||
const supplierPkh = "a".repeat(56);
|
||||
const parsed = parseChatRequest({
|
||||
model: "m",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
x_vector: { supplier_pkh: supplierPkh },
|
||||
});
|
||||
expect(parsed.supplierPkh).toBe(supplierPkh);
|
||||
expect(() => parseChatRequest({
|
||||
model: "m",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
x_vector: { supplier_pkh: "bad" },
|
||||
})).toThrow(GatewayError);
|
||||
});
|
||||
it("rejects tools/functions", () => {
|
||||
expect(() => parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], tools: [] })).toThrow(GatewayError);
|
||||
});
|
||||
@@ -208,8 +244,16 @@ describe("onchain/preflight", () => {
|
||||
});
|
||||
|
||||
it("preflight requires balance AND collateral", async () => {
|
||||
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n };
|
||||
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA
|
||||
const cost = {
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
priceLovelace: 2_000_000n,
|
||||
buyerBondLovelace: 1_000_000n,
|
||||
supplierBondLovelace: 1_000_000n,
|
||||
};
|
||||
// required = escrowLockFloor(2 + 1 + 1) + 5 (collateral) + 2 (fee) = 11 ADA
|
||||
// — at this economic total (4 ADA) the Submitted-state min-ada floor for a
|
||||
// short capability id (~2.2 ADA) stays well under the raw total, so the
|
||||
// floor is a no-op here; tx-minada-floor.test.ts covers the lifted case.
|
||||
const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
|
||||
const ok = await preflight(fakeChain, "a", cost);
|
||||
expect(ok.ok).toBe(true);
|
||||
|
||||
@@ -29,6 +29,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY indexer/package.json ./indexer/package.json
|
||||
COPY indexer-ui/package.json ./indexer-ui/package.json
|
||||
|
||||
@@ -37,5 +37,10 @@
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"libsodium-wrappers-sumo@0.7.16": "patches/libsodium-wrappers-sumo@0.7.16.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PostAdvertParams {
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
/** Durable journal hook invoked with signed CBOR before broadcast. */
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function buildPostAdvertTx(
|
||||
@@ -75,6 +77,7 @@ export async function buildPostAdvertTx(
|
||||
walletKey,
|
||||
advertDatum,
|
||||
deposit_lovelace,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,13 +105,15 @@ export async function buildPostAdvertTx(
|
||||
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
// Submit so the mock seeds the new UTxO + spends inputs (none here).
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
const built: PostAdvertBuildResult = {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
await params.beforeSubmit?.(built);
|
||||
|
||||
// Submit so the mock seeds the new UTxO + spends inputs (none here).
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ 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";
|
||||
|
||||
export interface PostChatEscrowParams {
|
||||
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
|
||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||
const promptHash = chatSessionPromptHash({ session_nonce });
|
||||
|
||||
const totalLocked =
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
|
||||
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 = []).
|
||||
// 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.
|
||||
|
||||
@@ -38,6 +38,7 @@ 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";
|
||||
|
||||
export interface PostEscrowParams {
|
||||
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
|
||||
// supplier-side validation in M1-C can verify role/content/system integrity.
|
||||
const promptHash = sha256Utf8Hex(canonicalize(messages));
|
||||
|
||||
const totalLocked =
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
|
||||
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.
|
||||
if (detectCborBackend(chain) === "live") {
|
||||
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 { 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";
|
||||
|
||||
/** 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 promptHash = ttsPromptHash(request);
|
||||
|
||||
const totalLocked =
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
|
||||
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
|
||||
// capability-agnostic — `messages` is `void`-marked there. We pass an
|
||||
// empty array so the type checker is satisfied; the actual on-chain
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
export interface ReleaseParams {
|
||||
chain: ChainProvider;
|
||||
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
|
||||
}
|
||||
|
||||
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW.
|
||||
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
|
||||
const tipSlot = await chain.tip();
|
||||
const tipMs = mockSlotToWallclockMs(tipSlot);
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
if (tipMs < threshold) {
|
||||
throw new TxConstructionError(
|
||||
@@ -75,6 +79,21 @@ export async function buildReleaseTx(
|
||||
);
|
||||
}
|
||||
|
||||
// Live path: real Cardano CBOR via lucid-evolution.
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForRelease } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForRelease({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
supplierKey,
|
||||
escrowRef,
|
||||
escrowUtxo: utxo,
|
||||
datum,
|
||||
tipMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock path: synthetic testTxBody (preserves existing test behaviour).
|
||||
const supplierDue =
|
||||
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SubmitParams {
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
receiptHash: string;
|
||||
/** Durable journal hook invoked with signed CBOR before broadcast. */
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
function isHex32(s: string): boolean {
|
||||
@@ -113,6 +115,7 @@ export async function buildSubmitTx(
|
||||
deliverBy: datum.deliver_by,
|
||||
tipMs,
|
||||
receiptHash: receiptHash.toLowerCase(),
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +141,7 @@ export async function buildSubmitTx(
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
await params.beforeSubmit?.({ txCborHex, expectedTxHash });
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
||||
export { TxConstructionError } from "./types.js";
|
||||
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
|
||||
export {
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
chatInputTokenUpperBound,
|
||||
} from "./inputBound.js";
|
||||
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
|
||||
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
|
||||
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
|
||||
export type { Signer } from "./signer.js";
|
||||
export { MockSigner } from "./signer.js";
|
||||
export type { Blueprint } from "./blueprint.js";
|
||||
@@ -37,6 +42,15 @@ export {
|
||||
} from "./escrow/postTtsEscrow.js";
|
||||
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
|
||||
export { buildPostChatEscrowTx, chatSessionPromptHash } from "./escrow/postChatEscrow.js";
|
||||
export type { PostOcrEscrowParams, OcrRequest } from "./escrow/postOcrEscrow.js";
|
||||
export {
|
||||
buildPostOcrEscrowTx,
|
||||
ocrPromptHash,
|
||||
validateOcrRequest,
|
||||
ALLOWED_OCR_MIMES,
|
||||
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
} from "./escrow/postOcrEscrow.js";
|
||||
export type { ClaimParams } from "./escrow/claim.js";
|
||||
export { buildClaimTx } from "./escrow/claim.js";
|
||||
export type { SubmitParams } from "./escrow/submit.js";
|
||||
|
||||
26
packages/shared/src/tx/inputBound.ts
Normal file
26
packages/shared/src/tx/inputBound.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ChatMessage } from "./types.js";
|
||||
import { canonicalize } from "../cbor/canonical.js";
|
||||
|
||||
/** Advert detail-URI marker for suppliers that require live input-cap preflight. */
|
||||
export const BOUNDED_INPUT_DETAIL_MARKER = "#vector-bounded-input-v1";
|
||||
|
||||
const CHAT_MESSAGE_OVERHEAD = 8;
|
||||
const CHAT_REPLY_PRIMER = 3;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Conservative, tokenizer-independent upper bound for text chat input.
|
||||
*
|
||||
* Every UTF-8 byte counts as one token unit. The fixed per-message and reply
|
||||
* overhead covers role separators and the provider chat template. Major
|
||||
* byte-fallback tokenizers cannot emit more content tokens than input bytes.
|
||||
*/
|
||||
export function chatInputTokenUpperBound(
|
||||
messages: readonly ChatMessage[],
|
||||
): number {
|
||||
return (
|
||||
CHAT_REPLY_PRIMER +
|
||||
CHAT_MESSAGE_OVERHEAD * messages.length +
|
||||
encoder.encode(canonicalize(messages)).byteLength
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
|
||||
* does not validate address network for inputs (only for outputs).
|
||||
*
|
||||
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release)
|
||||
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path.
|
||||
* Deferred builders: only updateAdvert remains on the synthetic testTxBody
|
||||
* path. postAdvert, retireAdvert, reclaim, and release all have live builders.
|
||||
*
|
||||
* Catherine M1-F-4-green.
|
||||
*/
|
||||
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
|
||||
const REDEEMER_SUBMIT = 122;
|
||||
const REDEEMER_ACCEPT = 123;
|
||||
const REDEEMER_RECLAIM = 124;
|
||||
const REDEEMER_RELEASE = 125;
|
||||
|
||||
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
|
||||
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
|
||||
@@ -100,6 +101,7 @@ export interface LiveSubmitParams {
|
||||
deliverBy: number;
|
||||
tipMs: number;
|
||||
receiptHash: string;
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveAcceptParams {
|
||||
@@ -121,11 +123,21 @@ export interface LiveReclaimParams {
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LiveReleaseParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
escrowUtxo: Utxo;
|
||||
datum: EscrowDatum;
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LivePostAdvertParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveRetireAdvertParams {
|
||||
@@ -489,6 +501,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
validFromMs: number;
|
||||
validToMs: number;
|
||||
signerPkh: string;
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(opts.chain);
|
||||
const { lucid } = await createLucidContext(provider, opts.walletKey, {
|
||||
@@ -575,6 +588,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await opts.beforeSubmit?.({ txCborHex, expectedTxHash });
|
||||
|
||||
await opts.chain.submitTx(txCborHex);
|
||||
|
||||
@@ -640,6 +654,7 @@ export async function buildLiveTxForSubmit(params: LiveSubmitParams): Promise<Bu
|
||||
validFromMs: tipMs,
|
||||
validToMs: stampMs,
|
||||
signerPkh: params.supplierKey.pubKeyHash,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -778,6 +793,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── Release ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForRelease(params: LiveReleaseParams): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(params.chain);
|
||||
const { lucid } = await createLucidContext(provider, params.supplierKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const { script, address: escrowAddress } = loadEscrowScript();
|
||||
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
|
||||
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
|
||||
const releaseRedeemerHex = encodeNullaryConstrHex(REDEEMER_RELEASE);
|
||||
|
||||
if (params.datum.submitted_at === null) {
|
||||
throw new TxConstructionError(
|
||||
"submitted_at missing",
|
||||
"Submitted-state escrow must carry a submitted_at timestamp",
|
||||
);
|
||||
}
|
||||
|
||||
// Release pays everything to the supplier: payment + supplier_bond + buyer_bond.
|
||||
const supplierDue =
|
||||
params.datum.payment_lovelace +
|
||||
params.datum.supplier_bond_lovelace +
|
||||
params.datum.buyer_bond_lovelace;
|
||||
const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue;
|
||||
const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID);
|
||||
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
// escrow.ak:handle_release checks `lower_bound >= submitted_at + ACCEPT_WINDOW`.
|
||||
// Clamp validFrom to that threshold: the Reclaim-style tip-60s back-pad alone
|
||||
// would violate the validator whenever the tip is less than 60s past the window.
|
||||
const threshold = params.datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
const validFromMs = Math.max(threshold, params.tipMs - 60_000);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = (
|
||||
spendCtx.kind === "ref"
|
||||
? lucid.newTx().readFrom([spendCtx.refUtxo])
|
||||
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
|
||||
)
|
||||
.collectFrom([lucidInput], releaseRedeemerHex)
|
||||
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
|
||||
.addSignerKey(params.supplierKey.pubKeyHash)
|
||||
.validFrom(validFromMs)
|
||||
.validTo(params.tipMs + 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
// Same UPLC eval delegation as Accept/Reclaim — lucid's CML evaluator
|
||||
// falsely rejects valid Plutus V3 spends; route to Ogmios.
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "release build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await params.chain.submitTx(txCborHex);
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── PostAdvert ──────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForAdvert(
|
||||
@@ -829,14 +915,16 @@ export async function buildLiveTxForAdvert(
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
const built: PostAdvertBuildResult = {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
await params.beforeSubmit?.(built);
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
// ─── RetireAdvert ────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -45,3 +45,16 @@ export type {
|
||||
WithdrawWalletBuildResult,
|
||||
} from "./wallet/withdrawWallet.js";
|
||||
export { buildWithdrawTx } from "./wallet/withdrawWallet.js";
|
||||
|
||||
// ── Surplus Merkle-batch anchor (live-only) ────────────────────────────
|
||||
export type {
|
||||
AnchorMetadataParams,
|
||||
SurplusSaleProofAnchorMetadata,
|
||||
} from "./wallet/anchorMetadata.js";
|
||||
export {
|
||||
MAX_TRANSACTION_METADATA_TEXT_BYTES,
|
||||
SURPLUS_SALE_PROOF_METADATA_LABEL,
|
||||
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
buildAnchorMetadataTx,
|
||||
validateSurplusSaleProofAnchorMetadata,
|
||||
} from "./wallet/anchorMetadata.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user