45 Commits

Author SHA1 Message Date
david
a5cb4b03e1 feat: backfill Vector proofs from the Surplus support export
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
2026-08-28 12:29:45 +02:00
david
a6eabe6b98 feat: publish Vector proof anchors per Base settlement 2026-08-28 12:12:54 +02:00
david
0ed594f6ba fix: tolerate omitted cache_read_tokens in live Surplus sales 2026-08-28 12:06:43 +02:00
david
59cac438f2 feat: rename dashboard to Capacity Markets with Vector sale proofs 2026-08-28 11:59:24 +02:00
david
af405cf378 fix: wrap marketplace navigation on mobile 2026-08-28 10:39:42 +02:00
david
9463bfae85 feat: move resale dashboard into marketplace 2026-08-28 10:28:39 +02:00
david
53607738f8 feat: add unified resale dashboard 2026-08-28 09:45:51 +02:00
david
cd3ea734e3 fix: run Surplus seller continuously 2026-08-28 09:27:53 +02:00
david
41820b11bb fix: keep Surplus seller active after proof 2026-08-28 09:25:02 +02:00
david
3393748972 fix: probe Surplus seller over IPv4 2026-08-27 13:38:59 +02:00
david
799c7d8256 fix: stop Surplus offer at provider spend cap 2026-08-27 13:31:50 +02:00
david
ced400f928 fix: price against all healthy Surplus offers 2026-08-27 13:26:50 +02:00
david
f79a33e7c3 fix: manage Surplus multiplier offers 2026-08-27 13:21:19 +02:00
david
ed6dbf5730 fix: quote Surplus text offers by multiplier 2026-08-27 13:16:51 +02:00
david
23ae561c16 fix: redact Surplus validation errors safely 2026-08-27 13:10:59 +02:00
david
2603820106 fix: skip incomplete Surplus discovery rows 2026-08-27 13:07:16 +02:00
david
97ae2d8110 fix: accept live Surplus discovery metadata 2026-08-27 13:02:16 +02:00
david
b714c09aca fix: fail closed across Surplus seller state 2026-08-27 12:51:15 +02:00
david
732de817aa feat: add autonomous Surplus seller 2026-08-27 12:48:18 +02:00
david
2b8409a499 feat: prefer reseller for matching gateway requests 2026-08-27 10:51:43 +02:00
david
6b9f0f2284 feat: use provider model tags for resale 2026-08-27 10:15:15 +02:00
david
27d23dd7a4 feat: link escrow references to ApexScan 2026-08-27 10:02:43 +02:00
david
e54eb55f2b feat: add balance-aware provider resale agent 2026-08-27 09:54:53 +02:00
david
8baf127c77 docs: supplier deploy runbook for self-hosted operators
Generic runbook for deploying a mainnet supplier on any operator-owned
server: own DNS/TLS via the in-repo Traefik compose, docker-run CLI forms
(no host Node), advert posted last as the go-live flag, verification via
the public indexer. Fleet-specific steps stay in HETZNER_SETUP.md.
2026-08-21 16:40:18 +02:00
david
e8261d9500 feat(deploy): swap retired Hetzner suppliers for Qwen3.8-27B pair
Hetzner Inference dropped Kimi-K2.7-Code, GLM-5.2-NVFP4 and
DeepSeek-V4-Flash-0731 from /v1/models; the 6 suppliers serving them are
deregistered (adverts retired on-chain 2026-08-21, containers removed):

  kimi-code          3f37082166faf2b2220e6476235bae8723af9a3434a6aaf60277366ff1f62bfb
  kimi-code-chat     3d1b43645c00bdb7b429f29ced798bc4af206c9dc56074e955d84b603b64f50c
  glm                bf3ee48addb32e2b0726066f1806b9d84ad64b86c7d41d379abd4b439c522696
  glm-chat           b910327023edb6a3f31696b7feaa9448a151c671c2ff303c4c3dd5a07d03c6b5
  ds-flash-htz       61dd30deb1cd3d6c3f73792bda46ecea6890735237d8329ade2b8449d298f24e
  ds-flash-htz-chat  348a278c81a2bf664ea65705a6245df31e81f64c0ffa6e04a59b248fcdae3d9f

New qwen38 + qwen38-chat suppliers serve Hetzner's new Qwen3.8-27B
(ctx 262144; ping + tools round-trip verified 2026-08-21), reusing the
retired kimi-code / kimi-code-chat wallets. Adverts:

  qwen38       bb8a7078098dc5e60b3f97b040e38f05e6cca662497d4bea471e1dff19c1e1ae#0
  qwen38-chat  31e3a6e258ceaa0d1eaabe61fa3fbe3abcbbb400a7b885e05c8dbcdcf4924eda#0
2026-08-21 13:46:03 +02:00
david
bb18578ae9 feat(buyer): move task form (chat window) above the supplier grid
Selecting a supplier used to render its form at the bottom of the
dashboard, below the card grid — chat sessions in particular ended up
off-screen. The form now renders directly under the page title, above
the grid, and clicking Use scrolls back to the top so the freshly
opened form is visible.
2026-08-21 13:16:09 +02:00
david
5221815a71 feat(buyer): PDF_ENABLED kill switch for the PDF summarizer
PDF_ENABLED=0 skips the JobStore/PdfJobDb wiring in runMain, so every
/v1/pdf-* route responds 503 (the pre-existing disabled path). Default
stays enabled; only "0"/"1" accepted, misconfig fails boot loudly.
2026-08-21 02:14:45 +02:00
dcejtlin-apex
c531e4678f Merge pull request #17 from Apex-Fusion/feat/hf-llama33-supplier
feat(deploy): add HF-router Llama-3.3-70B mainnet supplier (hf-llama33)
2026-08-18 12:15:46 +02:00
HalukG
47b3c4efcd feat(deploy): add HF-router Llama-3.3-70B mainnet supplier (hf-llama33)
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
OpenAI-compatible supplier backed by the HuggingFace Inference Providers router
(meta-llama/Llama-3.3-70B-Instruct, llm.text.generate.v1). Config only; mirrors
the glm one-off template with OPENAI_BASE_URL=https://router.huggingface.co.

- deploy/mainnet/docker-compose.supplier-hf-llama33.yml
- register the supplier wallet in monitor-wallets OPERATOR_SOURCES

The env file (supplier/.env.hf-llama33) holds the wallet key + HF token and is
never committed; wallet-monitor/wallets.json is gitignored runtime data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 14:25:14 +03:00
dcejtlin-apex
7db4de1f75 Merge pull request #14 from Apex-Fusion/fix/release-tx-builder
feat(tx): live CBOR builder for Release - Submitted strays become resolvable
2026-08-17 09:53:46 +02:00
dcejtlin-apex
133102bc31 Merge pull request #15 from Apex-Fusion/fix/gateway-mutex-deadline
fix(gateway): per-run deadline + queue bound on Mutex (08-12 freeze fix)
2026-08-17 09:45:15 +02:00
dcejtlin-apex
6fbf0d0f45 Merge pull request #16 from Apex-Fusion/fix/escrow-locked-overcount
fix(gateway): stop counting terminally-spent escrows as locked funds
2026-08-17 09:44:56 +02:00
FilipBl4gojevic
85ca58e4b2 fix(gateway): stop counting terminally-spent escrows as locked funds
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The indexer never updates escrow rows on terminal spends (Accept /
Reclaim / Release consume the UTxO without producing a new escrow
output), so /account summed rows whose funds had long moved and
locked_in_escrow_lovelace over-reported for 35+ minutes after
settlement.

The account handler now verifies each active-state row's UTxO is still
unspent (chain.queryUtxo) before counting it, capped at 32 lookups per
request with the tail counted unverified. Unparseable refs and failed
lookups count as live on purpose: errors inflate the figure rather than
hide locked funds. supplier_bond stays in the sum - the buyer fronts
all three legs at PostEscrow (see postEscrow.ts totalLocked), so it is
the key's money while the escrow lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:33:34 +02:00
FilipBl4gojevic
b3b7d6b5e1 fix(gateway): per-run deadline + queue bound on Mutex - 08-12 freeze fix
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
One chain call that never settled, run under the per-key Mutex with no
deadline, convoyed every subsequent request on that key silently while
/models and /account stayed healthy (2026-08-12 incident). The per-fetch
30s aborts in the Ogmios providers predate the freeze and did not prevent
it; the unbounded thing was the composition under the mutex.

Mutex.run() now races each fn against a deadline (default 180s): on
expiry the run rejects with MutexTimeoutError and the chain advances, so
queued work proceeds instead of freezing. A zombie that settles later can
at worst lose a UTxO-contention race with the run that replaced it -
strictly better than a permanent convoy. New work is refused with
MutexQueueFullError past a queue bound (default 64) instead of queueing
without limit. Serialization and rejection-tolerance semantics are
unchanged (covered by tests). Session locks in transcripts.ts construct
the same class and inherit both protections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:56:41 +02:00
FilipBl4gojevic
d5fdd1f1b7 feat(tx): live CBOR builder for Release - Submitted strays become resolvable
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Release was the one escrow spend path still hardwired to the synthetic
testTxBody backend, so suppliers had no way to resolve Submitted escrows
past the accept window on mainnet (the sweeper parks them 'for supplier
Release'). Adds buildLiveTxForRelease mirroring the Reclaim builder
(supplier payout, Constr4/tag 125 redeemer, Ogmios UPLC eval) and the
detectCborBackend dispatch in buildReleaseTx, reclaim.ts-style.

validFrom clamps to submitted_at + ACCEPT_WINDOW rather than tip-60s
alone: the back-pad by itself violates the validator whenever the tip is
less than 60s past the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:50:51 +02:00
david
019766ab8d fix(deploy): retarget inference-proxy suppliers to new tailnet (teamhaleight@)
The tailnet account changed (caslav.nedeljkovic@ to teamhaleight@,
taild99fee.ts.net) and all device IPs were reassigned, breaking both
inference-proxy suppliers' hardcoded backend addresses.

- supplier-local: vuk llama.cpp 100.82.111.46 to 100.77.146.49.
- supplier-openclaw: the openclaw gateway is now exposed via Tailscale
  Serve (David's reconfigure set gateway.tailscale.mode=serve, which
  forces bind=loopback). Switch OPENAI_BASE_URL from the old raw
  http endpoint to https://web-tools.taild99fee.ts.net, and pin that
  MagicDNS name to web-tools' tailnet IP (100.105.36.0) via extra_hosts
  (Serve's TLS cert is for the hostname, and containers don't inherit
  the host tailscale MagicDNS).
- README: new account/IP table, Serve config (replaces the tailnet-bind
  section), reachability check (the old ACL blocker is gone), updated
  probe/runbook.

Verified: authenticated turn to the Serve endpoint from inference-proxy
returns a completion; vuk:8002 reachable from inference-proxy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:55:43 +02:00
david
5c9da7639d feat(deploy): inference-proxy supplier 'openclaw' — OpenClaw web-research agent
Second supplier on the inference-proxy box, proxying to an OpenClaw gateway
(agent 'main') on the web-tools KVM guest over the tailnet
(100.101.143.72:18789). Same vuk llama.cpp brain as supplier 'local'; the
product is the agent loop (searxng web search + web fetch + browser).
Capability llm.chat.v1, advertised model 'openclaw-web-agent', 0.2 AP3X,
ticket settle mode, MAX_CHAT_SESSIONS=1.

Uses the new stateful-upstream contract: OPENAI_SESSION_PASSTHROUGH=1 (escrow
ref as OpenAI 'user' → sticky per-escrow openclaw session, delta-only sends)
and OPENAI_MODEL_OVERRIDE=openclaw (endpoint only accepts 'openclaw').

- docker-compose.supplier-openclaw.yml: new supplier compose (traefik host
  mp-suppliers-openclaw.vector.apexfusion.org).
- README: gateway config (bind=tailnet, chatCompletions on, GLOBAL tool
  allow+deny lockdown), the tailnet-ACL blocker (inference-proxy can't yet
  reach web-tools:18789), bring-up runbook, and the sudo-gated browser +
  egress-firewall follow-ups.
- monitor-wallets.ts: register supplier-openclaw@inference-proxy.

Wallet generated + funded (200 AP3X); .env.openclaw written on the host.
Not yet advertised — go-live (post-advert) waits on the ACL + a healthy
end-to-end path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:56:23 +02:00
david
843ac7cbdd feat(supplier): stateful-upstream contract for chat-session (OpenClaw)
Two opt-in envs preparing the openclaw supplier on inference-proxy:

OPENAI_SESSION_PASSTHROUGH=1 — chat-session turns speak the stateful-
upstream contract: the escrow ref is sent as the OpenAI `user` field
(OpenClaw keys one persistent agent session per `user`, so browser/
tool state survives across turns) and only the current turn's delta
goes upstream — the upstream session carries history, and re-sending
the full transcript would duplicate it into the upstream context every
turn (OpenClaw renders ALL request messages into the new agent turn).
The local transcript still accumulates fully for the receipt. Without
`user`, OpenClaw creates an ephemeral session per request (uuid key),
so default-off stays safe for stateless providers.

OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
advert's buyer-facing model. OpenClaw's chat-completions endpoint 400s
on anything but "openclaw" / "openclaw/<agentId>", while the advert
must carry a unique fleet-routable id (openclaw-web-agent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:15:13 +02:00
david
48ad639d12 feat(deploy): switch supplier 'local' to chat-session capability (llm.chat.v1)
MAX_CHAT_SESSIONS=1 — vuk is a single-slot ~4.5 tok/s rig, one session
owns it at a time. Idle timeout / settle mode mirror the Hetzner -chat
fleet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:04:20 +02:00
david
4047be04a3 chore(monitor): register supplier-local@inference-proxy wallet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 11:01:22 +02:00
david
23157d62a3 fix(deploy): pin traefik v3.6 + DOCKER_API_VERSION on inference-proxy — Docker 29 rejects API 1.24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 10:30:52 +02:00
david
3ee09f1a9a feat(deploy): inference-proxy box — supplier 'local' backed by llama.cpp on vuk via tailscale
New manually-deployed host (62.238.38.167) with its own in-repo traefik
project (LE HTTP-01) and one text supplier proxying to the home rig at
100.82.111.46:8002. Outside CD by design; runbook in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 10:28:13 +02:00
dcejtlin-apex
44abbdffdc Merge pull request #13 from Apex-Fusion/fix/hetzner-dns-docs
docs(deploy): correct DNS guidance for Hetzner suppliers — no wildcard exists
2026-08-11 16:04:27 +02:00
david
b0c83ca985 docs(deploy): correct DNS guidance for Hetzner suppliers — no wildcard exists
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The PR #12 runbook and compose pre-flight headers claimed a wildcard
*.vector.apexfusion.org record covers new supplier hosts. It does not:
each mp-suppliers-* host is an individual DNS-only A record to
91.98.147.172, and on-box Let's Encrypt HTTP-01 issuance requires the
record to be unproxied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:53:07 +02:00
dcejtlin-apex
bfcb0c46b8 Merge pull request #12 from Apex-Fusion/feat/hetzner-suppliers
feat(deploy): Hetzner Inference supplier fleet — 4 models × {text, chat}
2026-08-11 14:32:40 +02:00
108 changed files with 13627 additions and 539 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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,
});

View File

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

View File

@@ -80,6 +80,8 @@ import {
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";
@@ -88,6 +90,7 @@ import type {
DiscoverSuppliersOptions,
SubmitPromptOptions,
SubmitPromptResult,
SupplierCapabilityView,
SubmitTtsOptions,
SubmitTtsResult,
SubmitOcrOptions,
@@ -110,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}$/;
@@ -241,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({
@@ -279,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,
@@ -321,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);
@@ -347,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) {
@@ -385,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.
@@ -423,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;

View File

@@ -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;
}
/**

View File

@@ -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(

View File

@@ -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 />} />

View File

@@ -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>
);
}

View 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>
);
}

View 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`).

View 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

View 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

View 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

View File

@@ -82,13 +82,14 @@ project_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.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
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=()
@@ -128,7 +129,7 @@ done
drain_supplier() { # drain_supplier <compose-file>
[ "$DRAIN_TIMEOUT_SECS" = "0" ] && return 0
local cid deadline status
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q | head -1)
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

View File

@@ -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

View File

@@ -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.

View File

@@ -1,88 +0,0 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-htz.yml — Supplier — MAINNET
# (Hetzner Inference / DeepSeek-V4-Flash-0731 — 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-ds-flash-htz.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# 2. supplier/.env.ds-flash-htz 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-ds-flash-htz
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-htz
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.ds-flash-htz
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.ds-flash-htz 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-ds-flash-htz-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.rule=Host(`mp-suppliers-ds-flash-htz.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.middlewares=mp-suppliers-ds-flash-htz-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-htz.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-htz-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -1,100 +0,0 @@
# deploy/mainnet/docker-compose.supplier-glm-chat.yml — Supplier — MAINNET
# (Hetzner Inference / GLM-5.2-NVFP4 — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-glm.yml. Same
# image, same Hetzner backend (GLM-5.2-NVFP4), 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-glm-chat.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# 2. supplier/.env.glm-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-glm-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-glm-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.glm-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.glm-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-glm-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.rule=Host(`mp-suppliers-glm-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.middlewares=mp-suppliers-glm-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-glm-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-glm-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.rule=Host(`mp-suppliers-glm-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-glm-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

View File

@@ -1,88 +0,0 @@
# deploy/mainnet/docker-compose.supplier-glm.yml — Supplier — MAINNET
# (Hetzner Inference / GLM-5.2-NVFP4 — 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-glm.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# 2. supplier/.env.glm 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-glm
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-glm
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.glm
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.glm 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-glm-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-glm-mainnet.rule=Host(`mp-suppliers-glm.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-mainnet.middlewares=mp-suppliers-glm-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-glm-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-glm-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.rule=Host(`mp-suppliers-glm.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-glm-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,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

View File

@@ -1,100 +0,0 @@
# deploy/mainnet/docker-compose.supplier-kimi-code-chat.yml — Supplier — MAINNET
# (Hetzner Inference / Kimi-K2.7-Code — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-kimi-code.yml. Same
# image, same Hetzner backend (Kimi-K2.7-Code), 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-kimi-code-chat.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# 2. supplier/.env.kimi-code-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-kimi-code-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-code-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.kimi-code-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.kimi-code-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-kimi-code-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.rule=Host(`mp-suppliers-kimi-code-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.middlewares=mp-suppliers-kimi-code-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-code-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-code-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

View 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

View File

@@ -15,7 +15,8 @@
#
# Pre-flight:
# 1. DNS: mp-suppliers-qwen35b-chat.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# (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,

View File

@@ -10,7 +10,8 @@
#
# Pre-flight:
# 1. DNS: mp-suppliers-qwen35b.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# (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,

View File

@@ -1,8 +1,8 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-htz-chat.yml — Supplier — MAINNET
# (Hetzner Inference / DeepSeek-V4-Flash-0731 — MULTI-TURN CHAT capability `llm.chat.v1`)
# 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-ds-flash-htz.yml. Same
# image, same Hetzner backend (DeepSeek-V4-Flash-0731), but CAPABILITY_KIND=chat-session so
# 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.
#
@@ -10,13 +10,16 @@
# 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.
# 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-ds-flash-htz-chat.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# 2. supplier/.env.ds-flash-htz-chat written on host (chmod 600) with at minimum:
# 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,
@@ -27,14 +30,14 @@
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-ds-flash-htz-chat
name: marketplace-mainnet-supplier-qwen38-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-htz-chat
container_name: marketplace-mainnet-supplier-qwen38-chat
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
@@ -42,7 +45,7 @@ services:
# 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.ds-flash-htz-chat
- path: ../../supplier/.env.qwen38-chat
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
@@ -63,7 +66,7 @@ services:
# 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.ds-flash-htz-chat on a mainnet host.
# 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
@@ -76,15 +79,15 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.rule=Host(`mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.middlewares=mp-suppliers-ds-flash-htz-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-htz-chat-mainnet.loadbalancer.server.port=8080"
- "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:

View File

@@ -1,17 +1,20 @@
# deploy/mainnet/docker-compose.supplier-kimi-code.yml — Supplier — MAINNET
# (Hetzner Inference / Kimi-K2.7-Code — one-off `llm.text.generate.v1`)
# 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 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
# 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-kimi-code.vector.apexfusion.org → host
# (wildcard *.vector.apexfusion.org already in place via Cloudflare)
# 2. supplier/.env.kimi-code written on host (chmod 600) with at minimum:
# 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,
@@ -20,14 +23,14 @@
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-kimi-code
name: marketplace-mainnet-supplier-qwen38
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-code
container_name: marketplace-mainnet-supplier-qwen38
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
@@ -35,7 +38,7 @@ services:
# 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.kimi-code
- path: ../../supplier/.env.qwen38
required: false
environment:
LLM_BACKEND: openai
@@ -51,7 +54,7 @@ services:
# 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.kimi-code on a mainnet host.
# 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
@@ -64,15 +67,15 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.rule=Host(`mp-suppliers-kimi-code.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.middlewares=mp-suppliers-kimi-code-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.rule=Host(`mp-suppliers-kimi-code.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-code-mainnet.loadbalancer.server.port=8080"
- "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:

View 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

View 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"
}

291
docs/DEPLOY_NEW_SUPPLIER.md Normal file
View 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`.

View File

@@ -1,8 +1,13 @@
# Hetzner Inference Supplier Fleet — Setup Runbook
> **Status:** shipped. Configures 8 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.
> **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.
---
@@ -15,19 +20,21 @@ upstream client (`supplier/src/openai.ts`) already speaks plain
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) — 4 models, both the one-off
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 |
|---|---|---|---|
| `kimi-code` | `Kimi-K2.7-Code` | `llm.text.generate.v1` | 262144 |
| `kimi-code-chat` | `Kimi-K2.7-Code` | `llm.chat.v1` | 262144 |
| `glm` | `GLM-5.2-NVFP4` | `llm.text.generate.v1` | 512000 |
| `glm-chat` | `GLM-5.2-NVFP4` | `llm.chat.v1` | 512000 |
| `ds-flash-htz` | `DeepSeek-V4-Flash-0731` | `llm.text.generate.v1` | 512000 |
| `ds-flash-htz-chat` | `DeepSeek-V4-Flash-0731` | `llm.chat.v1` | 512000 |
| `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**
@@ -36,41 +43,47 @@ for one-off / **1800000** for chat (the session spans the whole conversation).
forwarded upstream (`OPENAI_MAX_TOKENS` unset), so the advert value only gates
what buyers may request.
Names follow the brand-by-model convention; `ds-flash-htz` carries a `-htz`
suffix only because `deepseek-flash-*` was already taken by the OpenRouter
fleet serving `deepseek/deepseek-v4-flash`.
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 8 suppliers; Hetzner
choice, not cost-derived). One key is shared by all suppliers; Hetzner
rate limits apply to the aggregate.
2. Eight funded supplier wallets (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`); the
wildcard DNS `*.vector.apexfusion.org` already covers the new hosts.
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 four model ids come from `GET https://inference.hetzner.com/api/v1/models`
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":"GLM-5.2-NVFP4","messages":[{"role":"user","content":"ping"}]}' \
-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. All four models passed both checks
on 2026-08-11.
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 ×8)
## 4. Per-supplier bring-up (repeat per supplier)
```bash
# 1. wallet — prints privateKeyHex, publicKeyHex, pubKeyHash, address

View File

@@ -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:*",

View File

@@ -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")) +

View 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;
});
}

View File

@@ -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;
@@ -56,6 +57,8 @@ export interface GatewayConfig {
/** 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 {
@@ -115,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,
@@ -142,6 +156,9 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
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(/\/+$/, ""))

View File

@@ -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) {
@@ -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,

View File

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

View File

@@ -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,
};
}

View File

@@ -58,6 +58,10 @@ export interface SelectSupplierOpts {
* 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
@@ -86,6 +90,9 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
for (const raw of body as SupplierView[]) {
if (raw.capability_id !== opts.capabilityId) 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;
@@ -106,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;
}

View File

@@ -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;
}
}

View File

@@ -41,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") {

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 };

View File

@@ -5,6 +5,10 @@
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";

View 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
);
}

View File

@@ -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 ────────────────────────────────────────────────────

View File

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

View File

@@ -0,0 +1,166 @@
/**
* Builds and submits the live wallet transaction that anchors one Surplus sale
* Merkle batch. The caller remains responsible for awaiting confirmation.
*/
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
import { createLucidContext } from "../internal/lucidContext.js";
import type { BuildResult, WalletKey } from "../types.js";
import { TxConstructionError } from "../types.js";
export const SURPLUS_SALE_PROOF_METADATA_LABEL = 674;
export const SURPLUS_SALE_PROOF_PROTOCOL = "surplus-sale-proof-v1";
export const MAX_TRANSACTION_METADATA_TEXT_BYTES = 64;
export interface SurplusSaleProofAnchorMetadata {
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
root: string;
count: number;
first: string;
last: string;
}
export interface AnchorMetadataParams {
chain: LiveOgmiosProvider;
walletKey: WalletKey;
metadata: SurplusSaleProofAnchorMetadata;
}
const ROOT_HEX_RE = /^[0-9a-f]{64}$/;
const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/;
const METADATA_KEYS = ["p", "root", "count", "first", "last"] as const;
const textEncoder = new TextEncoder();
function metadataTextFits(value: string): boolean {
return textEncoder.encode(value).byteLength <= MAX_TRANSACTION_METADATA_TEXT_BYTES;
}
function validSaleId(value: unknown): value is string {
return (
typeof value === "string" &&
PRINTABLE_ASCII_RE.test(value) &&
metadataTextFits(value)
);
}
/** Validate the exact label-674 payload before Lucid or Ogmios is initialized. */
export function validateSurplusSaleProofAnchorMetadata(
value: unknown,
): asserts value is SurplusSaleProofAnchorMetadata {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"Surplus sale proof metadata must be an object",
);
}
const metadata = value as Record<string, unknown>;
const keys = Object.keys(metadata);
if (
keys.length !== METADATA_KEYS.length ||
METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(metadata, key))
) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"Surplus sale proof metadata must contain only p, root, count, first, and last",
);
}
if (metadata.p !== SURPLUS_SALE_PROOF_PROTOCOL) {
throw new TxConstructionError(
"anchor_metadata_invalid",
`metadata protocol must be ${SURPLUS_SALE_PROOF_PROTOCOL}`,
);
}
if (!metadataTextFits(SURPLUS_SALE_PROOF_PROTOCOL)) {
throw new TxConstructionError(
"anchor_metadata_oversized",
"metadata protocol exceeds the 64-byte transaction metadata text limit",
);
}
if (typeof metadata.root !== "string" || !ROOT_HEX_RE.test(metadata.root)) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"metadata root must be 64 lowercase hexadecimal characters",
);
}
if (!metadataTextFits(metadata.root)) {
throw new TxConstructionError(
"anchor_metadata_oversized",
"metadata root exceeds the 64-byte transaction metadata text limit",
);
}
if (
typeof metadata.count !== "number" ||
!Number.isSafeInteger(metadata.count) ||
metadata.count <= 0
) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"metadata count must be a positive safe integer",
);
}
for (const field of ["first", "last"] as const) {
const id = metadata[field];
if (!validSaleId(id)) {
const oversized =
typeof id === "string" &&
textEncoder.encode(id).byteLength > MAX_TRANSACTION_METADATA_TEXT_BYTES;
throw new TxConstructionError(
oversized ? "anchor_metadata_oversized" : "anchor_metadata_invalid",
`metadata ${field} must be a non-empty printable ASCII sale ID of at most 64 bytes`,
);
}
}
}
export async function buildAnchorMetadataTx(
params: AnchorMetadataParams,
): Promise<BuildResult> {
const { chain, walletKey, metadata } = params;
validateSurplusSaleProofAnchorMetadata(metadata);
const provider = new OgmiosLucidProvider({
ogmiosUrl: chain.url,
fetch: chain.fetchImpl,
});
const { lucid } = await createLucidContext(
provider,
walletKey,
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
{ usePresetProtocolParameters: true },
);
const realWalletUtxos = await lucid.wallet().getUtxos();
const anchorMetadata = {
p: metadata.p,
root: metadata.root,
count: metadata.count,
first: metadata.first,
last: metadata.last,
};
let signed;
try {
const txBuilder = lucid
.newTx()
.attachMetadata(SURPLUS_SALE_PROOF_METADATA_LABEL, anchorMetadata)
.addSignerKey(walletKey.pubKeyHash)
.setMinFee(500_000n);
const completed = await txBuilder.complete({
presetWalletInputs: realWalletUtxos,
localUPLCEval: false,
});
signed = await completed.sign.withWallet().complete();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new TxConstructionError("anchor_metadata_build_failed", message);
}
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await chain.submitTx(txCborHex);
return { txCborHex, expectedTxHash };
}

116
pnpm-lock.yaml generated
View File

@@ -321,10 +321,16 @@ importers:
express:
specifier: ^4.21.2
version: 4.22.1
pg:
specifier: ^8.16.3
version: 8.23.0
devDependencies:
'@types/express':
specifier: ^5.0.3
version: 5.0.6
'@types/pg':
specifier: ^8.15.5
version: 8.23.1
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
@@ -1244,6 +1250,9 @@ packages:
'@types/pdf-parse@1.1.5':
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -2360,6 +2369,40 @@ packages:
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
engines: {node: '>=6.8.1'}
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
pg-connection-string@2.14.0:
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.14.0:
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.16.0:
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.23.0:
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2430,6 +2473,22 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -2640,6 +2699,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
@@ -3863,6 +3926,12 @@ snapshots:
dependencies:
'@types/node': 25.6.0
'@types/pg@8.23.1':
dependencies:
'@types/node': 25.6.0
pg-protocol: 1.16.0
pg-types: 2.2.0
'@types/prop-types@15.7.15': {}
'@types/qs@6.15.0': {}
@@ -4947,6 +5016,41 @@ snapshots:
dependencies:
node-ensure: 0.0.0
pg-cloudflare@1.4.0:
optional: true
pg-connection-string@2.14.0: {}
pg-int8@1.0.1: {}
pg-pool@3.14.0(pg@8.23.0):
dependencies:
pg: 8.23.0
pg-protocol@1.16.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.23.0:
dependencies:
pg-connection-string: 2.14.0
pg-pool: 3.14.0(pg@8.23.0)
pg-protocol: 1.16.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.4.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -4997,6 +5101,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
@@ -5282,6 +5396,8 @@ snapshots:
source-map-js@1.2.1: {}
split2@4.2.0: {}
sprintf-js@1.1.3: {}
stackback@0.0.2: {}

View File

@@ -132,3 +132,51 @@ ADVERT_DETAIL_HASH=
# MAX_CHAT_SESSIONS=1 # concurrent session slots (default 1)
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit) or "ticket" (no supplier
# # chain ops; buyer reclaims the Open escrow)
# ─── Autonomous Surplus seller (surplus:start) ────────────────────────────
# This service uses a seller-scoped Surplus key. It does not need a wallet
# private key. Keep both API keys in the host-only surplus-seller.env file.
SURPLUS_LIVE=
SURPLUS_API_URL=https://api.surplusintelligence.ai
SURPLUS_SELLER_API_KEY=si_seller_REPLACE_ME
SURPLUS_OPENROUTER_API_KEY=sk-or-v1-REPLACE_ME
SURPLUS_PROVIDER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_CAPACITY_BASE_URL=https://openrouter.ai/api
SURPLUS_SELLER_WALLET=0x0000000000000000000000000000000000000000
SURPLUS_PAYOUT_ADDRESS=0x0000000000000000000000000000000000000000
# The controller discovers all fixed-price text models and selects one by
# current demand. It keeps only one active offer. At the default 490 bps
# recovery floor, a $0.05 seller cap bounds upstream spend near $1.02 per day.
SURPLUS_PER_OFFER_CAP_USD=0.05
SURPLUS_AGGREGATE_CAP_USD=0.05
SURPLUS_OPENROUTER_RESERVE_USD=1.00
SURPLUS_OPENROUTER_MAX_LIMIT_USD=20.00
SURPLUS_MIN_RECOVERY_BPS=490
SURPLUS_UNDERCUT_BPS=10
SURPLUS_MAX_CANDIDATE_ORDER_BOOKS=10
SURPLUS_REPRICE_INTERVAL_MS=300000
SURPLUS_REQUEST_TIMEOUT_MS=120000
# 0 keeps selling. 1 pauses after the first trade or one offer-cap of spend.
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0
SURPLUS_SETTLED_STATUSES=confirmed
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json
# --- Vector proof publisher ------------------------------------------------
# When enabled, every settled Base settlement transaction gets one Vector
# counterpart: the sales it settled are Merkle-batched and anchored in a
# label-674 metadata transaction signed by the proof wallet below.
SURPLUS_PROOF_ENABLED=
# Required when enabled. Mainnet Ogmios endpoint and the 64-hex Ed25519 key
# of the AP3X proof wallet (the retired Vector supplier wallet).
OGMIOS_URL=
SURPLUS_PROOF_WALLET_PRIV_KEY_HEX=
SURPLUS_PROOF_LEDGER_PATH=/var/lib/surplus-manager/vector-proofs.json
# Anchoring pauses when the wallet cannot keep this reserve after one fee.
SURPLUS_PROOF_RESERVE_LOVELACE=5000000
SURPLUS_PROOF_FEE_BUDGET_LOVELACE=1000000
SURPLUS_PROOF_INTERVAL_MS=300000
SURPLUS_PROOF_CONFIRM_TIMEOUT_MS=300000
# Optional support-export backfill. The CSV must have the exact header:
# request_id,settled_at,model,input_tokens,output_tokens,seller_earned_usd,settlement_status,settlement_type,tx_hash
# The offer id is stamped into each export leaf because the export omits it.
SURPLUS_PROOF_HISTORY_CSV_PATH=
SURPLUS_PROOF_HISTORY_OFFER_ID=

View File

@@ -70,6 +70,9 @@ COPY --from=builder /repo /repo
# Drop privileges. The official node image ships a non-root `node` user.
RUN chown -R node:node /repo
RUN mkdir -p /var/lib/surplus-manager \
&& chown node:node /var/lib/surplus-manager \
&& chmod 700 /var/lib/surplus-manager
USER node
ENV NODE_ENV=production

View File

@@ -9,17 +9,21 @@
"tx:post-advert": "tsx src/cli/post-advert.ts",
"tx:retire-advert": "tsx src/cli/retire-advert.ts",
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts",
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts"
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts",
"reseller:setup": "tsx src/cli/setup-reseller.ts",
"surplus:start": "tsx src/cli/surplus-seller.ts"
},
"dependencies": {
"@marketplace/shared": "workspace:*",
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^1.8.0",
"bech32": "^2.0.0",
"express": "^4.21.2"
"express": "^4.21.2",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/pg": "^8.15.5",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
"typescript": "^5.7.2"

View File

@@ -15,6 +15,7 @@ import {
detectCborBackend,
mockSlotToWallclockMs,
TxConstructionError,
type PostAdvertBuildResult,
type WalletKey,
} from "@marketplace/shared/tx";
@@ -34,6 +35,8 @@ export interface PostAdvertFlowParams {
* in that order. Defaults to console.log.
*/
log?: (line: string) => void;
/** Durable journal hook invoked with the signed transaction before broadcast. */
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
}
export interface PostAdvertFlowResult {
@@ -110,6 +113,7 @@ export async function runPostAdvert(
walletKey,
advertDatum: datumToSubmit,
deposit_lovelace: datumToSubmit.supplier_bond_lovelace,
beforeSubmit: params.beforeSubmit,
});
writeLog(`submitted tx ${built.expectedTxHash}`);

View File

@@ -0,0 +1,486 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { chmod, mkdir, open, rename } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveOgmiosProvider, type OutputReference } from "@marketplace/shared/chain";
import { decodeAdvertDatum, type AdvertDatum } from "@marketplace/shared/cbor";
import {
BOUNDED_INPUT_DETAIL_MARKER,
loadBlueprint,
type WalletKey,
} from "@marketplace/shared/tx";
import { deriveKeypair } from "./gen-keypair.js";
import { runPostAdvert } from "./postAdvertFlow.js";
import { callOpenAi } from "../openai.js";
import { OpenRouterCapacityProvider } from "../reseller/openRouterCapacity.js";
import {
loadResellerSetupConfig,
type ResellerSetupConfig,
} from "../reseller/setupConfig.js";
interface CliOptions {
configPath: string;
startStack: boolean;
}
interface SetupState extends Record<string, string> {
SUPPLIER_PRIV_KEY_HEX: string;
SUPPLIER_PUB_KEY_HEX: string;
SUPPLIER_PKH: string;
SUPPLIER_ADDRESS: string;
POSTGRES_PASSWORD: string;
OPENAI_API_KEY: string;
ADVERT_REF: string;
}
interface SetupResult {
supplierAddress: string;
supplierPkh: string;
advertRef: string;
endpointUrl: string;
dashboardUrl: string;
marketplaceModel: string;
providerModel: string;
stackStarted: boolean;
}
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const SHA256_EMPTY = createHash("sha256").update("").digest("hex");
const MAINNET_ZERO_TIME_MS = "1756485600000";
const MAINNET_ESCROW_REF =
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0";
const MAINNET_ADVERT_REF =
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1";
const POSTGRES_STATE_FILE =
"/var/lib/agents-marketplace/openrouter-reseller-postgres.env";
const DEFAULT_STATE_FILE = "/var/lib/agents-marketplace/openrouter-reseller.env";
const RESELLER_HOSTNAME =
"mp-suppliers-openrouter-reseller.vector.apexfusion.org";
export function parseSetupArgs(argv: string[]): CliOptions {
let configPath = "";
let startStack = true;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--config") {
const value = argv[index + 1];
if (!value) throw new Error("--config requires a path");
configPath = value;
index += 1;
} else if (arg === "--no-start") {
startStack = false;
} else {
throw new Error(`unknown setup argument: ${arg}`);
}
}
if (configPath === "") throw new Error("--config is required");
return { configPath, startStack };
}
export async function runResellerSetup(
options: CliOptions,
env: Record<string, string | undefined>,
log: (line: string) => void = (line) => console.error(line),
): Promise<SetupResult> {
const config = await loadResellerSetupConfig(
resolve(REPO_ROOT, options.configPath),
);
if (new URL(config.endpointUrl).hostname !== RESELLER_HOSTNAME) {
throw new Error(`endpointUrl host must be ${RESELLER_HOSTNAME}`);
}
const statePath = resolveStatePath(env.RESELLER_STATE_FILE, options.startStack);
process.env.VECTOR_ZERO_TIME_MS = MAINNET_ZERO_TIME_MS;
process.env.ESCROW_REF_UTXO = MAINNET_ESCROW_REF;
process.env.ADVERT_REF_UTXO = MAINNET_ADVERT_REF;
const prior = await readState(statePath);
const privateKey = prior.SUPPLIER_PRIV_KEY_HEX ?? randomBytes(32).toString("hex");
const derived = deriveKeypair(privateKey, config.networkId);
const postgresPassword = prior.POSTGRES_PASSWORD ?? randomBytes(24).toString("hex");
const openRouterKey = env.OPENROUTER_API_KEY ?? prior.OPENAI_API_KEY;
if (!openRouterKey) {
throw new Error("OPENROUTER_API_KEY is required on the first setup run");
}
const runtimeState = buildRuntimeState(
config,
{
privateKeyHex: derived.privateKeyHex,
pubKeyHex: derived.publicKeyHex,
pubKeyHash: derived.pubKeyHash,
address: derived.address,
},
postgresPassword,
openRouterKey,
prior.ADVERT_REF ?? "",
);
await writeState(statePath, runtimeState);
await writeState(POSTGRES_STATE_FILE, {
POSTGRES_USER: runtimeState.POSTGRES_USER,
POSTGRES_DB: runtimeState.POSTGRES_DB,
POSTGRES_PASSWORD: runtimeState.POSTGRES_PASSWORD,
});
log("checking capped OpenRouter allowance and model pricing");
const capacity = new OpenRouterCapacityProvider({
baseUrl: OPENROUTER_BASE_URL,
apiKey: openRouterKey,
timeoutMs: config.providerTimeoutMs,
});
await capacity.readCapacity(config.openRouterModel);
log("running one-token OpenRouter inference probe");
await callOpenAi({
baseUrl: OPENROUTER_BASE_URL,
apiKey: openRouterKey,
model: config.openRouterModel,
messages: [{ role: "user", content: "Reply OK" }],
maxTokens: 1,
disableReasoning: true,
timeoutMs: config.inferenceTimeoutMs,
});
const chain = new LiveOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
log(`seller wallet ${derived.address}`);
await waitForFunding(
chain,
derived.address,
config.minimumWalletLovelace,
config.fundingTimeoutMs,
log,
);
const walletKey: WalletKey = {
privateKeyHex: derived.privateKeyHex,
pubKeyHex: derived.publicKeyHex,
pubKeyHash: derived.pubKeyHash,
address: derived.address,
};
const advert = buildAdvert(config, walletKey);
const advertRef = await findOrPostAdvert(
chain,
walletKey,
advert,
runtimeState.ADVERT_REF,
log,
async (pendingRef) => {
runtimeState.ADVERT_REF = `${pendingRef.txHash}#${pendingRef.index}`;
await writeState(statePath, runtimeState);
},
);
runtimeState.ADVERT_REF = `${advertRef.txHash}#${advertRef.index}`;
await writeState(statePath, runtimeState);
if (options.startStack) {
log("starting reseller and Postgres stack");
await runCompose(config.composeFile);
}
return {
supplierAddress: walletKey.address,
supplierPkh: walletKey.pubKeyHash,
advertRef: runtimeState.ADVERT_REF,
endpointUrl: config.endpointUrl,
dashboardUrl: `${config.endpointUrl}/reseller`,
marketplaceModel: config.marketplaceModel,
providerModel: config.openRouterModel,
stackStarted: options.startStack,
};
}
function buildRuntimeState(
config: ResellerSetupConfig,
wallet: WalletKey,
postgresPassword: string,
openRouterKey: string,
advertRef: string,
): SetupState {
const hostname = new URL(config.endpointUrl).hostname;
return {
SUPPLIER_PRIV_KEY_HEX: wallet.privateKeyHex,
SUPPLIER_PUB_KEY_HEX: wallet.pubKeyHex,
SUPPLIER_PKH: wallet.pubKeyHash,
SUPPLIER_ADDRESS: wallet.address,
POSTGRES_USER: "reseller",
POSTGRES_DB: "reseller",
POSTGRES_PASSWORD: postgresPassword,
OPENAI_API_KEY: openRouterKey,
ADVERT_REF: advertRef,
OGMIOS_URL: config.ogmiosUrl,
NETWORK_ID: String(config.networkId),
LIVE_CHAIN: "1",
CAPABILITY_KIND: "chat",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: OPENROUTER_BASE_URL,
OPENAI_MODEL_OVERRIDE: config.openRouterModel,
OPENAI_MAX_TOKENS: String(config.maxOutputTokens),
OPENAI_REASONING: "off",
OPENAI_TIMEOUT_MS: String(config.inferenceTimeoutMs),
RESELLER_PROVIDER: "openrouter",
RESELLER_DATABASE_URL: `postgresql://reseller:${postgresPassword}@postgres:5432/reseller`,
RESELLER_RESERVE_USD: config.reserveUsd,
RESELLER_MAX_INPUT_TOKENS: String(config.maxInputTokens),
RESELLER_POLL_INTERVAL_MS: String(config.pollIntervalMs),
RESELLER_PROVIDER_TIMEOUT_MS: String(config.providerTimeoutMs),
RESELLER_PREVIEW_MAX_CHARS: String(config.previewMaxChars),
RESELLER_HOSTNAME: hostname,
RESELLER_ENDPOINT_URL: config.endpointUrl,
RESELLER_MARKETPLACE_MODEL: config.marketplaceModel,
RESELLER_PROVIDER_MODEL: config.openRouterModel,
PORT: "8080",
WALLET_HEALTH_INTERVAL_MS: "600000",
VECTOR_ZERO_TIME_MS: MAINNET_ZERO_TIME_MS,
ESCROW_REF_UTXO: MAINNET_ESCROW_REF,
ADVERT_REF_UTXO: MAINNET_ADVERT_REF,
};
}
function buildAdvert(config: ResellerSetupConfig, wallet: WalletKey): AdvertDatum {
return {
supplier_pkh: wallet.pubKeyHash,
capability_id: "llm.text.generate.v1",
model: config.marketplaceModel,
max_output_tokens: config.maxOutputTokens,
max_processing_ms: config.maxProcessingMs,
price_lovelace: config.priceLovelace,
supplier_bond_lovelace: config.supplierBondLovelace,
buyer_bond_lovelace: config.buyerBondLovelace,
endpoint_url: config.endpointUrl,
detail_uri: BOUNDED_INPUT_DETAIL_MARKER,
detail_hash: SHA256_EMPTY,
advertised_at: 0,
status: "Active",
};
}
async function findOrPostAdvert(
chain: LiveOgmiosProvider,
walletKey: WalletKey,
expected: AdvertDatum,
savedRef: string,
log: (line: string) => void,
journal: (ref: OutputReference) => Promise<void>,
): Promise<OutputReference> {
const parsedSaved = parseRef(savedRef);
if (parsedSaved) {
const utxo = await chain.queryUtxo(parsedSaved);
if (utxo?.datumHex) {
assertAdvertMatches(decodeAdvertDatum(utxo.datumHex), expected);
log(`reusing saved advert ${savedRef}`);
return parsedSaved;
}
try {
await chain.awaitTx(parsedSaved.txHash, 120_000);
const confirmed = await chain.queryUtxo(parsedSaved);
if (confirmed?.datumHex) {
assertAdvertMatches(decodeAdvertDatum(confirmed.datumHex), expected);
log(`reusing confirmed pending advert ${savedRef}`);
return parsedSaved;
}
} catch {
// The pending transaction is no longer viable; authoritative scan follows.
}
}
const advertAddress = loadBlueprint().advertScriptAddress(1);
const advertUtxos = await chain.queryUtxosByAddress(advertAddress);
const sameModel: Array<{ ref: OutputReference; datum: AdvertDatum }> = [];
for (const utxo of advertUtxos) {
if (!utxo.datumHex) continue;
try {
const datum = decodeAdvertDatum(utxo.datumHex);
if (datum.status === "Active" && datum.model === expected.model) {
sameModel.push({ ref: utxo.ref, datum });
}
} catch {
// Ignore unrelated or malformed script outputs.
}
}
const own = sameModel.filter(
(candidate) => candidate.datum.supplier_pkh === expected.supplier_pkh,
);
if (own.length > 1) {
throw new Error(`multiple active adverts already exist for ${expected.model}`);
}
if (own.length === 1) {
assertAdvertMatches(own[0].datum, expected);
log(`reusing discovered advert ${own[0].ref.txHash}#${own[0].ref.index}`);
return own[0].ref;
}
const result = await runPostAdvert({
chain,
walletKey,
advertDatum: expected,
log,
beforeSubmit: async (built) => journal(built.advertOutputRef),
});
return result.advertRef;
}
function assertAdvertMatches(actual: AdvertDatum, expected: AdvertDatum): void {
const fieldsMatch =
actual.status === "Active" &&
actual.supplier_pkh === expected.supplier_pkh &&
actual.capability_id === expected.capability_id &&
actual.model === expected.model &&
actual.max_output_tokens === expected.max_output_tokens &&
actual.max_processing_ms === expected.max_processing_ms &&
actual.price_lovelace === expected.price_lovelace &&
actual.supplier_bond_lovelace === expected.supplier_bond_lovelace &&
actual.buyer_bond_lovelace === expected.buyer_bond_lovelace &&
actual.endpoint_url === expected.endpoint_url &&
actual.detail_uri === expected.detail_uri &&
actual.detail_hash === expected.detail_hash;
if (!fieldsMatch) {
throw new Error("saved advert does not match the reseller config");
}
}
async function waitForFunding(
chain: LiveOgmiosProvider,
address: string,
minimum: bigint,
timeoutMs: number,
log: (line: string) => void,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastReported = -1n;
while (Date.now() < deadline) {
const utxos = await chain.queryUtxosByAddress(address);
const total = utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
if (total >= minimum) return;
if (total !== lastReported) {
log(`wallet funding ${total}/${minimum} lovelace`);
lastReported = total;
}
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 5_000));
}
throw new Error(`wallet funding timed out for ${address}`);
}
function resolveStatePath(
configured: string | undefined,
startStack: boolean,
): string {
const statePath = resolve(configured ?? DEFAULT_STATE_FILE);
const repoRoot = REPO_ROOT;
const fromRepo = relative(repoRoot, statePath);
if (fromRepo === "" || (!fromRepo.startsWith("../") && fromRepo !== "..")) {
throw new Error("RESELLER_STATE_FILE must be outside the repository");
}
if (startStack && statePath !== DEFAULT_STATE_FILE) {
throw new Error(
`stack startup requires the fixed state file ${DEFAULT_STATE_FILE}`,
);
}
return statePath;
}
async function readState(path: string): Promise<Record<string, string>> {
let raw: string;
try {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const info = await handle.stat();
if (!info.isFile()) {
throw new Error(`reseller state must be a regular file: ${path}`);
}
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
throw new Error(`reseller state is not owned by the current user: ${path}`);
}
if ((info.mode & 0o077) !== 0) {
throw new Error(`reseller state permissions must be 0600: ${path}`);
}
raw = await handle.readFile("utf8");
} finally {
await handle.close();
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
const state: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
if (line === "" || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator <= 0) throw new Error(`invalid reseller state line: ${line}`);
state[line.slice(0, separator)] = line.slice(separator + 1);
}
return state;
}
async function writeState(path: string, state: Record<string, string>): Promise<void> {
for (const [name, value] of Object.entries(state)) {
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
throw new Error(`reseller state ${name} must be one line`);
}
}
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await chmod(dirname(path), 0o700);
const temp = `${path}.${randomBytes(12).toString("hex")}.tmp`;
const body = Object.entries(state)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => `${name}=${value}`)
.join("\n") + "\n";
const handle = await open(temp, "wx", 0o600);
try {
await handle.writeFile(body, "utf8");
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, path);
await chmod(path, 0o600);
}
function parseRef(value: string): OutputReference | null {
const match = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(value);
if (!match) return null;
return { txHash: match[1], index: Number(match[2]) };
}
async function runCompose(composeFile: string): Promise<void> {
const args = [
"compose",
"-f",
resolve(REPO_ROOT, composeFile),
"up",
"-d",
"--build",
"--wait",
];
await new Promise<void>((resolveRun, rejectRun) => {
const child = spawn("docker", args, { stdio: "inherit" });
child.once("error", rejectRun);
child.once("exit", (code) => {
if (code === 0) resolveRun();
else rejectRun(new Error(`docker compose exited with code ${code}`));
});
});
}
export async function main(
argv: string[],
env: Record<string, string | undefined>,
): Promise<number> {
try {
const result = await runResellerSetup(parseSetupArgs(argv), env);
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 setup 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;
});
}

View File

@@ -0,0 +1,209 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { loadSurplusManagerConfig } from "../surplus/config.js";
import { SurplusClient, type SurplusSale } from "../surplus/client.js";
import { SurplusSellerController } from "../surplus/controller.js";
import { OpenRouterKeyClient } from "../surplus/openRouterKey.js";
import { FileSurplusStateStore } from "../surplus/state.js";
import {
SurplusDashboardService,
type SurplusDashboardSnapshot,
} from "../surplus/dashboard.js";
import { FileSurplusVectorProofLedger } from "../surplus/vectorProof.js";
import { loadHistoricalSalesCsv } from "../surplus/historyExport.js";
import { SurplusVectorProofPublisher } from "../surplus/vectorPublisher.js";
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import { buildAnchorMetadataTx } from "@marketplace/shared/tx/server";
import { deriveKeypair } from "./gen-keypair.js";
export async function runSurplusSeller(
env: Record<string, string | undefined>,
): Promise<{
server: Server;
controller: SurplusSellerController;
publisher: SurplusVectorProofPublisher | null;
}> {
const config = loadSurplusManagerConfig(env);
const client = new SurplusClient({
apiBaseUrl: config.apiBaseUrl,
sellerApiKey: config.sellerApiKey,
timeoutMs: config.requestTimeoutMs,
});
const allowance = new OpenRouterKeyClient({
baseUrl: config.capacityBaseUrl,
apiKey: config.providerApiKey,
timeoutMs: config.requestTimeoutMs,
});
const stateStore = new FileSurplusStateStore(config.statePath);
const controller = new SurplusSellerController({
config,
client,
allowance,
stateStore,
log: (message) => console.log(message),
});
let publisher: SurplusVectorProofPublisher | null = null;
let proofs: FileSurplusVectorProofLedger | undefined;
if (config.proof !== null) {
const proofConfig = config.proof;
const ledger = await FileSurplusVectorProofLedger.open(proofConfig.ledgerPath);
proofs = ledger;
const chain = new LiveOgmiosProvider({ ogmiosUrl: proofConfig.ogmiosUrl });
const derived = deriveKeypair(proofConfig.walletPrivateKeyHex, 1);
const walletKey: WalletKey = {
pubKeyHash: derived.pubKeyHash,
pubKeyHex: derived.publicKeyHex,
privateKeyHex: derived.privateKeyHex,
address: derived.address,
};
let historicalSales: (() => Promise<readonly SurplusSale[]>) | undefined;
if (proofConfig.historyCsvPath !== null && proofConfig.historyOfferId !== null) {
const history = await loadHistoricalSalesCsv(
proofConfig.historyCsvPath,
proofConfig.historyOfferId,
);
console.log(
`surplus-proof: loaded ${history.sales.length} settled export sale(s) from ` +
`${proofConfig.historyCsvPath} (${history.settledWithoutTx} without a Base tx, ` +
`${history.skippedUnsettled} unsettled rows skipped)`,
);
historicalSales = async () => history.sales;
}
publisher = new SurplusVectorProofPublisher({
ledger,
earnings: () => client.getEarnings(),
historicalSales,
anchor: async (metadata) => {
const built = await buildAnchorMetadataTx({ chain, walletKey, metadata });
return { expectedTxHash: built.expectedTxHash };
},
awaitTx: (txHash, timeoutMs) => chain.awaitTx(txHash, timeoutMs),
balanceLovelace: async () => {
const utxos = await chain.queryUtxosByAddress(walletKey.address);
return utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
},
reserveLovelace: proofConfig.reserveLovelace,
feeBudgetLovelace: proofConfig.feeBudgetLovelace,
settledStatuses: config.settledStatuses,
intervalMs: proofConfig.intervalMs,
confirmTimeoutMs: proofConfig.confirmTimeoutMs,
log: (message) => console.log(message),
});
console.log(
`surplus-proof: publisher enabled with wallet ${derived.address} and ledger ${proofConfig.ledgerPath}`,
);
}
const dashboard = new SurplusDashboardService({
controller,
client,
allowance,
config,
proofs,
});
const server = createSurplusServer(controller, dashboard);
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(config.port, "0.0.0.0", () => {
server.off("error", rejectListen);
resolveListen();
});
});
await controller.start();
publisher?.start();
return { server, controller, publisher };
}
interface DashboardSource {
getSnapshot(): Promise<SurplusDashboardSnapshot>;
}
export function createSurplusServer(
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
dashboard: DashboardSource,
): Server {
return createServer((request, response) => {
void handleRequest(controller, dashboard, request, response);
});
}
async function handleRequest(
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
dashboard: DashboardSource,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const path = request.url?.split("?")[0] ?? "/";
response.setHeader("cache-control", "no-store");
response.setHeader("x-content-type-options", "nosniff");
response.setHeader("x-frame-options", "DENY");
response.setHeader("referrer-policy", "no-referrer");
const status = controller.snapshot();
if (request.method === "GET" && path === "/healthz") {
const healthy = controller.healthy();
response.statusCode = healthy ? 200 : 503;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ ok: healthy, phase: status.phase }));
return;
}
if (request.method === "GET" && path === "/status") {
response.statusCode = 200;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(status));
return;
}
if (request.method === "GET" && path === "/internal/resale-dashboard") {
try {
const snapshot = await dashboard.getSnapshot();
response.statusCode = 200;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(snapshot));
} catch {
response.statusCode = 503;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ error: "dashboard_data_unavailable" }));
}
return;
}
response.statusCode = 404;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ error: "not_found" }));
}
export async function main(
env: Record<string, string | undefined> = process.env,
): Promise<void> {
const { server, controller, publisher } = await runSurplusSeller(env);
console.log("Surplus seller controller listening");
let shuttingDown = false;
const shutdown = async (signal: string): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Surplus seller controller shutting down (${signal})`);
await publisher?.stop();
await controller.stop();
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => error ? rejectClose(error) : resolveClose());
});
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
if (invoked) {
main().catch((error) => {
const detail = error instanceof Error ? error.message : String(error);
console.error(`Surplus seller controller failed: ${detail}`);
process.exitCode = 1;
});
}

View File

@@ -36,6 +36,15 @@
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
* first-token latency is longer than local Ollama)
* OPENAI_SESSION_PASSTHROUGH
* — "1" switches chat-session turns to the stateful-
* upstream contract (OpenClaw): escrow ref as the
* OpenAI `user` field + delta-only messages per turn.
* Strict "1" only. Default off. Requires
* CAPABILITY_KIND="chat-session".
* OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
* advert's buyer-facing model (OpenClaw accepts only
* "openclaw" / "openclaw/<agentId>"). Default unset.
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
* Any other value (including "true", "yes", "TRUE")
@@ -53,6 +62,7 @@ const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
const POS_INT_RE = /^[1-9]\d*$/;
const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/;
/** Lowercased hostname of a URL, or "" if it doesn't parse. */
function hostnameOf(url: string): string {
@@ -63,6 +73,24 @@ function hostnameOf(url: string): string {
}
}
function isOpenRouterApiBaseUrl(value: string): boolean {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
url.hostname === "openrouter.ai" &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
(url.pathname === "/api" || url.pathname === "/api/") &&
url.search === "" &&
url.hash === ""
);
} catch {
return false;
}
}
export interface AdvertRef {
txHash: string;
index: number;
@@ -103,6 +131,22 @@ export type LlmBackend = "ollama" | "openai";
* the buyer's sweeper Reclaims the Open escrow after deliver_by. */
export type ChatSettleMode = "full" | "ticket";
export type ResellerProvider = "openrouter";
export interface ResellerConfig {
provider: ResellerProvider;
databaseUrl: string;
/** Fixed protected amount in provider USD credits. Parsed exactly at runtime. */
reserveUsd: string;
/** Conservative off-chain input bound advertised by /capability. */
maxInputTokens: number;
pollIntervalMs: number;
staleAfterMs: number;
providerTimeoutMs: number;
previewMaxChars: number;
}
export interface SupplierConfig {
supplierPrivKeyHex: string;
ogmiosUrl: string;
@@ -129,6 +173,28 @@ export interface SupplierConfig {
* the model context or the provider 400s (e.g. kimi context = 262144).
*/
openaiMaxTokens: number;
/**
* When true, chat-session turns speak the STATEFUL-upstream contract
* (e.g. an OpenClaw gateway): each turn 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 sends ONLY the current
* turn's delta messages — the upstream session carries the history, and
* re-sending the full transcript would duplicate it into the upstream
* context every turn. The supplier still accumulates the full transcript
* locally for the receipt. Env: OPENAI_SESSION_PASSTHROUGH="1". Default
* off (stateless upstreams get the full transcript each turn). Requires
* capabilityKind="chat-session".
*/
openaiSessionPassthrough: boolean;
/**
* When non-empty, sent as the upstream `model` instead of the advert's
* model id. For upstreams that reject arbitrary model strings — OpenClaw's
* chat-completions endpoint 400s on anything but "openclaw" or
* "openclaw/<agentId>" — while the advert carries the buyer-facing id the
* gateway routes on. Env: OPENAI_MODEL_OVERRIDE. Default "" (advert model
* sent verbatim).
*/
openaiModelOverride: string;
/** Empty string when capabilityKind="chat". */
piperUrl: string;
advertRef: AdvertRef;
@@ -195,6 +261,8 @@ export interface SupplierConfig {
* Default 600_000 (10 min). Only fires while liveChain=true.
*/
walletHealthIntervalMs: number;
/** Optional balance-aware provider resale mode. Null preserves legacy behavior. */
reseller: ResellerConfig | null;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -379,6 +447,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiMaxTokens = Number(maxTokStr);
}
// OPENAI_SESSION_PASSTHROUGH — "1" switches chat-session turns to the
// stateful-upstream contract (OpenClaw): escrow ref as the OpenAI `user`
// field + delta-only messages (the upstream session carries history).
// Strict "1" only, mirroring LIVE_CHAIN.
const openaiSessionPassthrough = env.OPENAI_SESSION_PASSTHROUGH === "1";
if (openaiSessionPassthrough && capabilityKind !== "chat-session") {
throw new Error(
'loadConfig: OPENAI_SESSION_PASSTHROUGH requires CAPABILITY_KIND="chat-session"',
);
}
// OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
// advert's (buyer-facing) model. Needed when the upstream validates the
// model field (OpenClaw accepts only "openclaw" / "openclaw/<agentId>").
const openaiModelOverride = env.OPENAI_MODEL_OVERRIDE ?? "";
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
let chatIdleTimeoutMs = 300_000;
@@ -485,6 +569,121 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
walletHealthIntervalMs = Number(whIntervalStr);
}
const resellerFields = [
"RESELLER_DATABASE_URL",
"RESELLER_RESERVE_USD",
"RESELLER_MAX_INPUT_TOKENS",
"RESELLER_POLL_INTERVAL_MS",
"RESELLER_PROVIDER_TIMEOUT_MS",
"RESELLER_PREVIEW_MAX_CHARS",
] as const;
const resellerProviderRaw = (env.RESELLER_PROVIDER ?? "").trim();
const hasResellerFields = resellerFields.some(
(name) => env[name] !== undefined && env[name] !== "",
);
let reseller: ResellerConfig | null = null;
if (resellerProviderRaw === "") {
if (hasResellerFields) {
throw new Error(
"loadConfig: RESELLER_PROVIDER is required when reseller fields are set",
);
}
} else {
if (resellerProviderRaw !== "openrouter") {
throw new Error('loadConfig: RESELLER_PROVIDER must be "openrouter"');
}
if (capabilityKind !== "chat" || llmBackend !== "openai") {
throw new Error(
'loadConfig: RESELLER_PROVIDER requires CAPABILITY_KIND="chat" and LLM_BACKEND="openai"',
);
}
if (!isOpenRouterApiBaseUrl(openaiBaseUrl)) {
throw new Error(
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_BASE_URL=https://openrouter.ai/api",
);
}
if (openaiApiKey === "") {
throw new Error(
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_API_KEY",
);
}
if (openaiMaxTokens <= 0) {
throw new Error(
"loadConfig: RESELLER_PROVIDER requires a positive OPENAI_MAX_TOKENS",
);
}
const databaseUrl = requireField(env, "RESELLER_DATABASE_URL");
let databaseProtocol = "";
try {
databaseProtocol = new URL(databaseUrl).protocol;
} catch {
throw new Error(
"loadConfig: RESELLER_DATABASE_URL must be a valid PostgreSQL URL",
);
}
if (databaseProtocol !== "postgres:" && databaseProtocol !== "postgresql:") {
throw new Error(
"loadConfig: RESELLER_DATABASE_URL must use postgres:// or postgresql://",
);
}
const reserveUsd = requireField(env, "RESELLER_RESERVE_USD").trim();
if (!USD_RE.test(reserveUsd)) {
throw new Error(
"loadConfig: RESELLER_RESERVE_USD must be a non-negative decimal with at most 9 places",
);
}
const maxInputRaw = requireField(env, "RESELLER_MAX_INPUT_TOKENS");
if (!POS_INT_RE.test(maxInputRaw) || !Number.isSafeInteger(Number(maxInputRaw))) {
throw new Error(
"loadConfig: RESELLER_MAX_INPUT_TOKENS must be a positive safe integer",
);
}
const pollRaw = env.RESELLER_POLL_INTERVAL_MS ?? "30000";
if (!POS_INT_RE.test(pollRaw) || !Number.isSafeInteger(Number(pollRaw))) {
throw new Error(
"loadConfig: RESELLER_POLL_INTERVAL_MS must be a positive safe integer",
);
}
const pollIntervalMs = Number(pollRaw);
const providerTimeoutRaw = env.RESELLER_PROVIDER_TIMEOUT_MS ?? "10000";
if (
!POS_INT_RE.test(providerTimeoutRaw) ||
!Number.isSafeInteger(Number(providerTimeoutRaw))
) {
throw new Error(
"loadConfig: RESELLER_PROVIDER_TIMEOUT_MS must be a positive safe integer",
);
}
const previewMaxRaw = env.RESELLER_PREVIEW_MAX_CHARS ?? "160";
if (
!POS_INT_RE.test(previewMaxRaw) ||
!Number.isSafeInteger(Number(previewMaxRaw)) ||
Number(previewMaxRaw) > 1000
) {
throw new Error(
"loadConfig: RESELLER_PREVIEW_MAX_CHARS must be an integer from 1 to 1000",
);
}
reseller = {
provider: "openrouter",
databaseUrl,
reserveUsd,
maxInputTokens: Number(maxInputRaw),
pollIntervalMs,
staleAfterMs: Math.max(pollIntervalMs * 2, pollIntervalMs + 5000),
providerTimeoutMs: Number(providerTimeoutRaw),
previewMaxChars: Number(previewMaxRaw),
};
}
return {
supplierPrivKeyHex,
ogmiosUrl,
@@ -493,6 +692,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiApiKey,
openaiReasoningDisabled,
openaiMaxTokens,
openaiSessionPassthrough,
openaiModelOverride,
piperUrl,
advertRef,
networkId,
@@ -516,5 +717,6 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
revisionProbeMode,
liveChain,
walletHealthIntervalMs,
reseller,
};
}

View File

@@ -22,6 +22,12 @@ import { JobStore } from "./jobs.js";
import { createApp, type SupplierDeps } from "./server.js";
import { buildChainProvider } from "./chain.js";
import { startWalletHealthTicker } from "./walletHealth.js";
import { decodeAdvertDatum } from "@marketplace/shared/cbor";
import { BOUNDED_INPUT_DETAIL_MARKER } from "@marketplace/shared/tx";
import { OpenRouterCapacityProvider } from "./reseller/openRouterCapacity.js";
import { CapacityGate } from "./reseller/capacityGate.js";
import { ResellerEvidenceStore } from "./reseller/evidenceStore.js";
import { ResellerRuntime } from "./reseller/runtime.js";
async function main(): Promise<void> {
const cfg = loadConfig(process.env);
@@ -45,8 +51,74 @@ async function main(): Promise<void> {
// chat-session kind serves N concurrent sessions; one-shot kinds stay 1.
const state = new SupplierState(cfg.capabilityKind === "chat-session" ? cfg.maxChatSessions : 1);
const jobs = new JobStore();
let reseller: ResellerRuntime | undefined;
if (cfg.reseller) {
const advertUtxo = await chain.queryUtxo(cfg.advertRef);
if (!advertUtxo?.datumHex) {
throw new Error("reseller boot: configured advert UTxO is unavailable");
}
const advert = decodeAdvertDatum(advertUtxo.datumHex);
if (advert.status !== "Active") {
throw new Error(`reseller boot: advert status is ${advert.status}`);
}
if (advert.capability_id !== "llm.text.generate.v1") {
throw new Error(
`reseller boot: advert capability must be llm.text.generate.v1, got ${advert.capability_id}`,
);
}
if (!advert.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
throw new Error("reseller boot: advert is missing bounded-input metadata");
}
if (advert.max_output_tokens !== cfg.openaiMaxTokens) {
throw new Error(
"reseller boot: OPENAI_MAX_TOKENS must equal advert max_output_tokens",
);
}
if (advert.supplier_pkh !== supplierKey.pubKeyHash) {
throw new Error("reseller boot: advert supplier does not match SUPPLIER_PKH");
}
const deps: SupplierDeps = { chain, state, config: cfg, supplierKey, jobs };
const providerModel = cfg.openaiModelOverride || advert.model;
const provider = new OpenRouterCapacityProvider({
baseUrl: cfg.openaiBaseUrl,
apiKey: cfg.openaiApiKey,
timeoutMs: cfg.reseller.providerTimeoutMs,
});
const gate = new CapacityGate({
provider,
model: providerModel,
reserveUsd: cfg.reseller.reserveUsd,
maxInputTokens: cfg.reseller.maxInputTokens,
maxOutputTokens: advert.max_output_tokens,
staleAfterMs: cfg.reseller.staleAfterMs,
});
const store = new ResellerEvidenceStore(
cfg.reseller.databaseUrl,
cfg.reseller.previewMaxChars,
);
reseller = new ResellerRuntime({
chain,
state,
gate,
store,
advert,
advertRef: cfg.advertRef,
providerModel,
pollIntervalMs: cfg.reseller.pollIntervalMs,
maxInputTokens: cfg.reseller.maxInputTokens,
});
await reseller.initialize();
}
const deps: SupplierDeps = {
chain,
state,
config: cfg,
supplierKey,
jobs,
reseller,
};
const app = createApp(deps);
const server = app.listen(cfg.port, () => {
@@ -80,7 +152,9 @@ async function main(): Promise<void> {
clearInterval(evictInterval);
walletHealthTicker?.stop();
state.markOffline();
server.close(() => process.exit(0));
server.close(() => {
void (reseller?.close() ?? Promise.resolve()).finally(() => process.exit(0));
});
setTimeout(() => process.exit(1), 5_000).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));

View File

@@ -48,6 +48,8 @@ import type {
TtsJobResponsePayload,
OcrJobResponsePayload,
} from "./jobs.js";
import type { ResellerRuntime } from "./reseller/runtime.js";
import { parseUsdNumberCeil } from "./reseller/money.js";
export interface RunChatJobDeps {
chain: ChainProvider;
@@ -55,6 +57,7 @@ export interface RunChatJobDeps {
config: SupplierConfig;
supplierKey: WalletKey;
jobs: JobStore;
reseller?: ResellerRuntime;
}
export interface RunChatJobParams {
@@ -71,6 +74,15 @@ function sha256Hex(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
async function recordResellerFailure(
reseller: ResellerRuntime | undefined,
escrowRef: string,
reason: string,
): Promise<void> {
if (!reseller) return;
await reseller.recordFailed(escrowRef, reason).catch(() => undefined);
}
/**
* Run Ollama → receipt → Submit in the background.
* Always resolves (never rejects). Terminal state written to jobs store.
@@ -83,16 +95,36 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
deps.jobs.setRunning(jobId);
try {
if (deps.reseller) {
try {
await deps.reseller.recordInferenceStarted(escrowRef);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
return;
}
}
// ── 2. Call upstream LLM ───────────────────────────────────────────
// Both backends return the same { content, prompt_tokens,
// completion_tokens, wallclock_ms } shape so receipt construction
// downstream is identical.
let inference: { content: string; prompt_tokens: number; completion_tokens: number; wallclock_ms: number };
let inference: {
content: string;
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
cost_usd?: number;
};
try {
if (deps.config.llmBackend === "openai") {
inference = await openai.callOpenAi({
baseUrl: deps.config.openaiBaseUrl,
model: advert.model,
model: deps.config.openaiModelOverride || advert.model,
messages: requestBody.messages,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
@@ -127,6 +159,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
reason: collapsedReason,
message,
});
await recordResellerFailure(deps.reseller, escrowRef, collapsedReason);
return;
}
@@ -146,8 +179,39 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
});
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
const resultHash = receiptResultHash(signed);
if (deps.reseller) {
try {
await deps.reseller.recordOutput(escrowRef, {
response: assistantMessage,
receipt: {
receipt: signed.receipt,
signature: signed.signature,
},
promptTokens: inference.prompt_tokens,
completionTokens: inference.completion_tokens,
outputText: inference.content,
actualCostUsdNanos: inference.cost_usd === undefined
? null
: parseUsdNumberCeil(inference.cost_usd, "OpenRouter usage cost"),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
await recordResellerFailure(
deps.reseller,
escrowRef,
"output_persistence_failed",
);
return;
}
}
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
// ── 4+5. Broadcast, journal, then confirm Submit under one wallet lock ─
const submitOutcome = await deps.state.walletMutex.run(async () => {
let built: { txCborHex: string; expectedTxHash: string };
try {
@@ -156,19 +220,30 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
supplierKey: deps.supplierKey,
escrowRef: claimedRef,
receiptHash: resultHash,
beforeSubmit: deps.reseller
? async (candidate) => {
await deps.reseller!.recordSubmitting(
escrowRef,
`${candidate.expectedTxHash}#0`,
);
}
: undefined,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
const submittedRef = `${built.expectedTxHash}#0`;
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
return { kind: "await_failed" as const, err, built, submittedRef };
}
return { kind: "ok" as const };
return { kind: "ok" as const, built, submittedRef };
});
if (submitOutcome.kind === "build_failed") {
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
const message = submitOutcome.err instanceof Error
? submitOutcome.err.message
: String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
);
@@ -177,20 +252,42 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
reason: "submit_failed",
message: `Submit tx failed: ${message}`,
});
await recordResellerFailure(deps.reseller, escrowRef, "submit_failed");
return;
}
if (submitOutcome.kind === "await_failed") {
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
const message = submitOutcome.err instanceof Error
? submitOutcome.err.message
: String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${message}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_timeout",
message: `Submit awaitTx failed: ${msg}`,
message: `Submit awaitTx failed: ${message}`,
});
deps.reseller?.watchSubmitting(escrowRef, submitOutcome.submittedRef);
return;
}
if (deps.reseller) {
try {
await deps.reseller.recordSubmitConfirmed(
escrowRef,
submitOutcome.submittedRef,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
deps.reseller.watchSubmitting(escrowRef, submitOutcome.submittedRef);
return;
}
}
// ── 6. Mark complete ────────────────────────────────────────────────
const payload: ChatJobResponsePayload = {
@@ -212,6 +309,9 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
};
deps.jobs.complete(jobId, payload);
} finally {
if (deps.reseller) {
await deps.reseller.refreshCapacity().catch(() => undefined);
}
// ── 7. Always release this job's slot ───────────────────────────────
try {
deps.state.release(escrowRef);

View File

@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
* context window (some providers 400 when it exceeds context).
*/
maxTokens?: number;
/**
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
* escrow ref pins one upstream session per marketplace chat session.
* Stateless providers ignore it.
*/
user?: string;
/**
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
@@ -67,6 +74,8 @@ export interface OpenAiResult {
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
/** Provider-reported request cost in USD when present (OpenRouter extension). */
cost_usd?: number;
/** Present when the model requested tool calls (streaming path only). */
tool_calls?: ToolCall[];
/** Upstream finish_reason when reported (e.g. "stop", "tool_calls"). */
@@ -93,11 +102,12 @@ function isAbortError(err: unknown): boolean {
}
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, user } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = { model, messages, stream: false };
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
const body = JSON.stringify(payload);
const headers: Record<string, string> = { "content-type": "application/json" };
@@ -180,10 +190,18 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
const usageRaw = obj.usage;
let promptTokens = 0;
let completionTokens = 0;
let costUsd: number | undefined;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
if (
typeof usage.cost === "number" &&
Number.isFinite(usage.cost) &&
usage.cost >= 0
) {
costUsd = usage.cost;
}
}
const wallclockMs = Date.now() - startedAt;
@@ -193,6 +211,7 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: wallclockMs,
...(costUsd === undefined ? {} : { cost_usd: costUsd }),
};
}
@@ -215,7 +234,7 @@ export async function callOpenAiStream(
params: CallOpenAiParams,
onToken: (delta: string) => void,
): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice, user } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = {
model,
@@ -225,6 +244,7 @@ export async function callOpenAiStream(
};
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
if (Array.isArray(tools) && tools.length > 0) {
payload.tools = tools;
if (toolChoice !== undefined) payload.tool_choice = toolChoice;

View File

@@ -0,0 +1,179 @@
import type { CapacityProvider } from "./provider.js";
import { parseUsdDecimal } from "./money.js";
export type CapacityReason =
| "starting"
| "ready"
| "below_reserve"
| "insufficient_for_job"
| "provider_unavailable"
| "database_unavailable"
| "stale";
export interface CapacitySnapshot {
provider: string;
model: string;
canServe: boolean;
reason: CapacityReason;
checkedAtMs: number | null;
lastSuccessAtMs: number | null;
keyLimitUsdNanos: bigint;
remainingAllowanceUsdNanos: bigint;
reserveUsdNanos: bigint;
sellableUsdNanos: bigint;
worstCaseJobUsdNanos: bigint;
promptPriceUsdNanosPerToken: bigint;
completionPriceUsdNanosPerToken: bigint;
requestPriceUsdNanos: bigint;
availableJobs: bigint | null;
limitReset: string | null;
error: string | null;
}
interface CapacityGateOptions {
provider: CapacityProvider;
model: string;
reserveUsd: string;
maxInputTokens: number;
maxOutputTokens: number;
staleAfterMs: number;
now?: () => number;
}
export class CapacityGate {
private readonly provider: CapacityProvider;
private readonly model: string;
private readonly reserveUsdNanos: bigint;
private readonly maxInputTokens: number;
private readonly maxOutputTokens: number;
private readonly staleAfterMs: number;
private readonly now: () => number;
private current: CapacitySnapshot;
private inFlight: Promise<CapacitySnapshot> | null = null;
constructor(options: CapacityGateOptions) {
this.provider = options.provider;
this.model = options.model;
this.reserveUsdNanos = parseUsdDecimal(options.reserveUsd, "ceil");
this.maxInputTokens = options.maxInputTokens;
this.maxOutputTokens = options.maxOutputTokens;
this.staleAfterMs = options.staleAfterMs;
this.now = options.now ?? Date.now;
this.current = {
provider: options.provider.id,
model: options.model,
canServe: false,
reason: "starting",
checkedAtMs: null,
lastSuccessAtMs: null,
keyLimitUsdNanos: 0n,
remainingAllowanceUsdNanos: 0n,
reserveUsdNanos: this.reserveUsdNanos,
sellableUsdNanos: 0n,
worstCaseJobUsdNanos: 0n,
promptPriceUsdNanosPerToken: 0n,
completionPriceUsdNanosPerToken: 0n,
requestPriceUsdNanos: 0n,
availableJobs: 0n,
limitReset: null,
error: null,
};
}
snapshot(): CapacitySnapshot {
const snapshot = this.current;
if (
snapshot.lastSuccessAtMs !== null &&
this.now() - snapshot.lastSuccessAtMs > this.staleAfterMs
) {
return {
...snapshot,
canServe: false,
reason: "stale",
error: "provider capacity data is stale",
};
}
return { ...snapshot };
}
async refresh(): Promise<CapacitySnapshot> {
if (this.inFlight) return this.inFlight;
this.inFlight = this.performRefresh();
try {
return await this.inFlight;
} finally {
this.inFlight = null;
}
}
forceUnavailable(
reason: Extract<CapacityReason, "database_unavailable" | "provider_unavailable">,
error: string,
): CapacitySnapshot {
this.current = {
...this.current,
canServe: false,
reason,
checkedAtMs: this.now(),
error,
};
return this.snapshot();
}
private async performRefresh(): Promise<CapacitySnapshot> {
try {
const reading = await this.provider.readCapacity(this.model);
const pricing = reading.pricing;
const worstCaseJobUsdNanos =
pricing.requestUsdNanos +
pricing.promptUsdNanosPerToken * BigInt(this.maxInputTokens) +
pricing.completionUsdNanosPerToken * BigInt(this.maxOutputTokens);
const remaining = reading.keyRemainingUsdNanos;
const sellable = remaining > this.reserveUsdNanos
? remaining - this.reserveUsdNanos
: 0n;
const enoughForOne =
worstCaseJobUsdNanos === 0n || sellable >= worstCaseJobUsdNanos;
const aboveReserve = remaining >= this.reserveUsdNanos;
const canServe = aboveReserve && enoughForOne;
const reason: CapacityReason = canServe
? "ready"
: !aboveReserve
? "below_reserve"
: "insufficient_for_job";
const availableJobs = worstCaseJobUsdNanos === 0n
? null
: sellable / worstCaseJobUsdNanos;
this.current = {
provider: reading.provider,
model: this.model,
canServe,
reason,
checkedAtMs: reading.checkedAtMs,
lastSuccessAtMs: reading.checkedAtMs,
keyLimitUsdNanos: reading.keyLimitUsdNanos,
remainingAllowanceUsdNanos: remaining,
reserveUsdNanos: this.reserveUsdNanos,
sellableUsdNanos: sellable,
worstCaseJobUsdNanos,
promptPriceUsdNanosPerToken: pricing.promptUsdNanosPerToken,
completionPriceUsdNanosPerToken: pricing.completionUsdNanosPerToken,
requestPriceUsdNanos: pricing.requestUsdNanos,
availableJobs,
limitReset: reading.limitReset,
error: null,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.current = {
...this.current,
canServe: false,
reason: "provider_unavailable",
checkedAtMs: this.now(),
error: detail,
};
}
return this.snapshot();
}
}

View File

@@ -0,0 +1,101 @@
export function renderResellerDashboardPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reseller capacity</title>
<style>
:root{color-scheme:dark;--bg:#071014;--panel:#0d1a20;--line:#24363d;--text:#eef5f5;--muted:#98aeb5;--good:#50d890;--warn:#ffcb66;--bad:#ff7b72;--accent:#75bfff}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#12303a 0,transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:24px}h1{font-size:clamp(28px,5vw,48px);line-height:1.05;margin:0;letter-spacing:-.04em}header p{margin:8px 0 0;color:var(--muted)}.stamp{color:var(--muted);font-variant-numeric:tabular-nums}.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:16px;box-shadow:0 16px 48px #0005}.identity{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:1px;overflow:hidden;margin-bottom:16px}.identity>div{padding:18px 20px;background:var(--panel)}.label{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em;margin-bottom:6px}.value{font-size:17px;font-weight:650;overflow-wrap:anywhere}.status{display:inline-flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 4px #fff1}.status.free .dot{background:var(--good)}.status.working .dot{background:var(--warn)}.status.offline .dot{background:var(--bad)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:0 0 24px}.metric{padding:18px}.metric strong{display:block;font-size:24px;letter-spacing:-.03em;font-variant-numeric:tabular-nums}.metric small{color:var(--muted)}.section-head{display:flex;justify-content:space-between;align-items:center;margin:28px 0 12px}h2{font-size:18px;margin:0}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:940px}th,td{text-align:left;padding:13px 14px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.09em;font-weight:600}tbody tr:last-child td{border-bottom:0}.job-status{font-weight:700}.job-status.settled{color:var(--good)}.job-status.failed{color:var(--bad)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.preview{max-width:250px;color:#c9d8dc;white-space:normal}.empty,.error{padding:30px;color:var(--muted)}.error{color:var(--bad)}
a.explorer-link{color:var(--accent);text-decoration:none;text-underline-offset:3px}a.explorer-link:hover,a.explorer-link:focus-visible{text-decoration:underline}
@media(max-width:820px){header{align-items:flex-start;flex-direction:column}.identity{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:480px){main{width:min(100% - 20px,1180px);padding-top:24px}.metrics{grid-template-columns:1fr}.metric strong{font-size:21px}}
</style>
</head>
<body>
<main>
<header><div><h1>Provider capacity resale</h1><p>Bounded upstream capacity settled through Vector escrow.</p></div><div class="stamp" id="updated">Loading current state</div></header>
<section class="panel identity" aria-label="Agent identity">
<div><span class="label">Route</span><span class="value" id="route">Loading</span></div>
<div><span class="label">Marketplace model</span><span class="value" id="market-model">Loading</span></div>
<div><span class="label">Supplier status</span><span class="status" id="status"><span class="dot"></span><span id="status-text">Loading</span></span><div class="stamp" id="reason"></div></div>
</section>
<section class="metrics" aria-label="Capacity and settlement metrics">
<div class="panel metric"><span class="label">Sellable allowance</span><strong id="sellable">--</strong><small id="allowance">Provider allowance --</small></div>
<div class="panel metric"><span class="label">Worst-case job</span><strong id="worst-job">--</strong><small id="available-jobs">-- jobs available</small></div>
<div class="panel metric"><span class="label">AP3X earned</span><strong id="earned">--</strong><small id="settled-count">-- settled jobs</small></div>
<div class="panel metric"><span class="label">Upstream spend</span><strong id="spend">--</strong><small id="failed-count">-- failed jobs</small></div>
</section>
<div class="section-head"><h2>Conversion timeline</h2><span class="stamp">Public previews require buyer consent</span></div>
<section class="panel table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Model</th><th>Prompt preview</th><th>Output preview</th><th>Upstream</th><th>Payout</th><th>Escrow</th></tr></thead><tbody id="jobs"><tr><td colspan="8" class="empty">Loading jobs</td></tr></tbody></table></section>
</main>
<script>
(function(){
'use strict';
var byId=function(id){return document.getElementById(id)};
var text=function(id,value){byId(id).textContent=value==null?'--':String(value)};
var money=function(value){return '$'+String(value==null?'0':value)};
function renderStatus(state){
text('route',state.provider+' / '+state.provider_model);
text('market-model',state.marketplace_model);
var status=byId('status');status.className='status '+state.status;
text('status-text',state.status);text('reason',state.reason);
text('sellable',money(state.capacity.sellable_usd));
text('allowance','Allowance '+money(state.capacity.remaining_allowance_usd)+' · reserve '+money(state.capacity.protected_reserve_usd)+' · committed '+money(state.capacity.committed_usd));
text('worst-job',money(state.capacity.worst_case_job_usd));
text('available-jobs',state.capacity.available_jobs==null?'No balance limit':state.capacity.available_jobs+' jobs available');
text('earned',state.totals.ap3x_earned+' AP3X');
text('settled-count',state.totals.settled_jobs+' settled jobs');
text('spend',money(state.totals.upstream_spend_usd));
text('failed-count',state.totals.failed_jobs+' failed jobs');
text('updated','Updated '+new Date().toLocaleTimeString());
}
function cell(row,value,className){var td=document.createElement('td');if(className)td.className=className;td.textContent=value==null?'--':String(value);row.appendChild(td)}
function escrowCell(row,value){
var td=document.createElement('td');td.className='mono';
var ref=typeof value==='string'?value:'';
var txHash=ref.split('#')[0];
if(/^[0-9a-fA-F]{64}$/.test(txHash)){
var link=document.createElement('a');
link.className='explorer-link';
link.href='https://vector.apexscan.org/en/transaction/'+txHash+'/summary/';
link.target='_blank';link.rel='noopener noreferrer';
link.textContent=ref;
link.setAttribute('aria-label','View escrow transaction '+txHash+' on ApexScan');
td.appendChild(link);
}else{
td.textContent=ref||'--';
}
row.appendChild(td);
}
function renderJobs(rows){
var body=byId('jobs');body.replaceChildren();
if(!rows.length){var row=document.createElement('tr');cell(row,'No terminal jobs yet','empty');row.firstChild.colSpan=8;body.appendChild(row);return}
rows.forEach(function(job){
var row=document.createElement('tr');
cell(row,new Date(job.created_at).toLocaleString());
cell(row,job.status,'job-status '+job.status);
cell(row,job.marketplace_model,'mono');
cell(row,job.prompt_preview,'preview');
cell(row,job.output_preview,'preview');
cell(row,job.upstream_cost_usd==null?'--':money(job.upstream_cost_usd));
cell(row,job.ap3x_payout+' AP3X');
escrowCell(row,job.escrow_ref);
body.appendChild(row);
});
}
function showError(message){var body=byId('jobs');body.replaceChildren();var row=document.createElement('tr');cell(row,message,'error');row.firstChild.colSpan=8;body.appendChild(row);text('updated','Data unavailable')}
async function refresh(){
try{
var results=await Promise.all([fetch('/reseller/api/status',{cache:'no-store'}),fetch('/reseller/api/jobs?limit=50',{cache:'no-store'})]);
if(!results[0].ok||!results[1].ok)throw new Error('Seller evidence is unavailable');
var data=await Promise.all([results[0].json(),results[1].json()]);renderStatus(data[0]);renderJobs(data[1].jobs||[]);
}catch(error){showError(error instanceof Error?error.message:'Seller evidence is unavailable')}
}
refresh();setInterval(refresh,5000);
})();
</script>
</body>
</html>`;
}

View File

@@ -0,0 +1,446 @@
import { Pool } from "pg";
import type { ChatMessage } from "@marketplace/shared/tx";
import type { CapacitySnapshot } from "./capacityGate.js";
import { formatUsdNanos } from "./money.js";
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS reseller_jobs (
escrow_ref text PRIMARY KEY,
local_job_id text,
status text NOT NULL CHECK (status IN ('received','claimed','running','submitting','submitted','settled','failed')),
provider text NOT NULL,
provider_model text NOT NULL,
marketplace_model text NOT NULL,
request_json jsonb NOT NULL,
response_json jsonb,
receipt_json jsonb,
public_preview boolean NOT NULL DEFAULT false,
prompt_preview text,
output_preview text,
balance_before_nanos bigint NOT NULL,
balance_after_nanos bigint,
worst_case_cost_nanos bigint NOT NULL,
actual_cost_nanos bigint,
price_lovelace bigint NOT NULL,
prompt_tokens integer,
completion_tokens integer,
claim_tx_hash text,
submitted_ref text,
failure_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
settled_at timestamptz
);
CREATE INDEX IF NOT EXISTS reseller_jobs_status_idx
ON reseller_jobs (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS reseller_jobs_created_idx
ON reseller_jobs (created_at DESC);
CREATE TABLE IF NOT EXISTS reseller_capacity (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
provider text NOT NULL,
model text NOT NULL,
can_serve boolean NOT NULL,
reason text NOT NULL,
checked_at timestamptz,
remaining_allowance_nanos bigint NOT NULL,
reserve_nanos bigint NOT NULL,
sellable_nanos bigint NOT NULL,
worst_case_job_nanos bigint NOT NULL,
available_jobs bigint,
limit_reset text,
updated_at timestamptz NOT NULL DEFAULT now()
);
`;
export interface ReceivedJob {
escrowRef: string;
provider: string;
providerModel: string;
marketplaceModel: string;
messages: ChatMessage[];
publicPreview: boolean;
balanceBeforeUsdNanos: bigint;
worstCaseCostUsdNanos: bigint;
priceLovelace: bigint;
}
export interface RecordedOutput {
response: Record<string, unknown>;
receipt: Record<string, unknown>;
promptTokens: number;
completionTokens: number;
actualCostUsdNanos: bigint | null;
outputText: string;
}
export interface SubmittedJobRow {
escrowRef: string;
submittedRef: string;
}
export interface PublicResellerJob {
escrow_ref: string;
status: "settled" | "failed";
provider: string;
provider_model: string;
marketplace_model: string;
prompt_preview: string | null;
output_preview: string | null;
upstream_cost_usd: string | null;
ap3x_payout: string;
claim_tx_hash: string | null;
submitted_ref: string | null;
failure_reason: string | null;
created_at: string;
settled_at: string | null;
}
export interface PublicResellerTotals {
settled_jobs: number;
failed_jobs: number;
upstream_spend_usd: string;
ap3x_earned: string;
}
export class ResellerEvidenceStore {
private readonly pool: Pool;
private readonly previewMaxChars: number;
constructor(databaseUrl: string, previewMaxChars: number) {
this.pool = new Pool({ connectionString: databaseUrl, max: 4 });
this.previewMaxChars = previewMaxChars;
}
async initialize(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query("SELECT pg_advisory_lock(9042713861245)");
await client.query(SCHEMA_SQL);
} finally {
await client.query("SELECT pg_advisory_unlock(9042713861245)").catch(() => undefined);
client.release();
}
}
async ping(): Promise<void> {
await this.pool.query("SELECT 1");
}
async close(): Promise<void> {
await this.pool.end();
}
async markInterruptedJobs(): Promise<void> {
await this.pool.query(
`UPDATE reseller_jobs
SET status = 'failed', failure_reason = 'interrupted_on_restart', updated_at = now()
WHERE status IN ('received','claimed','running')`,
);
}
async saveCapacity(snapshot: CapacitySnapshot): Promise<void> {
await this.pool.query(
`INSERT INTO reseller_capacity (
singleton, provider, model, can_serve, reason, checked_at,
remaining_allowance_nanos, reserve_nanos, sellable_nanos,
worst_case_job_nanos, available_jobs, limit_reset, updated_at
) VALUES (true,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
ON CONFLICT (singleton) DO UPDATE SET
provider=EXCLUDED.provider, model=EXCLUDED.model,
can_serve=EXCLUDED.can_serve, reason=EXCLUDED.reason,
checked_at=EXCLUDED.checked_at,
remaining_allowance_nanos=EXCLUDED.remaining_allowance_nanos,
reserve_nanos=EXCLUDED.reserve_nanos,
sellable_nanos=EXCLUDED.sellable_nanos,
worst_case_job_nanos=EXCLUDED.worst_case_job_nanos,
available_jobs=EXCLUDED.available_jobs,
limit_reset=EXCLUDED.limit_reset, updated_at=now()`,
[
snapshot.provider,
snapshot.model,
snapshot.canServe,
snapshot.reason,
snapshot.checkedAtMs === null ? null : new Date(snapshot.checkedAtMs),
snapshot.remainingAllowanceUsdNanos.toString(),
snapshot.reserveUsdNanos.toString(),
snapshot.sellableUsdNanos.toString(),
snapshot.worstCaseJobUsdNanos.toString(),
snapshot.availableJobs?.toString() ?? null,
snapshot.limitReset,
],
);
}
async recordReceived(job: ReceivedJob): Promise<void> {
const promptPreview = job.publicPreview
? redactPublicPreview(
job.messages.map((message) => `${message.role}: ${String(message.content)}`).join(" "),
this.previewMaxChars,
)
: null;
await this.pool.query(
`INSERT INTO reseller_jobs (
escrow_ref, status, provider, provider_model, marketplace_model,
request_json, public_preview, prompt_preview, balance_before_nanos,
worst_case_cost_nanos, price_lovelace
) VALUES ($1,'received',$2,$3,$4,$5::jsonb,$6,$7,$8,$9,$10)`,
[
job.escrowRef,
job.provider,
job.providerModel,
job.marketplaceModel,
JSON.stringify(job.messages),
job.publicPreview,
promptPreview,
job.balanceBeforeUsdNanos.toString(),
job.worstCaseCostUsdNanos.toString(),
job.priceLovelace.toString(),
],
);
}
async recordClaimed(
escrowRef: string,
claimTxHash: string,
localJobId: string,
): Promise<void> {
await this.transition(
escrowRef,
["received"],
`status='claimed', claim_tx_hash=$2, local_job_id=$3`,
[claimTxHash, localJobId],
);
}
async recordInferenceStarted(escrowRef: string): Promise<void> {
await this.transition(escrowRef, ["claimed"], `status='running'`, []);
}
async recordOutput(escrowRef: string, output: RecordedOutput): Promise<void> {
const outputPreview = await this.publicPreviewEnabled(escrowRef)
? redactPublicPreview(output.outputText, this.previewMaxChars)
: null;
await this.transition(
escrowRef,
["running"],
`response_json=$2::jsonb, receipt_json=$3::jsonb,
prompt_tokens=$4, completion_tokens=$5, actual_cost_nanos=$6,
output_preview=$7`,
[
JSON.stringify(output.response),
JSON.stringify(output.receipt),
output.promptTokens,
output.completionTokens,
output.actualCostUsdNanos?.toString() ?? null,
outputPreview,
],
false,
);
}
async recordSubmitting(escrowRef: string, submittedRef: string): Promise<void> {
await this.transition(
escrowRef,
["running"],
`status='submitting', submitted_ref=$2`,
[submittedRef],
);
}
async recordSubmitConfirmed(escrowRef: string): Promise<void> {
await this.transition(
escrowRef,
["submitting"],
`status='submitted'`,
[],
);
}
async recordSettled(
escrowRef: string,
balanceAfterUsdNanos: bigint | null,
): Promise<void> {
await this.transition(
escrowRef,
["submitted"],
`status='settled', balance_after_nanos=$2, settled_at=now()`,
[balanceAfterUsdNanos?.toString() ?? null],
);
}
async recordFailed(escrowRef: string, reason: string): Promise<void> {
await this.pool.query(
`UPDATE reseller_jobs
SET status='failed', failure_reason=$2, updated_at=now()
WHERE escrow_ref=$1 AND status <> 'settled'`,
[escrowRef, reason.slice(0, 1000)],
);
}
async listSubmitting(): Promise<SubmittedJobRow[]> {
const result = await this.pool.query<{
escrow_ref: string;
submitted_ref: string;
}>(
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
WHERE status='submitting' AND submitted_ref IS NOT NULL`,
);
return result.rows.map((row) => ({
escrowRef: row.escrow_ref,
submittedRef: row.submitted_ref,
}));
}
async listSubmitted(): Promise<SubmittedJobRow[]> {
const result = await this.pool.query<{
escrow_ref: string;
submitted_ref: string;
}>(
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
WHERE status='submitted' AND submitted_ref IS NOT NULL`,
);
return result.rows.map((row) => ({
escrowRef: row.escrow_ref,
submittedRef: row.submitted_ref,
}));
}
async listPublicJobs(limit: number): Promise<PublicResellerJob[]> {
const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
const result = await this.pool.query<{
escrow_ref: string;
status: "settled" | "failed";
provider: string;
provider_model: string;
marketplace_model: string;
prompt_preview: string | null;
output_preview: string | null;
actual_cost_nanos: string | null;
price_lovelace: string;
claim_tx_hash: string | null;
submitted_ref: string | null;
failure_reason: string | null;
created_at: Date;
settled_at: Date | null;
}>(
`SELECT escrow_ref,status,provider,provider_model,marketplace_model,
prompt_preview,output_preview,actual_cost_nanos,price_lovelace,
claim_tx_hash,submitted_ref,failure_reason,created_at,settled_at
FROM reseller_jobs WHERE status IN ('settled','failed')
ORDER BY created_at DESC LIMIT $1`,
[safeLimit],
);
return result.rows.map((row) => ({
escrow_ref: row.escrow_ref,
status: row.status,
provider: row.provider,
provider_model: row.provider_model,
marketplace_model: row.marketplace_model,
prompt_preview: row.prompt_preview,
output_preview: row.output_preview,
upstream_cost_usd: row.actual_cost_nanos === null
? null
: formatUsdNanos(BigInt(row.actual_cost_nanos)),
ap3x_payout: row.status === "settled"
? formatAp3x(BigInt(row.price_lovelace))
: "0.000000",
claim_tx_hash: row.claim_tx_hash,
submitted_ref: row.submitted_ref,
failure_reason: row.failure_reason,
created_at: row.created_at.toISOString(),
settled_at: row.settled_at?.toISOString() ?? null,
}));
}
async publicTotals(): Promise<PublicResellerTotals> {
const result = await this.pool.query<{
settled_jobs: string;
failed_jobs: string;
spend_nanos: string;
earned_lovelace: string;
}>(
`SELECT
count(*) FILTER (WHERE status='settled')::text AS settled_jobs,
count(*) FILTER (WHERE status='failed')::text AS failed_jobs,
coalesce(sum(actual_cost_nanos),0)::text AS spend_nanos,
coalesce(sum(price_lovelace) FILTER (WHERE status='settled'),0)::text AS earned_lovelace
FROM reseller_jobs`,
);
const row = result.rows[0];
return {
settled_jobs: Number(row?.settled_jobs ?? 0),
failed_jobs: Number(row?.failed_jobs ?? 0),
upstream_spend_usd: formatUsdNanos(BigInt(row?.spend_nanos ?? 0)),
ap3x_earned: formatAp3x(BigInt(row?.earned_lovelace ?? 0)),
};
}
private async publicPreviewEnabled(escrowRef: string): Promise<boolean> {
const result = await this.pool.query<{ public_preview: boolean }>(
"SELECT public_preview FROM reseller_jobs WHERE escrow_ref=$1",
[escrowRef],
);
return result.rows[0]?.public_preview === true;
}
private async transition(
escrowRef: string,
expected: string[],
assignment: string,
values: unknown[],
requireStatusChange = true,
): Promise<void> {
const parameters = [escrowRef, ...values];
const expectedPlaceholders = expected
.map((_, index) => `$${parameters.length + index + 1}`)
.join(",");
parameters.push(...expected);
const result = await this.pool.query(
`UPDATE reseller_jobs SET ${assignment}, updated_at=now()
WHERE escrow_ref=$1 AND status IN (${expectedPlaceholders})`,
parameters,
);
if (result.rowCount !== 1 && requireStatusChange) {
throw new Error(
`invalid reseller job transition for ${escrowRef}; expected ${expected.join("|")}`,
);
}
if (result.rowCount !== 1 && !requireStatusChange) {
throw new Error(`reseller job not running: ${escrowRef}`);
}
}
}
export function redactPublicPreview(value: string, maxChars: number): string {
const redacted = value
.replace(
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g,
"[private material]",
)
.replace(
/["']?\b[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PRIV[_-]?KEY)\b["']?\s*[:=]\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s,}]+)/gi,
"[secret assignment]",
)
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt]")
.replace(/\b(?:gh[opsu]_|github_pat_)[A-Za-z0-9_]{16,}\b/g, "[secret]")
.replace(/\b[0-9a-fA-F]{64}\b/g, "[64-hex secret]")
.replace(/\b(?:sk|key)-[A-Za-z0-9_-]{8,}\b/g, "[secret]")
.replace(/\bBearer\s+\S+/gi, "Bearer [secret]")
.replace(/\bBasic\s+[A-Za-z0-9+/=]+/gi, "Basic [secret]")
.replace(/\b(?:xox[baprs]-|AKIA)[A-Za-z0-9_-]{12,}\b/g, "[secret]")
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[email]")
.replace(/https?:\/\/\S+/gi, "[url]")
.replace(/\s+/g, " ")
.trim();
return redacted.length <= maxChars
? redacted
: `${redacted.slice(0, Math.max(0, maxChars - 1))}…`;
}
function formatAp3x(lovelace: bigint): string {
const whole = lovelace / 1_000_000n;
const fraction = (lovelace % 1_000_000n).toString().padStart(6, "0");
return `${whole}.${fraction}`;
}

View File

@@ -0,0 +1,54 @@
export const USD_NANOS = 1_000_000_000n;
export type DecimalRounding = "floor" | "ceil";
/** Parse a non-negative decimal USD string into integer nanodollars. */
export function parseUsdDecimal(
raw: string,
rounding: DecimalRounding,
): bigint {
const match = /^(?:0|[1-9]\d*)(?:\.(\d+))?$/.exec(raw);
if (!match) throw new Error(`invalid USD decimal: ${raw}`);
const dot = raw.indexOf(".");
const wholeRaw = dot === -1 ? raw : raw.slice(0, dot);
const fractionRaw = dot === -1 ? "" : raw.slice(dot + 1);
const kept = fractionRaw.slice(0, 9).padEnd(9, "0");
let nanos = BigInt(wholeRaw) * USD_NANOS + BigInt(kept || "0");
if (rounding === "ceil" && /[1-9]/.test(fractionRaw.slice(9))) {
nanos += 1n;
}
return nanos;
}
/**
* Convert a provider JSON number to a conservative remaining-balance value.
* One nanodollar is removed to protect against binary floating-point rounding.
*/
export function parseUsdNumberFloor(value: unknown, field: string): bigint {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number`);
}
const scaled = BigInt(Math.floor(value * Number(USD_NANOS)));
return scaled > 0n ? scaled - 1n : 0n;
}
/** Convert a provider JSON cost to nanodollars without rounding down. */
export function parseUsdNumberCeil(value: unknown, field: string): bigint {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number`);
}
if (value === 0) return 0n;
return BigInt(Math.ceil(value * Number(USD_NANOS))) + 1n;
}
export function formatUsdNanos(value: bigint, decimals = 6): string {
if (value < 0n) throw new Error("USD value must be non-negative");
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
throw new Error("USD display decimals must be from 0 to 9");
}
const whole = value / USD_NANOS;
if (decimals === 0) return whole.toString();
const fraction = (value % USD_NANOS).toString().padStart(9, "0");
return `${whole}.${fraction.slice(0, decimals)}`;
}

View File

@@ -0,0 +1,189 @@
import type {
CapacityProvider,
ProviderCapacityReading,
ProviderModelPricing,
} from "./provider.js";
import { parseUsdDecimal, parseUsdNumberFloor } from "./money.js";
interface OpenRouterCapacityOptions {
baseUrl: string;
apiKey: string;
timeoutMs: number;
pricingCacheMs?: number;
fetchFn?: typeof globalThis.fetch;
now?: () => number;
}
export class OpenRouterCapacityProvider implements CapacityProvider {
readonly id = "openrouter";
private readonly apiRoot: string;
private readonly apiKey: string;
private readonly timeoutMs: number;
private readonly pricingCacheMs: number;
private readonly fetchFn: typeof globalThis.fetch;
private readonly now: () => number;
private pricingCache: ProviderModelPricing | null = null;
constructor(options: OpenRouterCapacityOptions) {
this.apiRoot = `${options.baseUrl.replace(/\/+$/, "")}/v1`;
this.apiKey = options.apiKey;
this.timeoutMs = options.timeoutMs;
this.pricingCacheMs = options.pricingCacheMs ?? 300_000;
this.fetchFn = options.fetchFn ?? globalThis.fetch;
this.now = options.now ?? Date.now;
}
async readCapacity(model: string): Promise<ProviderCapacityReading> {
const [key, pricing] = await Promise.all([
this.readKeyLimit(),
this.readModelPricing(model),
]);
return {
provider: this.id,
keyLimitUsdNanos: key.limit,
keyRemainingUsdNanos: key.remaining,
limitReset: key.reset,
pricing,
checkedAtMs: this.now(),
};
}
private async readKeyLimit(): Promise<{
limit: bigint;
remaining: bigint;
reset: string | null;
}> {
const body = await this.fetchJson(`${this.apiRoot}/key`);
const data = readRecord(body, "OpenRouter key response").data;
const key = readRecord(data, "OpenRouter key response.data");
if (key.limit === null || key.limit_remaining === null) {
throw new Error("OpenRouter resale key must have a finite spending limit");
}
const limit = parseUsdNumberFloor(key.limit, "OpenRouter key limit");
const remaining = parseUsdNumberFloor(
key.limit_remaining,
"OpenRouter key limit_remaining",
);
const reset = key.limit_reset;
if (reset !== null && typeof reset !== "string") {
throw new Error("OpenRouter key limit_reset must be a string or null");
}
return { limit, remaining, reset };
}
private async readModelPricing(model: string): Promise<ProviderModelPricing> {
const cached = this.pricingCache;
const now = this.now();
if (
cached &&
cached.model === model &&
now - cached.fetchedAtMs < this.pricingCacheMs
) {
return cached;
}
const body = await this.fetchJson(`${this.apiRoot}/models`);
const root = readRecord(body, "OpenRouter models response");
if (!Array.isArray(root.data)) {
throw new Error("OpenRouter models response.data must be an array");
}
const rawModel = root.data.find(
(entry) =>
typeof entry === "object" &&
entry !== null &&
(entry as Record<string, unknown>).id === model,
);
if (!rawModel) throw new Error(`OpenRouter model not found: ${model}`);
const modelRecord = rawModel as Record<string, unknown>;
const pricing = readRecord(modelRecord.pricing, `OpenRouter pricing for ${model}`);
const prompt = readPrice(pricing.prompt, `${model} prompt price`);
const completion = readPrice(
pricing.completion,
`${model} completion price`,
);
const request = pricing.request === undefined
? 0n
: readPrice(pricing.request, `${model} request price`);
const result: ProviderModelPricing = {
model,
promptUsdNanosPerToken: prompt,
completionUsdNanosPerToken: completion,
requestUsdNanos: request,
fetchedAtMs: now,
};
this.pricingCache = result;
return result;
}
private async fetchJson(url: string): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await this.fetchFn(url, {
headers: { authorization: `Bearer ${this.apiKey}` },
signal: controller.signal,
});
const text = await readBoundedBody(response, 8 * 1024 * 1024, controller);
if (!response.ok) {
throw new Error(
`OpenRouter capacity request returned HTTP ${response.status}${
text ? `: ${text.slice(0, 200)}` : ""
}`,
);
}
try {
return JSON.parse(text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`OpenRouter capacity response was not JSON: ${detail}`);
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`OpenRouter capacity request failed: ${detail}`);
} finally {
clearTimeout(timer);
}
}
}
async function readBoundedBody(
response: Response,
maxBytes: number,
controller: AbortController,
): Promise<string> {
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parts: string[] = [];
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > maxBytes) {
controller.abort();
throw new Error(`OpenRouter capacity response exceeded ${maxBytes} bytes`);
}
parts.push(decoder.decode(chunk.value, { stream: true }));
}
parts.push(decoder.decode());
return parts.join("");
}
function readRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function readPrice(value: unknown, label: string): bigint {
if (typeof value !== "string") throw new Error(`${label} must be a string`);
try {
return parseUsdDecimal(value, "ceil");
} catch {
throw new Error(`${label} must be a non-negative decimal`);
}
}

View File

@@ -0,0 +1,22 @@
export interface ProviderModelPricing {
model: string;
promptUsdNanosPerToken: bigint;
completionUsdNanosPerToken: bigint;
requestUsdNanos: bigint;
fetchedAtMs: number;
}
export interface ProviderCapacityReading {
provider: string;
keyLimitUsdNanos: bigint;
keyRemainingUsdNanos: bigint;
limitReset: string | null;
pricing: ProviderModelPricing;
checkedAtMs: number;
}
/** Provider-specific balance and price discovery. Inference stays OpenAI-compatible. */
export interface CapacityProvider {
readonly id: string;
readCapacity(model: string): Promise<ProviderCapacityReading>;
}

View File

@@ -0,0 +1,331 @@
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import type { ChatMessage } from "@marketplace/shared/tx";
import type { AdvertDatum } from "@marketplace/shared/cbor";
import type { SupplierState } from "../state.js";
import { CapacityGate, type CapacitySnapshot } from "./capacityGate.js";
import {
ResellerEvidenceStore,
type PublicResellerJob,
type RecordedOutput,
} from "./evidenceStore.js";
import { formatUsdNanos } from "./money.js";
interface ResellerRuntimeOptions {
chain: ChainProvider;
state: SupplierState;
gate: CapacityGate;
store: ResellerEvidenceStore;
advert: AdvertDatum;
advertRef: OutputReference;
providerModel: string;
pollIntervalMs: number;
settlementPollMs?: number;
}
export interface PublicResellerState {
provider: string;
provider_model: string;
marketplace_model: string;
advert_ref: string;
supplier_pkh: string;
status: "free" | "working" | "offline";
reason: string;
capacity: {
remaining_allowance_usd: string;
protected_reserve_usd: string;
sellable_usd: string;
committed_usd: string;
worst_case_job_usd: string;
available_jobs: string | null;
checked_at: string | null;
limit_reset: string | null;
};
totals: {
settled_jobs: number;
failed_jobs: number;
upstream_spend_usd: string;
ap3x_earned: string;
};
}
export class ResellerRuntime {
readonly maxInputTokens: number;
private readonly chain: ChainProvider;
private readonly state: SupplierState;
private readonly gate: CapacityGate;
private readonly store: ResellerEvidenceStore;
private readonly advert: AdvertDatum;
private readonly advertRef: OutputReference;
private readonly providerModel: string;
private readonly pollIntervalMs: number;
private readonly settlementPollMs: number;
private capacityTimer: NodeJS.Timeout | null = null;
private readonly settlementTimers = new Map<string, NodeJS.Timeout>();
private readonly submissionTimers = new Map<string, NodeJS.Timeout>();
constructor(options: ResellerRuntimeOptions & { maxInputTokens: number }) {
this.chain = options.chain;
this.state = options.state;
this.gate = options.gate;
this.store = options.store;
this.advert = options.advert;
this.advertRef = options.advertRef;
this.providerModel = options.providerModel;
this.pollIntervalMs = options.pollIntervalMs;
this.settlementPollMs = options.settlementPollMs ?? 5_000;
this.maxInputTokens = options.maxInputTokens;
}
async initialize(): Promise<void> {
await this.store.initialize();
await this.store.markInterruptedJobs();
await this.refreshCapacity();
this.capacityTimer = setInterval(() => {
void this.refreshCapacity().catch(() => undefined);
}, this.pollIntervalMs);
this.capacityTimer.unref();
for (const row of await this.store.listSubmitting()) {
this.trackSubmissionConfirmation(row.escrowRef, row.submittedRef);
}
for (const row of await this.store.listSubmitted()) {
this.trackSettlement(row.escrowRef, row.submittedRef);
}
}
async close(): Promise<void> {
clearInterval(this.capacityTimer ?? undefined);
this.capacityTimer = null;
for (const timer of this.settlementTimers.values()) clearInterval(timer);
this.settlementTimers.clear();
for (const timer of this.submissionTimers.values()) clearInterval(timer);
this.submissionTimers.clear();
await this.store.close();
}
capacitySnapshot(): CapacitySnapshot {
return this.gate.snapshot();
}
effectiveStatus(): "free" | "working" | "offline" {
const base = this.state.snapshot().status;
if (base === "working") return "working";
if (base === "offline") return "offline";
return this.gate.snapshot().canServe ? "free" : "offline";
}
async refreshCapacity(): Promise<CapacitySnapshot> {
try {
await this.store.ping();
const snapshot = await this.gate.refresh();
await this.store.saveCapacity(snapshot);
return snapshot;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.gate.forceUnavailable("database_unavailable", detail);
throw error;
}
}
async recordReceived(
escrowRef: string,
messages: ChatMessage[],
publicPreview: boolean,
): Promise<void> {
const snapshot = this.gate.snapshot();
await this.databaseOperation(() => this.store.recordReceived({
escrowRef,
provider: snapshot.provider,
providerModel: this.providerModel,
marketplaceModel: this.advert.model,
messages,
publicPreview,
balanceBeforeUsdNanos: snapshot.remainingAllowanceUsdNanos,
worstCaseCostUsdNanos: snapshot.worstCaseJobUsdNanos,
priceLovelace: this.advert.price_lovelace,
}));
}
async recordClaimed(
escrowRef: string,
claimTxHash: string,
localJobId: string,
): Promise<void> {
await this.databaseOperation(
() => this.store.recordClaimed(escrowRef, claimTxHash, localJobId),
);
}
async recordInferenceStarted(escrowRef: string): Promise<void> {
await this.databaseOperation(() => this.store.recordInferenceStarted(escrowRef));
}
async recordOutput(
escrowRef: string,
output: Omit<RecordedOutput, "actualCostUsdNanos"> & {
actualCostUsdNanos?: bigint | null;
},
): Promise<void> {
const snapshot = this.gate.snapshot();
const calculatedCost =
snapshot.requestPriceUsdNanos +
snapshot.promptPriceUsdNanosPerToken * BigInt(output.promptTokens) +
snapshot.completionPriceUsdNanosPerToken * BigInt(output.completionTokens);
await this.databaseOperation(() => this.store.recordOutput(escrowRef, {
...output,
actualCostUsdNanos: output.actualCostUsdNanos ?? calculatedCost,
}));
}
async recordSubmitting(escrowRef: string, submittedRef: string): Promise<void> {
await this.databaseOperation(
() => this.store.recordSubmitting(escrowRef, submittedRef),
);
}
async recordSubmitConfirmed(
escrowRef: string,
submittedRef: string,
): Promise<void> {
await this.databaseOperation(
() => this.store.recordSubmitConfirmed(escrowRef),
);
this.trackSettlement(escrowRef, submittedRef);
}
watchSubmitting(escrowRef: string, submittedRef: string): void {
this.trackSubmissionConfirmation(escrowRef, submittedRef);
}
async recordFailed(escrowRef: string, reason: string): Promise<void> {
await this.databaseOperation(() => this.store.recordFailed(escrowRef, reason));
}
async publicState(): Promise<PublicResellerState> {
try {
await this.store.ping();
const totals = await this.store.publicTotals();
const capacity = this.gate.snapshot();
const base = this.state.snapshot();
const status = this.effectiveStatus();
const committed = base.status === "working"
? capacity.worstCaseJobUsdNanos
: 0n;
return {
provider: capacity.provider,
provider_model: this.providerModel,
marketplace_model: this.advert.model,
advert_ref: `${this.advertRef.txHash}#${this.advertRef.index}`,
supplier_pkh: this.advert.supplier_pkh,
status,
reason: status === "working" ? "working" : capacity.reason,
capacity: {
remaining_allowance_usd: formatUsdNanos(capacity.remainingAllowanceUsdNanos),
protected_reserve_usd: formatUsdNanos(capacity.reserveUsdNanos),
sellable_usd: formatUsdNanos(capacity.sellableUsdNanos),
committed_usd: formatUsdNanos(committed),
worst_case_job_usd: formatUsdNanos(capacity.worstCaseJobUsdNanos),
available_jobs: capacity.availableJobs?.toString() ?? null,
checked_at: capacity.checkedAtMs === null
? null
: new Date(capacity.checkedAtMs).toISOString(),
limit_reset: capacity.limitReset,
},
totals,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.gate.forceUnavailable("database_unavailable", detail);
throw error;
}
}
async publicJobs(limit: number): Promise<PublicResellerJob[]> {
try {
return await this.store.listPublicJobs(limit);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.gate.forceUnavailable("database_unavailable", detail);
throw error;
}
}
private async databaseOperation<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.gate.forceUnavailable("database_unavailable", detail);
throw error;
}
}
private trackSubmissionConfirmation(
escrowRef: string,
submittedRef: string,
): void {
if (this.submissionTimers.has(escrowRef)) return;
const ref = parseOutputReference(submittedRef);
if (!ref) return;
let running = false;
const check = async (): Promise<void> => {
if (running) return;
running = true;
try {
const utxo = await this.chain.queryUtxo(ref);
if (utxo === null) return;
await this.recordSubmitConfirmed(escrowRef, submittedRef);
clearInterval(this.submissionTimers.get(escrowRef));
this.submissionTimers.delete(escrowRef);
} catch {
// A later poll retries. Provider inference is never replayed.
} finally {
running = false;
}
};
const timer = setInterval(() => void check(), this.settlementPollMs);
timer.unref();
this.submissionTimers.set(escrowRef, timer);
void check();
}
private trackSettlement(escrowRef: string, submittedRef: string): void {
if (this.settlementTimers.has(escrowRef)) return;
const ref = parseOutputReference(submittedRef);
if (!ref) return;
let running = false;
const check = async (): Promise<void> => {
if (running) return;
running = true;
try {
const utxo = await this.chain.queryUtxo(ref);
if (utxo !== null) return;
await this.refreshCapacity().catch(() => this.gate.snapshot());
const balance = this.gate.snapshot().error === null
? this.gate.snapshot().remainingAllowanceUsdNanos
: null;
await this.databaseOperation(
() => this.store.recordSettled(escrowRef, balance),
);
clearInterval(this.settlementTimers.get(escrowRef));
this.settlementTimers.delete(escrowRef);
} catch {
// A later poll retries chain or database recovery. Inference is never replayed.
} finally {
running = false;
}
};
const timer = setInterval(() => void check(), this.settlementPollMs);
timer.unref();
this.settlementTimers.set(escrowRef, timer);
void check();
}
}
function parseOutputReference(value: string): OutputReference | null {
const match = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(value);
if (!match) return null;
return { txHash: match[1], index: Number(match[2]) };
}

View File

@@ -0,0 +1,176 @@
import { readFile } from "node:fs/promises";
export interface ResellerSetupConfig {
networkId: 1;
ogmiosUrl: string;
endpointUrl: string;
marketplaceModel: string;
openRouterModel: string;
maxInputTokens: number;
maxOutputTokens: number;
maxProcessingMs: number;
inferenceTimeoutMs: number;
priceLovelace: bigint;
supplierBondLovelace: bigint;
buyerBondLovelace: bigint;
reserveUsd: string;
pollIntervalMs: number;
providerTimeoutMs: number;
previewMaxChars: number;
minimumWalletLovelace: bigint;
fundingTimeoutMs: number;
composeFile: string;
}
export async function loadResellerSetupConfig(
path: string,
): Promise<ResellerSetupConfig> {
let parsed: unknown;
try {
parsed = JSON.parse(await readFile(path, "utf8"));
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`unable to read reseller config ${path}: ${detail}`);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("reseller config must be a JSON object");
}
const value = parsed as Record<string, unknown>;
const networkId = integerField(value, "networkId");
if (networkId !== 1) throw new Error("reseller config networkId must be 1");
const endpoint = new URL(urlField(value, "endpointUrl", ["https:"]));
if (
endpoint.username !== "" ||
endpoint.password !== "" ||
endpoint.search !== "" ||
endpoint.hash !== "" ||
endpoint.pathname.replace(/\/+$/, "") !== ""
) {
throw new Error(
"reseller config endpointUrl must be a plain HTTPS origin without credentials, path, query, or fragment",
);
}
const endpointUrl = endpoint.origin;
const previewMaxChars = optionalInteger(value, "previewMaxChars", 160);
if (previewMaxChars > 1000) {
throw new Error("reseller config previewMaxChars must not exceed 1000");
}
const providerTimeoutMs = optionalInteger(value, "providerTimeoutMs", 10_000);
const maxProcessingMs = integerField(value, "maxProcessingMs");
const settlementMarginMs = 150_000 + providerTimeoutMs;
const defaultInferenceTimeoutMs = maxProcessingMs - settlementMarginMs;
if (defaultInferenceTimeoutMs <= 0) {
throw new Error(
"reseller config maxProcessingMs does not leave time for provider checks and chain settlement",
);
}
const inferenceTimeoutMs = value.inferenceTimeoutMs === undefined
? defaultInferenceTimeoutMs
: integerField(value, "inferenceTimeoutMs");
if (inferenceTimeoutMs + settlementMarginMs > maxProcessingMs) {
throw new Error(
"reseller config inferenceTimeoutMs does not leave time for provider checks and chain settlement",
);
}
return {
networkId: 1,
ogmiosUrl: urlField(value, "ogmiosUrl", ["http:", "https:"]),
endpointUrl,
marketplaceModel: stringField(value, "marketplaceModel"),
openRouterModel: stringField(value, "openRouterModel"),
maxInputTokens: integerField(value, "maxInputTokens"),
maxOutputTokens: integerField(value, "maxOutputTokens"),
maxProcessingMs,
inferenceTimeoutMs,
priceLovelace: bigintField(value, "priceLovelace"),
supplierBondLovelace: bigintField(value, "supplierBondLovelace"),
buyerBondLovelace: bigintField(value, "buyerBondLovelace"),
reserveUsd: usdField(value, "reserveUsd"),
pollIntervalMs: optionalInteger(value, "pollIntervalMs", 30_000),
providerTimeoutMs,
previewMaxChars,
minimumWalletLovelace: optionalBigint(value, "minimumWalletLovelace", 20_000_000n),
fundingTimeoutMs: optionalInteger(value, "fundingTimeoutMs", 3_600_000),
composeFile: stringField(value, "composeFile"),
};
}
function stringField(value: Record<string, unknown>, name: string): string {
const field = value[name];
if (typeof field !== "string" || field.trim() === "") {
throw new Error(`reseller config ${name} must be a non-empty string`);
}
if (/[\r\n]/.test(field)) {
throw new Error(`reseller config ${name} must be one line`);
}
return field.trim();
}
function urlField(
value: Record<string, unknown>,
name: string,
protocols: string[],
): string {
const raw = stringField(value, name);
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error(`reseller config ${name} must be a valid URL`);
}
if (!protocols.includes(url.protocol)) {
throw new Error(
`reseller config ${name} must use ${protocols.join(" or ")}`,
);
}
return raw;
}
function integerField(value: Record<string, unknown>, name: string): number {
const field = value[name];
if (!Number.isSafeInteger(field) || (field as number) <= 0) {
throw new Error(`reseller config ${name} must be a positive safe integer`);
}
return field as number;
}
function optionalInteger(
value: Record<string, unknown>,
name: string,
fallback: number,
): number {
return value[name] === undefined ? fallback : integerField(value, name);
}
function bigintField(value: Record<string, unknown>, name: string): bigint {
const field = value[name];
if (typeof field !== "string" || !/^[1-9]\d*$/.test(field)) {
throw new Error(`reseller config ${name} must be a positive integer string`);
}
return BigInt(field);
}
function optionalBigint(
value: Record<string, unknown>,
name: string,
fallback: bigint,
): bigint {
return value[name] === undefined ? fallback : bigintField(value, name);
}
function usdField(value: Record<string, unknown>, name: string): string {
const field = value[name];
if (
typeof field !== "string" ||
!/^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/.test(field)
) {
throw new Error(
`reseller config ${name} must be a non-negative decimal string`,
);
}
return field;
}

View File

@@ -38,6 +38,7 @@ import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
import type { AdvertDatum, EscrowDatum } from "@marketplace/shared/cbor";
import type { ChatMessage, WalletKey } from "@marketplace/shared/tx";
import { chatInputTokenUpperBound } from "@marketplace/shared/tx";
import {
buildClaimTx,
chatSessionPromptHash,
@@ -62,6 +63,9 @@ import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js"
import { callOpenAiStream } from "./openai.js";
import { healthzRouter } from "./routes/healthz.js";
import { triggerOnFailureConsolidate } from "./walletHealth.js";
import type { ResellerRuntime } from "./reseller/runtime.js";
import { formatUsdNanos } from "./reseller/money.js";
import { renderResellerDashboardPage } from "./reseller/dashboardPage.js";
export interface SupplierDeps {
chain: ChainProvider;
@@ -72,6 +76,8 @@ export interface SupplierDeps {
jobs?: JobStore;
/** Optional: defaults to a fresh ChatSessionStore (chat-session capability). */
chatSessions?: ChatSessionStore;
/** Optional balance-aware resale runtime. Absent for every legacy supplier. */
reseller?: ResellerRuntime;
}
const ESCROW_REF_RE = /^[0-9a-fA-F]{64}#(?:0|[1-9]\d*)$/;
@@ -102,6 +108,7 @@ interface ResolvedDeps {
supplierKey: WalletKey;
jobs: JobStore;
chatSessions: ChatSessionStore;
reseller?: ResellerRuntime;
}
function makeCapabilityHandler(deps: ResolvedDeps) {
@@ -138,6 +145,9 @@ function makeCapabilityHandler(deps: ResolvedDeps) {
supplier_pkh: datum.supplier_pkh,
// SPEC FIX 2026-04-25: pub_key_hex required for buyer-side receipt verification
pub_key_hex: deps.supplierKey.pubKeyHex,
...(deps.reseller
? { max_input_tokens: deps.reseller.maxInputTokens }
: {}),
});
} catch (err) {
next(err);
@@ -151,8 +161,9 @@ function makeCapabilityHandler(deps: ResolvedDeps) {
function makeStatusHandler(deps: ResolvedDeps) {
return (_req: Request, res: Response) => {
const snap = deps.state.snapshot();
const effectiveStatus = deps.reseller?.effectiveStatus() ?? snap.status;
const payload: Record<string, unknown> = {
status: snap.status,
status: effectiveStatus,
last_seen: snap.lastSeenIso,
active_sessions: snap.activeSessions,
max_sessions: snap.maxSessions,
@@ -160,6 +171,17 @@ function makeStatusHandler(deps: ResolvedDeps) {
if (snap.status === "working" && snap.currentEscrowRef) {
payload.current_escrow_ref = snap.currentEscrowRef;
}
if (deps.reseller) {
const capacity = deps.reseller.capacitySnapshot();
payload.capacity = {
provider: capacity.provider,
reason: effectiveStatus === "working" ? "working" : capacity.reason,
sellable_usd: formatUsdNanos(capacity.sellableUsdNanos),
checked_at: capacity.checkedAtMs === null
? null
: new Date(capacity.checkedAtMs).toISOString(),
};
}
return res.status(200).json(payload);
};
}
@@ -202,6 +224,8 @@ function makeChatHandler(deps: ResolvedDeps) {
// Slot held by THIS request (null until acquired / after hand-off to the
// job runner) — the catch-all must only release its own acquisition.
let acquiredRef: string | null = null;
let evidenceRecorded = false;
let evidenceEscrowRef: string | null = null;
try {
// ── 1. Header validation ──────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
@@ -214,6 +238,7 @@ function makeChatHandler(deps: ResolvedDeps) {
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
evidenceEscrowRef = escrowRefStr;
// ── 2. Body shape validation ─────────────────────────────────────
const body = (req.body ?? {}) as ChatBody;
@@ -240,6 +265,18 @@ function makeChatHandler(deps: ResolvedDeps) {
}
}
const validatedMessages = messages as Array<{ role: "system" | "user" | "assistant"; content: string }>;
if (deps.reseller) {
const inputUnits = chatInputTokenUpperBound(validatedMessages);
if (inputUnits > deps.reseller.maxInputTokens) {
return jsonError(
res,
413,
"input_cap_exceeded",
`input bound ${inputUnits} exceeds supplier cap ${deps.reseller.maxInputTokens}`,
);
}
}
// ── 3. Resolve advert ────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
@@ -314,6 +351,28 @@ function makeChatHandler(deps: ResolvedDeps) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
if (deps.reseller) {
let capacity;
try {
capacity = await deps.reseller.refreshCapacity();
} catch {
return jsonError(
res,
503,
"capacity_unavailable",
"provider capacity or evidence database is unavailable",
);
}
if (!capacity.canServe) {
return jsonError(
res,
503,
"capacity_unavailable",
`provider capacity is unavailable (${capacity.reason})`,
);
}
}
// ── 8. Acquire session slot ──────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
@@ -322,6 +381,26 @@ function makeChatHandler(deps: ResolvedDeps) {
acquiredRef = escrowRefStr;
// From here on, every error path MUST release the slot until the
// runner takes ownership of it.
if (deps.reseller) {
try {
await deps.reseller.recordReceived(
escrowRefStr,
validatedMessages,
req.header("X-Vector-Public-Preview") === "1",
);
evidenceRecorded = true;
} catch {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(
res,
503,
"evidence_unavailable",
"reseller evidence database is unavailable",
);
}
}
// ── 9+10. Claim tx: build + confirm as ONE wallet critical section ─
// (splitting them would let another wallet spend coin-select mid-flight)
@@ -343,7 +422,12 @@ function makeChatHandler(deps: ResolvedDeps) {
}
return { kind: "ok" as const, built };
});
const failEvidence = async (reason: string): Promise<void> => {
if (!evidenceRecorded || !deps.reseller) return;
await deps.reseller.recordFailed(escrowRefStr, reason).catch(() => undefined);
};
if (claimOutcome.kind === "build_failed") {
await failEvidence("claim_submit_failed");
deps.state.release(escrowRefStr);
acquiredRef = null;
// Backstop the periodic wallet-health ticker: most Claim build failures
@@ -359,6 +443,7 @@ function makeChatHandler(deps: ResolvedDeps) {
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
await failEvidence("claim_confirmation_timeout");
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout",
@@ -372,6 +457,32 @@ function makeChatHandler(deps: ResolvedDeps) {
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
if (deps.reseller) {
try {
await deps.reseller.recordClaimed(
escrowRefStr,
claimResult.expectedTxHash,
jobId,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
deps.state.release(escrowRefStr);
acquiredRef = null;
await failEvidence("claim_record_failed");
return jsonError(
res,
503,
"evidence_unavailable",
"Claim confirmed but evidence persistence failed",
);
}
}
// Slot release now happens inside runChatJob's finally.
acquiredRef = null;
@@ -382,6 +493,7 @@ function makeChatHandler(deps: ResolvedDeps) {
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
reseller: deps.reseller,
},
jobId,
escrowRef: escrowRefStr,
@@ -401,6 +513,11 @@ function makeChatHandler(deps: ResolvedDeps) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
if (evidenceRecorded && deps.reseller && evidenceEscrowRef) {
await deps.reseller
.recordFailed(evidenceEscrowRef, err instanceof Error ? err.message : String(err))
.catch(() => undefined);
}
next(err);
return;
}
@@ -1272,17 +1389,23 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
};
try {
// Stateful upstreams (OpenClaw) key a persistent agent session off the
// OpenAI `user` field and carry the history themselves — send only this
// turn's delta or the transcript duplicates into the upstream context
// every turn. Stateless upstreams get the full transcript as before.
const stateful = deps.config.openaiSessionPassthrough;
const result = await callOpenAiStream(
{
baseUrl: deps.config.openaiBaseUrl,
model: record.advert.model,
messages: record.transcript,
model: deps.config.openaiModelOverride || record.advert.model,
messages: stateful ? delta : record.transcript,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
maxTokens: deps.config.openaiMaxTokens,
disableReasoning: deps.config.openaiReasoningDisabled,
tools,
toolChoice,
user: stateful ? escrowRefStr : undefined,
},
(tok) => sse({ type: "token", value: tok }),
);
@@ -1377,6 +1500,7 @@ export function createApp(deps: SupplierDeps): Application {
supplierKey: deps.supplierKey,
jobs: deps.jobs ?? new JobStore(),
chatSessions: deps.chatSessions ?? new ChatSessionStore(),
reseller: deps.reseller,
};
const app = express();
@@ -1390,6 +1514,41 @@ export function createApp(deps: SupplierDeps): Application {
// /healthz is mounted FIRST and uses no deps — it must succeed regardless
// of chain/state/config (independent of /status free/working/offline).
app.use(healthzRouter());
if (resolved.reseller) {
app.get("/reseller", (_req: Request, res: Response) => {
res.setHeader("Cache-Control", "no-store");
res.type("html").send(renderResellerDashboardPage());
});
app.get("/reseller/api/status", async (_req: Request, res: Response) => {
res.setHeader("Cache-Control", "no-store");
try {
res.status(200).json(await resolved.reseller!.publicState());
} catch {
jsonError(
res,
503,
"evidence_unavailable",
"reseller evidence database is unavailable",
);
}
});
app.get("/reseller/api/jobs", async (req: Request, res: Response) => {
res.setHeader("Cache-Control", "no-store");
const parsed = Number(req.query.limit ?? 50);
const limit = Number.isSafeInteger(parsed) ? parsed : 50;
try {
res.status(200).json({ jobs: await resolved.reseller!.publicJobs(limit) });
} catch {
jsonError(
res,
503,
"evidence_unavailable",
"reseller evidence database is unavailable",
);
}
});
}
app.get("/capability", makeCapabilityHandler(resolved));
app.get("/status", makeStatusHandler(resolved));

View File

@@ -0,0 +1,789 @@
export interface SurplusOffer {
id: string;
model: string;
sellerBaseUrl: string;
status: "active" | "inactive";
capDailyUsd: number | null;
costMultiplierPpm: number | null;
inputMicroUsdPer1m: number | null;
outputMicroUsdPer1m: number | null;
}
export interface SurplusDiscoveredModel {
model: string;
supported: boolean;
inputUsdPer1m: number;
outputUsdPer1m: number;
priceUnit: string;
priceVariable: boolean;
providerModelId: string;
modelType: string;
availabilityStatus: string;
}
export interface SurplusMarketSummary {
model: string;
requests24h: number;
volume24h: number;
bestInputMicroUsdPer1m: number;
bestOutputMicroUsdPer1m: number;
healthySellerCount: number;
}
export interface SurplusOrderBookOffer {
id: string;
seller: string;
sellerBaseUrl: string;
inputMicroUsdPer1m: number;
outputMicroUsdPer1m: number;
available: boolean;
healthy: boolean;
trusted: boolean;
trades24h: number;
rank: number;
capRemainingMicroUsd: number | null;
volume24hMicroUsd: number;
}
export interface SurplusOrderBook {
model: string;
offers: SurplusOrderBookOffer[];
}
export interface SurplusOfferWrite {
model: string;
apiKey: string;
sellerBaseUrl: string;
costMultiplier: number;
dailyCapUsd: number;
payoutAddress: string;
idempotencyKey: string;
}
export interface SurplusOfferPatch {
costMultiplier: number;
dailyCapUsd: number;
idempotencyKey: string;
}
export interface SurplusSale {
id: string;
model: string;
offerId: string;
settlementStatus: string;
createdAt: string;
createdAtMs: number;
sellerCostMicroUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
effectiveInputUsdPer1m: number;
effectiveOutputUsdPer1m: number;
transactionHash: string | null;
}
export interface SurplusDailyEarnings {
day: string;
earnedUsd: number;
inputTokens: number;
outputTokens: number;
requests: number;
totalTokens: number;
}
export interface SurplusModelEarnings {
model: string;
earnedUsd: number;
requests: number;
totalTokens: number;
lastSaleAt: string | null;
}
export interface SurplusEarnings {
totalEarnedMicroUsd: number;
pendingMicroUsd: number;
paidMicroUsd: number;
daily: SurplusDailyEarnings[];
byModel: SurplusModelEarnings[];
requestCount: number;
tokenCount: number;
topModel: string | null;
payoutHoldReason: string | null;
payoutHoldReleasesAt: string | null;
recentSales: SurplusSale[];
}
interface SurplusClientOptions {
apiBaseUrl: string;
sellerApiKey: string;
timeoutMs: number;
fetchFn?: typeof globalThis.fetch;
}
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
export class SurplusHttpError extends Error {
readonly status: number | null;
readonly retryAfter: string | null;
constructor(message: string, status: number | null, retryAfter: string | null = null) {
super(message);
this.name = "SurplusHttpError";
this.status = status;
this.retryAfter = retryAfter;
}
get ambiguousMutation(): boolean {
return this.status === null || this.status >= 500;
}
}
export class SurplusHistoricalCompletenessError extends Error {
constructor() {
super(
"Surplus does not provide historical sale pagination; only the observed recent-sales window is available",
);
this.name = "SurplusHistoricalCompletenessError";
}
}
export class SurplusClient {
private readonly apiBaseUrl: string;
private readonly sellerApiKey: string;
private readonly timeoutMs: number;
private readonly fetchFn: typeof globalThis.fetch;
constructor(options: SurplusClientOptions) {
this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, "");
this.sellerApiKey = options.sellerApiKey;
this.timeoutMs = options.timeoutMs;
this.fetchFn = options.fetchFn ?? globalThis.fetch;
}
async listAllOffers(): Promise<SurplusOffer[]> {
const items: SurplusOffer[] = [];
const seenTokens = new Set<string>();
let nextToken: string | null = null;
for (;;) {
const params = new URLSearchParams();
if (nextToken !== null) params.set("next_token", nextToken);
const query = params.toString();
const body = await this.requestJson(`/v1/seller/offers${query ? `?${query}` : ""}`);
const root = record(body, "Surplus offers response");
if (!Array.isArray(root.items)) {
throw new Error("Surplus offers response.items must be an array");
}
items.push(...root.items.map((item, index) => parseOffer(item, items.length + index)));
const rawNext = root.next_token;
if (rawNext === undefined || rawNext === null || rawNext === "") break;
if (typeof rawNext !== "string") {
throw new Error("Surplus offers response.next_token must be a string or null");
}
if (seenTokens.has(rawNext)) {
throw new Error("Surplus offers pagination repeated a next_token");
}
seenTokens.add(rawNext);
nextToken = rawNext;
if (seenTokens.size > 10_000) {
throw new Error("Surplus offers pagination exceeded 10000 pages");
}
}
return items;
}
async discoverModels(apiKey: string, baseUrl: string): Promise<SurplusDiscoveredModel[]> {
const response = await this.requestText("/v1/seller/discover", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ api_key: apiKey, base_url: baseUrl }),
}, true, [apiKey]);
if (response.text.trim() === "") {
throw new Error("Surplus discovery returned an empty body");
}
return response.text
.split(/\r?\n/)
.filter((line) => line.trim() !== "")
.map((line, index) => {
let value: unknown;
try {
value = JSON.parse(line);
} catch {
throw new Error(`Surplus discovery line ${index + 1} was not complete JSON`);
}
return parseDiscoveredModel(value, index);
});
}
async getMarkets(): Promise<SurplusMarketSummary[]> {
const body = await this.requestJson("/api/markets", {}, false);
const root = record(body, "Surplus markets response");
if (!Array.isArray(root.markets)) {
throw new Error("Surplus markets response.markets must be an array");
}
return root.markets.map((item, index) => parseMarketSummary(item, index));
}
async getOrderBook(model: string): Promise<SurplusOrderBook> {
const body = await this.requestJson(
`/api/markets/${encodeURIComponent(model)}`,
{},
false,
);
const root = record(body, "Surplus order book response");
const returnedModel = string(root.model, "Surplus order book model");
if (returnedModel !== model) {
throw new Error(`Surplus order book returned model ${returnedModel} for ${model}`);
}
if (!Array.isArray(root.offers)) {
throw new Error("Surplus order book response.offers must be an array");
}
const ids = new Set<string>();
const offers = root.offers.map((item, index) => {
const offer = parseOrderBookOffer(item, index);
if (ids.has(offer.id)) throw new Error(`Surplus order book repeated offer ${offer.id}`);
ids.add(offer.id);
return offer;
});
return { model, offers };
}
async testConnection(apiKey: string, baseUrl: string, model: string): Promise<number> {
const body = await this.requestJson("/v1/seller/test-connection", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ api_key: apiKey, base_url: baseUrl, model }),
}, true, [apiKey]);
const root = record(body, "Surplus connection response");
if (root.ok !== true) throw new Error("Surplus provider connection test failed");
return nonNegativeNumber(root.latency_ms, "Surplus connection latency_ms");
}
async createOffer(input: SurplusOfferWrite): Promise<string> {
const body = await this.requestJson("/v1/seller/offers", {
method: "POST",
headers: mutationHeaders(input.idempotencyKey),
body: JSON.stringify({
model: input.model,
api_key: input.apiKey,
seller_base_url: input.sellerBaseUrl,
pricing_mode: "cost_multiplier",
cost_multiplier: input.costMultiplier,
cap_daily_usd: input.dailyCapUsd,
payout_address: input.payoutAddress,
}),
}, true, [input.apiKey]);
const root = record(body, "Surplus create offer response");
return offerId(root, "Surplus create offer response");
}
async updateOffer(id: string, patch: SurplusOfferPatch): Promise<void> {
await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: mutationHeaders(patch.idempotencyKey),
body: JSON.stringify({
pricing_mode: "cost_multiplier",
cost_multiplier: patch.costMultiplier,
cap_daily_usd: patch.dailyCapUsd,
}),
});
}
async pauseOffer(id: string, idempotencyKey: string): Promise<void> {
await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: mutationHeaders(idempotencyKey),
body: JSON.stringify({ status: "paused" }),
});
}
async resumeOffer(id: string, idempotencyKey: string): Promise<void> {
await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: mutationHeaders(idempotencyKey),
body: JSON.stringify({ status: "active" }),
});
}
async getEarnings(): Promise<SurplusEarnings> {
const body = await this.requestJson("/v1/seller/earnings?range=lifetime");
const root = record(body, "Surplus earnings response");
if (!Array.isArray(root.recent_sales)) {
throw new Error("Surplus earnings response.recent_sales must be an array");
}
if (!Array.isArray(root.daily)) {
throw new Error("Surplus earnings response.daily must be an array");
}
if (!Array.isArray(root.by_model)) {
throw new Error("Surplus earnings response.by_model must be an array");
}
const share = record(root.share, "Surplus earnings response.share");
const payoutHold = optionalRecord(root.payout_hold);
return {
totalEarnedMicroUsd: microUsd(root.total_earned_usdc, "total_earned_usdc"),
pendingMicroUsd: microUsd(root.pending_usdc, "pending_usdc"),
paidMicroUsd: microUsd(root.paid_usdc, "paid_usdc"),
daily: root.daily.map((day, index) => parseDailyEarnings(day, index)),
byModel: root.by_model.map((model, index) =>
parseModelEarnings(model, index)
),
requestCount: nonNegativeSafeInteger(
share.requests,
"Surplus earnings share.requests",
),
tokenCount: nonNegativeSafeInteger(
share.tokens,
"Surplus earnings share.tokens",
),
topModel: optionalString(share.top_model),
payoutHoldReason: payoutHold
? optionalString(payoutHold.reason)
: null,
payoutHoldReleasesAt: payoutHold
? optionalTimestamp(payoutHold.releases_at, "payout_hold.releases_at")
: null,
recentSales: root.recent_sales.map((sale, index) =>
parseSale(sale, index)
),
};
}
async listObservedSales(): Promise<SurplusSale[]> {
const salesById = new Map<string, SurplusSale>();
for (const sale of (await this.getEarnings()).recentSales) {
const existing = salesById.get(sale.id);
if (existing === undefined) {
salesById.set(sale.id, sale);
} else if (!sameImmutableSale(existing, sale)) {
throw new Error(
`Surplus recent-sales window contains conflicting immutable fields for sale ${sale.id}`,
);
}
}
return [...salesById.values()].sort((left, right) =>
left.id.localeCompare(right.id)
);
}
async listAllSales(): Promise<SurplusSale[]> {
throw new SurplusHistoricalCompletenessError();
}
private async requestJson(
path: string,
init: RequestInit = {},
authenticated = true,
additionalSecrets: readonly string[] = [],
): Promise<unknown> {
const response = await this.requestText(
path,
init,
authenticated,
additionalSecrets,
);
if (response.text === "") return {};
try {
return JSON.parse(response.text);
} catch {
throw new Error(`Surplus ${path} response was not JSON`);
}
}
private async requestText(
path: string,
init: RequestInit = {},
authenticated = true,
additionalSecrets: readonly string[] = [],
): Promise<{ status: number; text: string }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
const headers = new Headers(init.headers);
if (authenticated) headers.set("authorization", `Bearer ${this.sellerApiKey}`);
try {
const response = await this.fetchFn(`${this.apiBaseUrl}${path}`, {
...init,
headers,
redirect: "error",
signal: controller.signal,
});
const text = await readBoundedText(response, controller);
if (!response.ok) {
throw new SurplusHttpError(
`Surplus ${path} returned HTTP ${response.status}${
text
? `: ${sanitizedErrorSnippet(
text,
[this.sellerApiKey, ...additionalSecrets],
)}`
: ""
}`,
response.status,
response.headers.get("retry-after"),
);
}
return { status: response.status, text };
} catch (error) {
if (error instanceof SurplusHttpError) throw error;
const detail = error instanceof Error ? error.message : String(error);
throw new SurplusHttpError(`Surplus ${path} request failed: ${detail}`, null);
} finally {
clearTimeout(timer);
}
}
}
async function readBoundedText(
response: Response,
controller: AbortController,
): Promise<string> {
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let text = "";
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) {
controller.abort();
throw new Error(`response exceeded ${MAX_RESPONSE_BYTES} bytes`);
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
} finally {
reader.releaseLock();
}
}
function mutationHeaders(idempotencyKey: string): Headers {
const headers = new Headers({ "content-type": "application/json" });
headers.set("idempotency-key", idempotencyKey);
return headers;
}
function parseOffer(value: unknown, index: number): SurplusOffer {
const item = record(value, `Surplus offer ${index}`);
const statusRaw = item.status;
const status = statusRaw === "active" || item.active === true
? "active"
: statusRaw === "inactive" || statusRaw === "paused" || item.active === false
? "inactive"
: (() => { throw new Error(`Surplus offer ${index}.status was invalid`); })();
return {
id: offerId(item, `Surplus offer ${index}`),
model: string(item.model, `Surplus offer ${index}.model`),
sellerBaseUrl: string(
item.seller_base_url ?? item.base_url,
`Surplus offer ${index}.base_url`,
),
status,
capDailyUsd: optionalNonNegativeNumber(item.cap_daily_usd, `Surplus offer ${index}.cap_daily_usd`),
costMultiplierPpm: optionalMultiplierPpm(
item.cost_multiplier,
`Surplus offer ${index}.cost_multiplier`,
),
inputMicroUsdPer1m: optionalMicroUsd(item.price_input_per_1m, `Surplus offer ${index}.price_input_per_1m`),
outputMicroUsdPer1m: optionalMicroUsd(item.price_output_per_1m, `Surplus offer ${index}.price_output_per_1m`),
};
}
function parseDiscoveredModel(value: unknown, index: number): SurplusDiscoveredModel {
const item = record(value, `Surplus discovery item ${index}`);
const model = string(item.model, `Surplus discovery item ${index}.model`);
const supported = boolean(
item.supported,
`Surplus discovery item ${index}.supported`,
);
const pricing = optionalRecord(item.pricing);
const metadata = optionalRecord(item.metadata) ?? {};
return {
model,
supported,
inputUsdPer1m: pricing
? optionalNonNegativeNumber(
pricing.input_per_1m,
`Surplus discovery item ${index}.pricing.input_per_1m`,
) ?? 0
: 0,
outputUsdPer1m: pricing
? optionalNonNegativeNumber(
pricing.output_per_1m,
`Surplus discovery item ${index}.pricing.output_per_1m`,
) ?? 0
: 0,
priceUnit: pricing
? optionalString(pricing.price_unit) ?? "M"
: "unknown",
priceVariable: pricing?.price_variable === undefined
? false
: boolean(
pricing.price_variable,
`Surplus discovery item ${index}.pricing.price_variable`,
),
providerModelId: optionalString(metadata.provider_model_id) ?? "",
modelType: optionalString(metadata.model_type) ?? "unknown",
availabilityStatus:
optionalString(metadata.availability_status) ?? "unknown",
};
}
function parseMarketSummary(value: unknown, index: number): SurplusMarketSummary {
const item = record(value, `Surplus market ${index}`);
return {
model: string(item.model, `Surplus market ${index}.model`),
requests24h: nonNegativeSafeInteger(item.requests_24h, `Surplus market ${index}.requests_24h`),
volume24h: nonNegativeSafeInteger(item.volume_24h, `Surplus market ${index}.volume_24h`),
bestInputMicroUsdPer1m: nonNegativeSafeInteger(item.best_input_per_1m, `Surplus market ${index}.best_input_per_1m`),
bestOutputMicroUsdPer1m: nonNegativeSafeInteger(item.best_output_per_1m, `Surplus market ${index}.best_output_per_1m`),
healthySellerCount: nonNegativeSafeInteger(item.healthy_seller_count, `Surplus market ${index}.healthy_seller_count`),
};
}
function parseOrderBookOffer(value: unknown, index: number): SurplusOrderBookOffer {
const item = record(value, `Surplus order book offer ${index}`);
return {
id: offerId(item, `Surplus order book offer ${index}`),
seller: string(item.seller, `Surplus order book offer ${index}.seller`).toLowerCase(),
sellerBaseUrl: string(item.seller_base_url, `Surplus order book offer ${index}.seller_base_url`),
inputMicroUsdPer1m: positiveSafeInteger(item.effective_input_per_1m, `Surplus order book offer ${index}.effective_input_per_1m`),
outputMicroUsdPer1m: positiveSafeInteger(item.effective_output_per_1m, `Surplus order book offer ${index}.effective_output_per_1m`),
available: boolean(item.available, `Surplus order book offer ${index}.available`),
healthy: boolean(item.healthy, `Surplus order book offer ${index}.healthy`),
trusted: boolean(item.trusted, `Surplus order book offer ${index}.trusted`),
trades24h: nonNegativeSafeInteger(item.trades_24h, `Surplus order book offer ${index}.trades_24h`),
rank: positiveSafeInteger(item.rank, `Surplus order book offer ${index}.rank`),
capRemainingMicroUsd: optionalMicroUsd(
item.cap_remaining,
`Surplus order book offer ${index}.cap_remaining`,
),
volume24hMicroUsd: microUsd(
item.volume_24h,
`Surplus order book offer ${index}.volume_24h`,
),
};
}
function parseSale(value: unknown, index: number): SurplusSale {
const item = record(value, `Surplus sale ${index}`);
const createdAtMs = timestampMs(
item.created_at,
`Surplus sale ${index}.created_at`,
);
return {
id: string(item.id, `Surplus sale ${index}.id`),
model: string(item.model, `Surplus sale ${index}.model`),
offerId: string(item.offer_id, `Surplus sale ${index}.offer_id`),
settlementStatus: string(item.settlement_status, `Surplus sale ${index}.settlement_status`),
createdAt: new Date(createdAtMs).toISOString(),
createdAtMs,
sellerCostMicroUsd: microUsd(item.seller_cost_usdc, `Surplus sale ${index}.seller_cost_usdc`),
inputTokens: nonNegativeSafeInteger(item.input_tokens, `Surplus sale ${index}.input_tokens`),
outputTokens: nonNegativeSafeInteger(item.output_tokens, `Surplus sale ${index}.output_tokens`),
cacheReadTokens: nonNegativeSafeInteger(
item.cache_read_tokens ?? 0,
`Surplus sale ${index}.cache_read_tokens`,
),
effectiveInputUsdPer1m: nonNegativeNumber(
item.effective_input_per_1m,
`Surplus sale ${index}.effective_input_per_1m`,
),
effectiveOutputUsdPer1m: nonNegativeNumber(
item.effective_output_per_1m,
`Surplus sale ${index}.effective_output_per_1m`,
),
transactionHash: optionalString(item.tx_hash),
};
}
function sameImmutableSale(left: SurplusSale, right: SurplusSale): boolean {
return left.id === right.id &&
left.offerId === right.offerId &&
left.model === right.model &&
left.createdAtMs === right.createdAtMs &&
left.inputTokens === right.inputTokens &&
left.outputTokens === right.outputTokens &&
left.cacheReadTokens === right.cacheReadTokens &&
left.sellerCostMicroUsd === right.sellerCostMicroUsd &&
left.effectiveInputUsdPer1m === right.effectiveInputUsdPer1m &&
left.effectiveOutputUsdPer1m === right.effectiveOutputUsdPer1m;
}
function parseDailyEarnings(
value: unknown,
index: number,
): SurplusDailyEarnings {
const item = record(value, `Surplus daily earnings ${index}`);
return {
day: string(item.day, `Surplus daily earnings ${index}.day`),
earnedUsd: nonNegativeNumber(
item.earned_usd,
`Surplus daily earnings ${index}.earned_usd`,
),
inputTokens: nonNegativeSafeInteger(
item.input_tokens,
`Surplus daily earnings ${index}.input_tokens`,
),
outputTokens: nonNegativeSafeInteger(
item.output_tokens,
`Surplus daily earnings ${index}.output_tokens`,
),
requests: nonNegativeSafeInteger(
item.requests,
`Surplus daily earnings ${index}.requests`,
),
totalTokens: nonNegativeSafeInteger(
item.total_tokens,
`Surplus daily earnings ${index}.total_tokens`,
),
};
}
function parseModelEarnings(
value: unknown,
index: number,
): SurplusModelEarnings {
const item = record(value, `Surplus model earnings ${index}`);
return {
model: string(item.model, `Surplus model earnings ${index}.model`),
earnedUsd: nonNegativeNumber(
item.earned_usd,
`Surplus model earnings ${index}.earned_usd`,
),
requests: nonNegativeSafeInteger(
item.requests,
`Surplus model earnings ${index}.requests`,
),
totalTokens: nonNegativeSafeInteger(
item.total_tokens,
`Surplus model earnings ${index}.total_tokens`,
),
lastSaleAt: optionalTimestamp(
item.last_sale_at,
`Surplus model earnings ${index}.last_sale_at`,
),
};
}
function offerId(item: Record<string, unknown>, field: string): string {
const value = item.offer_id ?? item.id;
return string(value, `${field}.id`);
}
function record(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object`);
}
return value as Record<string, unknown>;
}
function optionalRecord(value: unknown): Record<string, unknown> | null {
if (value === null || value === undefined) return null;
if (typeof value !== "object" || Array.isArray(value)) {
throw new Error("Optional Surplus object has the wrong type");
}
return value as Record<string, unknown>;
}
function string(value: unknown, field: string): string {
if (typeof value !== "string" || value === "") throw new Error(`${field} must be a non-empty string`);
return value;
}
function optionalString(value: unknown): string | null {
return typeof value === "string" && value !== "" ? value : null;
}
function optionalTimestamp(value: unknown, field: string): string | null {
if (value === null || value === undefined) return null;
return new Date(timestampMs(value, field)).toISOString();
}
function timestampMs(value: unknown, field: string): number {
const timestamp = typeof value === "number"
? value
: typeof value === "string"
? Date.parse(value)
: Number.NaN;
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
throw new Error(`${field} must be a timestamp with integer milliseconds`);
}
return timestamp;
}
function boolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`);
return value;
}
function nonNegativeNumber(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number`);
}
return value;
}
function optionalNonNegativeNumber(value: unknown, field: string): number | null {
if (value === null || value === undefined) return null;
return nonNegativeNumber(value, field);
}
function optionalMultiplierPpm(value: unknown, field: string): number | null {
if (value === null || value === undefined) return null;
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
const ppm = Math.round(value * 1_000_000);
if (!Number.isSafeInteger(ppm) || ppm <= 0) {
throw new Error(`${field} cannot be represented at six decimal places`);
}
return ppm;
}
function sanitizedErrorSnippet(
value: string,
secrets: readonly string[],
): string {
let sanitized = value;
for (const secret of secrets) {
if (secret !== "") sanitized = sanitized.replaceAll(secret, "[redacted]");
}
return sanitized
.replace(/(?:si_seller_|sk-or-v1-)[A-Za-z0-9_-]+/g, "[redacted]")
.replace(/[\r\n]+/g, " ")
.slice(0, 300);
}
function positiveSafeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${field} must be a positive safe integer`);
}
return value;
}
function nonNegativeSafeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`${field} must be a non-negative safe integer`);
}
return value;
}
function optionalSafeInteger(value: unknown, field: string): number {
if (value === null || value === undefined) return 0;
return nonNegativeSafeInteger(value, field);
}
function microUsd(value: unknown, field: string): number {
if (typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value)) {
const parsed = Number(value);
if (Number.isSafeInteger(parsed)) return parsed;
}
return nonNegativeSafeInteger(value, field);
}
function optionalMicroUsd(value: unknown, field: string): number | null {
if (value === null || value === undefined) return null;
return microUsd(value, field);
}

View File

@@ -0,0 +1,297 @@
const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,6})?$/;
const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
const POSITIVE_INT_RE = /^[1-9]\d*$/;
const NON_NEGATIVE_INT_RE = /^(?:0|[1-9]\d*)$/;
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
export interface SurplusManagerConfig {
live: boolean;
apiBaseUrl: string;
sellerApiKey: string;
providerApiKey: string;
providerBaseUrl: string;
capacityBaseUrl: string;
perOfferCapUsd: string;
aggregateCapUsd: string;
sellerWallet: string;
payoutAddress: string;
maxCandidateOrderBooks: number;
reserveUsd: string;
maxProviderLimitUsd: string;
recoveryBps: number;
undercutBps: number;
pollIntervalMs: number;
requestTimeoutMs: number;
stopAfterTrades: number;
settledStatuses: string[];
statePath: string;
port: number;
proof: SurplusProofConfig | null;
}
export interface SurplusProofConfig {
ogmiosUrl: string;
walletPrivateKeyHex: string;
ledgerPath: string;
reserveLovelace: bigint;
feeBudgetLovelace: bigint;
intervalMs: number;
confirmTimeoutMs: number;
historyCsvPath: string | null;
historyOfferId: string | null;
}
export function loadSurplusManagerConfig(
env: Record<string, string | undefined>,
): SurplusManagerConfig {
const liveRaw = env.SURPLUS_LIVE;
if (liveRaw !== undefined && liveRaw !== "" && liveRaw !== "1") {
throw new Error("SURPLUS_LIVE must be literal 1 or unset");
}
const apiBaseUrl = httpsUrl(
env.SURPLUS_API_URL ?? "https://api.surplusintelligence.ai",
"SURPLUS_API_URL",
"api.surplusintelligence.ai",
);
const providerBaseUrl = httpsUrl(
env.SURPLUS_PROVIDER_BASE_URL ?? "https://openrouter.ai/api/v1",
"SURPLUS_PROVIDER_BASE_URL",
"openrouter.ai",
);
const capacityBaseUrl = httpsUrl(
env.OPENROUTER_CAPACITY_BASE_URL ?? "https://openrouter.ai/api",
"OPENROUTER_CAPACITY_BASE_URL",
"openrouter.ai",
);
const sellerWallet = evmAddress(
required(env, "SURPLUS_SELLER_WALLET"),
"SURPLUS_SELLER_WALLET",
);
const payoutAddress = evmAddress(
env.SURPLUS_PAYOUT_ADDRESS ?? sellerWallet,
"SURPLUS_PAYOUT_ADDRESS",
);
return {
live: liveRaw === "1",
apiBaseUrl,
sellerApiKey: prefixedSecret(
required(env, "SURPLUS_SELLER_API_KEY"),
"SURPLUS_SELLER_API_KEY",
"si_seller_",
),
providerApiKey: required(env, "SURPLUS_OPENROUTER_API_KEY"),
providerBaseUrl,
capacityBaseUrl,
perOfferCapUsd: usd(
env.SURPLUS_PER_OFFER_CAP_USD ?? "0.05",
"SURPLUS_PER_OFFER_CAP_USD",
),
aggregateCapUsd: usd(
env.SURPLUS_AGGREGATE_CAP_USD ?? "0.05",
"SURPLUS_AGGREGATE_CAP_USD",
),
sellerWallet,
payoutAddress,
maxCandidateOrderBooks: boundedInteger(
env.SURPLUS_MAX_CANDIDATE_ORDER_BOOKS ?? "10",
"SURPLUS_MAX_CANDIDATE_ORDER_BOOKS",
1,
100,
),
reserveUsd: usd(
env.SURPLUS_OPENROUTER_RESERVE_USD ?? "1.00",
"SURPLUS_OPENROUTER_RESERVE_USD",
),
maxProviderLimitUsd: usd(
env.SURPLUS_OPENROUTER_MAX_LIMIT_USD ?? "20.00",
"SURPLUS_OPENROUTER_MAX_LIMIT_USD",
),
recoveryBps: boundedInteger(
env.SURPLUS_MIN_RECOVERY_BPS ?? "490",
"SURPLUS_MIN_RECOVERY_BPS",
1,
10_000,
),
undercutBps: boundedInteger(
env.SURPLUS_UNDERCUT_BPS ?? "10",
"SURPLUS_UNDERCUT_BPS",
0,
9_999,
),
pollIntervalMs: boundedInteger(
env.SURPLUS_REPRICE_INTERVAL_MS ?? "300000",
"SURPLUS_REPRICE_INTERVAL_MS",
60_000,
86_400_000,
),
requestTimeoutMs: boundedInteger(
env.SURPLUS_REQUEST_TIMEOUT_MS ?? "120000",
"SURPLUS_REQUEST_TIMEOUT_MS",
1_000,
300_000,
),
stopAfterTrades: boundedInteger(
env.SURPLUS_STOP_AFTER_SETTLED_REQUESTS ?? "0",
"SURPLUS_STOP_AFTER_SETTLED_REQUESTS",
0,
1,
),
settledStatuses: statusList(
env.SURPLUS_SETTLED_STATUSES ?? "",
"SURPLUS_SETTLED_STATUSES",
),
statePath: requiredWithDefault(
env.SURPLUS_STATE_PATH,
"/var/lib/surplus-manager/state.json",
"SURPLUS_STATE_PATH",
),
port: boundedInteger(env.PORT ?? "8080", "PORT", 1, 65_535),
proof: loadProofConfig(env),
};
}
function loadProofConfig(
env: Record<string, string | undefined>,
): SurplusProofConfig | null {
const enabledRaw = env.SURPLUS_PROOF_ENABLED;
if (enabledRaw === undefined || enabledRaw === "") return null;
if (enabledRaw !== "1") {
throw new Error("SURPLUS_PROOF_ENABLED must be literal 1 or unset");
}
const ogmiosUrl = required(env, "OGMIOS_URL");
const walletPrivateKeyHex = required(
env,
"SURPLUS_PROOF_WALLET_PRIV_KEY_HEX",
);
if (!HEX64_RE.test(walletPrivateKeyHex)) {
throw new Error(
"SURPLUS_PROOF_WALLET_PRIV_KEY_HEX must be 64 hexadecimal characters",
);
}
const historyCsvPath = env.SURPLUS_PROOF_HISTORY_CSV_PATH?.trim() || null;
const historyOfferId = historyCsvPath === null
? null
: required(env, "SURPLUS_PROOF_HISTORY_OFFER_ID");
return {
ogmiosUrl,
walletPrivateKeyHex: walletPrivateKeyHex.toLowerCase(),
ledgerPath: requiredWithDefault(
env.SURPLUS_PROOF_LEDGER_PATH,
"/var/lib/surplus-manager/vector-proofs.json",
"SURPLUS_PROOF_LEDGER_PATH",
),
reserveLovelace: BigInt(boundedInteger(
env.SURPLUS_PROOF_RESERVE_LOVELACE ?? "5000000",
"SURPLUS_PROOF_RESERVE_LOVELACE",
0,
Number.MAX_SAFE_INTEGER,
)),
feeBudgetLovelace: BigInt(boundedInteger(
env.SURPLUS_PROOF_FEE_BUDGET_LOVELACE ?? "1000000",
"SURPLUS_PROOF_FEE_BUDGET_LOVELACE",
1,
Number.MAX_SAFE_INTEGER,
)),
intervalMs: boundedInteger(
env.SURPLUS_PROOF_INTERVAL_MS ?? "300000",
"SURPLUS_PROOF_INTERVAL_MS",
60_000,
86_400_000,
),
confirmTimeoutMs: boundedInteger(
env.SURPLUS_PROOF_CONFIRM_TIMEOUT_MS ?? "300000",
"SURPLUS_PROOF_CONFIRM_TIMEOUT_MS",
10_000,
3_600_000,
),
historyCsvPath,
historyOfferId,
};
}
function required(
env: Record<string, string | undefined>,
name: string,
): string {
const value = env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
}
function requiredWithDefault(
value: string | undefined,
fallback: string,
name: string,
): string {
const result = (value ?? fallback).trim();
if (!result) throw new Error(`${name} must not be empty`);
return result;
}
function prefixedSecret(value: string, name: string, prefix: string): string {
if (!value.startsWith(prefix) || value.length <= prefix.length) {
throw new Error(`${name} must start with ${prefix}`);
}
return value;
}
function usd(value: string, name: string): string {
if (!USD_RE.test(value)) {
throw new Error(`${name} must be a non-negative USD decimal with at most 6 places`);
}
return value;
}
function evmAddress(value: string, name: string): string {
if (!EVM_ADDRESS_RE.test(value)) {
throw new Error(`${name} must be a 20-byte 0x address`);
}
return value.toLowerCase();
}
function boundedInteger(
value: string,
name: string,
minimum: number,
maximum: number,
): number {
const pattern = minimum === 0 ? NON_NEGATIVE_INT_RE : POSITIVE_INT_RE;
if (!pattern.test(value)) throw new Error(`${name} must be an integer`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new Error(`${name} must be from ${minimum} to ${maximum}`);
}
return parsed;
}
function statusList(value: string, name: string): string[] {
if (value.trim() === "") return [];
const statuses = value.split(",").map((status) => status.trim().toLowerCase());
const seen = new Set<string>();
for (const status of statuses) {
if (!/^[a-z][a-z0-9_-]*$/.test(status)) {
throw new Error(`${name} contains an invalid status`);
}
if (seen.has(status)) throw new Error(`${name} contains a duplicate status`);
seen.add(status);
}
return statuses;
}
function httpsUrl(value: string, name: string, hostname: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`${name} must be a valid URL`);
}
if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== hostname) {
throw new Error(`${name} must use https://${hostname}`);
}
return value.replace(/\/+$/, "");
}

View File

@@ -0,0 +1,788 @@
import { randomUUID } from "node:crypto";
import { parseUsdDecimal, formatUsdNanos } from "../reseller/money.js";
import type { SurplusManagerConfig } from "./config.js";
import {
SurplusClient,
SurplusHttpError,
type SurplusDiscoveredModel,
type SurplusEarnings,
type SurplusOffer,
} from "./client.js";
import type {
OpenRouterKeyAllowance,
OpenRouterKeyClient,
} from "./openRouterKey.js";
import {
formatMicroUsd,
quoteSurplusMultiplier,
} from "./policy.js";
import {
eligibleCompetitors,
selectSurplusModelCandidates,
} from "./selection.js";
import type {
SurplusControllerState,
SurplusOfferIntent,
SurplusStateStore,
} from "./state.js";
export type SurplusControllerClient = Pick<
SurplusClient,
| "listAllOffers"
| "discoverModels"
| "getMarkets"
| "getOrderBook"
| "testConnection"
| "createOffer"
| "updateOffer"
| "pauseOffer"
| "resumeOffer"
| "getEarnings"
>;
export type OpenRouterAllowanceReader = Pick<OpenRouterKeyClient, "readAllowance">;
export interface SurplusControllerStatus {
ok: boolean;
phase: SurplusControllerState["phase"] | "error" | "planned" | "suspended";
live: boolean;
model: string | null;
providerModelId: string | null;
offerId: string | null;
inputUsdPer1m: string | null;
outputUsdPer1m: string | null;
competitorId: string | null;
remainingAllowanceUsd: string | null;
reserveThresholdUsd: string;
totalEarnedUsd: string | null;
lastCycleAt: string | null;
lastSuccessAt: string | null;
lastError: string | null;
}
interface SurplusControllerOptions {
config: SurplusManagerConfig;
client: SurplusControllerClient;
allowance: OpenRouterAllowanceReader;
stateStore: SurplusStateStore;
now?: () => number;
mutationId?: () => string;
log?: (message: string) => void;
}
export class SurplusSellerController {
private readonly config: SurplusManagerConfig;
private readonly client: SurplusControllerClient;
private readonly allowance: OpenRouterAllowanceReader;
private readonly stateStore: SurplusStateStore;
private readonly now: () => number;
private readonly mutationId: () => string;
private readonly log: (message: string) => void;
private state: SurplusControllerState | null = null;
private inFlight: Promise<void> | null = null;
private timer: NodeJS.Timeout | null = null;
private stopped = false;
private status: SurplusControllerStatus;
constructor(options: SurplusControllerOptions) {
this.config = options.config;
this.client = options.client;
this.allowance = options.allowance;
this.stateStore = options.stateStore;
this.now = options.now ?? Date.now;
this.mutationId = options.mutationId ?? randomUUID;
this.log = options.log ?? (() => undefined);
const perOfferCap = parseUsdDecimal(this.config.perOfferCapUsd, "ceil");
const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor");
if (perOfferCap > aggregateCap) {
throw new Error("Surplus per-offer cap must not exceed the aggregate cap");
}
this.status = {
ok: false,
phase: "selecting",
live: this.config.live,
model: null,
providerModelId: null,
offerId: null,
inputUsdPer1m: null,
outputUsdPer1m: null,
competitorId: null,
remainingAllowanceUsd: null,
reserveThresholdUsd: this.config.reserveUsd,
totalEarnedUsd: null,
lastCycleAt: null,
lastSuccessAt: null,
lastError: null,
};
}
snapshot(): SurplusControllerStatus {
return { ...this.status };
}
healthy(): boolean {
if (this.state?.phase === "completed") return true;
if (!this.status.ok || this.status.lastSuccessAt === null) return false;
const freshnessLimit =
this.config.pollIntervalMs + this.config.requestTimeoutMs + 60_000;
return this.now() - Date.parse(this.status.lastSuccessAt) <= freshnessLimit;
}
async start(): Promise<void> {
if (this.state !== null) return;
this.state = await this.stateStore.load();
await this.runOnce();
this.schedule();
}
async stop(): Promise<void> {
this.stopped = true;
clearTimeout(this.timer ?? undefined);
this.timer = null;
if (this.inFlight) await this.inFlight;
}
async runOnce(): Promise<void> {
if (this.inFlight) return this.inFlight;
const cycle = this.performCycle();
this.inFlight = cycle;
try {
await cycle;
} finally {
this.inFlight = null;
}
}
private schedule(): void {
if (this.stopped || this.state?.phase === "completed") return;
this.timer = setTimeout(() => {
void this.runOnce().finally(() => this.schedule());
}, this.config.pollIntervalMs);
this.timer.unref();
}
private async performCycle(): Promise<void> {
if (this.state === null) this.state = await this.stateStore.load();
const cycleAt = new Date(this.now()).toISOString();
this.status = { ...this.status, lastCycleAt: cycleAt };
if (this.state.phase === "completed") {
this.markSuccess("completed", null, null);
return;
}
try {
if (this.state.phase === "selecting") await this.selectAndCreate();
else if (this.state.phase === "create_pending") await this.adoptPendingCreate();
else if (this.state.phase === "active") await this.manageActiveOffer();
else if (this.state.phase === "stopping") await this.finishStopping();
else await this.awaitSettlement();
} catch (error) {
const stateAtFailure = this.state;
let paused = false;
if (
this.config.live &&
stateAtFailure !== null &&
"offerId" in stateAtFailure
) {
try {
await this.client.pauseOffer(stateAtFailure.offerId, this.mutationId());
paused = true;
} catch {
paused = false;
}
}
const detail = this.safeError(error);
this.status = {
...this.status,
ok: false,
phase: "error",
lastError: paused ? `${detail}; managed offer paused` : detail,
};
this.log(`Surplus cycle failed: ${this.status.lastError}`);
}
}
private async selectAndCreate(): Promise<void> {
const offers = await this.client.listAllOffers();
const active = offers.filter((offer) => offer.status === "active");
if (active.length > 0) {
await this.pauseOffers(active);
this.markSuccess("suspended", null, null);
return;
}
const allowance = await this.allowance.readAllowance();
this.assertAllowance(
allowance,
this.providerExposureUsdNanos(
parseUsdDecimal(this.config.aggregateCapUsd, "ceil"),
),
);
const [discovered, markets] = await Promise.all([
this.client.discoverModels(
this.config.providerApiKey,
this.config.providerBaseUrl,
),
this.client.getMarkets(),
]);
const candidates = selectSurplusModelCandidates(
discovered,
markets,
this.config.recoveryBps,
this.config.undercutBps,
);
const ownOfferIds = new Set(offers.map((offer) => offer.id));
let intent: SurplusOfferIntent | null = null;
let competitorId: string | null = null;
for (const candidate of candidates.slice(0, this.config.maxCandidateOrderBooks)) {
const book = await this.client.getOrderBook(candidate.model);
const competitors = eligibleCompetitors(
book.offers,
this.config.sellerWallet,
ownOfferIds,
);
const quote = quoteSurplusMultiplier({
upstreamInputMicroUsdPer1m: candidate.upstreamInputMicroUsdPer1m,
upstreamOutputMicroUsdPer1m: candidate.upstreamOutputMicroUsdPer1m,
recoveryBps: this.config.recoveryBps,
undercutBps: this.config.undercutBps,
competitors,
});
if (!quote.competitive) continue;
try {
await this.client.testConnection(
this.config.providerApiKey,
this.config.providerBaseUrl,
candidate.model,
);
} catch (error) {
this.log(
`Surplus candidate probe failed model=${candidate.model}: ${this.safeError(error)}`,
);
continue;
}
intent = {
model: candidate.model,
providerModelId: candidate.providerModelId,
costMultiplierPpm: quote.costMultiplierPpm,
inputMicroUsdPer1m: quote.inputMicroUsdPer1m,
outputMicroUsdPer1m: quote.outputMicroUsdPer1m,
baselineRemainingUsdNanos: allowance.remainingUsdNanos.toString(),
dailyCapUsd: Number(this.config.perOfferCapUsd),
idempotencyKey: this.mutationId(),
createdAt: new Date(this.now()).toISOString(),
baselineTrades24h: 0,
};
competitorId = quote.competitorId;
break;
}
if (intent === null) {
throw new Error("No supported OpenRouter model has a safe competitive quote");
}
if (!this.config.live) {
this.markSuccess("planned", allowance, {
intent,
offerId: null,
competitorId,
totalEarnedMicroUsd: null,
});
return;
}
const pending: SurplusControllerState = {
version: 1,
phase: "create_pending",
intent,
};
await this.stateStore.save(pending);
this.state = pending;
let createdOfferId: string | null = null;
let postCreateActive: SurplusOffer[] = [];
try {
createdOfferId = await this.client.createOffer({
model: intent.model,
apiKey: this.config.providerApiKey,
sellerBaseUrl: this.config.providerBaseUrl,
costMultiplier: intent.costMultiplierPpm / 1_000_000,
dailyCapUsd: intent.dailyCapUsd,
payoutAddress: this.config.payoutAddress,
idempotencyKey: intent.idempotencyKey,
});
const postCreateOffers = await this.client.listAllOffers();
postCreateActive = postCreateOffers.filter(
(offer) => offer.status === "active",
);
const created = postCreateActive.find(
(offer) => offer.id === createdOfferId,
);
if (
!created ||
created.model !== intent.model ||
postCreateActive.length !== 1
) {
throw new Error(
"Surplus create verification did not find exactly one expected active offer",
);
}
this.assertAggregateCap(postCreateOffers);
const activeState: SurplusControllerState = {
version: 1,
phase: "active",
offerId: createdOfferId,
intent,
highestTrades24h: 0,
};
await this.stateStore.save(activeState);
this.state = activeState;
this.markSuccess("active", allowance, {
intent,
offerId: createdOfferId,
competitorId,
totalEarnedMicroUsd: null,
});
} catch (error) {
if (createdOfferId !== null) {
const ids = new Set(postCreateActive.map((offer) => offer.id));
ids.add(createdOfferId);
for (const id of ids) {
await this.client.pauseOffer(id, this.mutationId()).catch(() => undefined);
}
}
if (error instanceof SurplusHttpError && !error.ambiguousMutation) {
const selecting: SurplusControllerState = {
version: 1,
phase: "selecting",
};
await this.stateStore.save(selecting);
this.state = selecting;
}
throw error;
}
}
private async adoptPendingCreate(): Promise<void> {
if (this.state?.phase !== "create_pending") return;
const pending = this.state;
const offers = await this.client.listAllOffers();
const active = offers.filter((offer) => offer.status === "active");
const activeMatches = active
.filter((offer) =>
offer.model === pending.intent.model &&
normalizeUrl(offer.sellerBaseUrl) ===
normalizeUrl(this.config.providerBaseUrl)
)
.sort((left, right) => left.id.localeCompare(right.id));
const inactiveMatches = offers
.filter((offer) =>
offer.status === "inactive" &&
offer.model === pending.intent.model &&
normalizeUrl(offer.sellerBaseUrl) ===
normalizeUrl(this.config.providerBaseUrl)
)
.sort((left, right) => left.id.localeCompare(right.id));
const primary = activeMatches[0] ?? inactiveMatches[0];
if (!primary) {
if (active.length > 0) await this.pauseOffers(active);
throw new Error(
"Create result is ambiguous and no matching offer is visible; refusing another POST",
);
}
await this.pauseOffers(active.filter((offer) => offer.id !== primary.id));
const confirmedOffers = await this.client.listAllOffers();
const confirmedActive = confirmedOffers.filter(
(offer) => offer.status === "active",
);
const expectedActive = primary.status === "active" ? 1 : 0;
if (
confirmedActive.length !== expectedActive ||
(expectedActive === 1 && confirmedActive[0].id !== primary.id)
) {
await this.pauseOffers(confirmedActive);
throw new Error("Adopted Surplus create did not reconcile safely");
}
const adopted: SurplusControllerState = {
version: 1,
phase: "active",
offerId: primary.id,
intent: pending.intent,
highestTrades24h: pending.intent.baselineTrades24h,
};
try {
this.assertAggregateCap(confirmedOffers);
await this.stateStore.save(adopted);
} catch (error) {
await this.pauseOffers(confirmedActive).catch(() => undefined);
throw error;
}
this.state = adopted;
if (primary.status === "inactive") {
await this.manageActiveOffer();
return;
}
this.markSuccess("active", null, {
intent: adopted.intent,
offerId: adopted.offerId,
competitorId: null,
totalEarnedMicroUsd: null,
});
}
private async manageActiveOffer(): Promise<void> {
if (this.state?.phase !== "active") return;
const currentState = this.state;
const offers = await this.client.listAllOffers();
const activeOffers = offers.filter((offer) => offer.status === "active");
const managed = offers.find((offer) => offer.id === currentState.offerId);
if (!managed) {
await this.pauseOffers(activeOffers);
throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`);
}
const otherActive = activeOffers.filter(
(offer) => offer.id !== currentState.offerId,
);
if (otherActive.length > 0) {
await this.pauseOffers(otherActive);
this.markSuccess("suspended", null, null);
return;
}
this.assertAggregateCap(offers);
const allowance = await this.allowance.readAllowance();
this.assertAllowance(
allowance,
this.providerExposureUsdNanos(
parseUsdDecimal(this.config.perOfferCapUsd, "ceil"),
),
);
const baselineAllowance = BigInt(
currentState.intent.baselineRemainingUsdNanos,
);
const providerSpend = baselineAllowance > allowance.remainingUsdNanos
? baselineAllowance - allowance.remainingUsdNanos
: 0n;
const spendCap = this.providerExposureUsdNanos(
parseUsdDecimal(this.config.perOfferCapUsd, "ceil"),
);
if (this.config.stopAfterTrades > 0 && providerSpend >= spendCap) {
const stopping: SurplusControllerState = {
version: 1,
phase: "stopping",
offerId: currentState.offerId,
intent: currentState.intent,
highestTrades24h: currentState.highestTrades24h,
tradeObservedAt: currentState.intent.createdAt,
pauseIdempotencyKey: this.mutationId(),
};
await this.stateStore.save(stopping);
this.state = stopping;
await this.finishStopping();
return;
}
const discovered = await this.client.discoverModels(
this.config.providerApiKey,
this.config.providerBaseUrl,
);
const selected = this.requireSelectedDiscovery(discovered, currentState.intent);
const book = await this.client.getOrderBook(currentState.intent.model);
const ownBookOffer = book.offers.find((offer) => offer.id === currentState.offerId);
if (
this.config.stopAfterTrades > 0 &&
ownBookOffer &&
ownBookOffer.trades24h >=
currentState.highestTrades24h + this.config.stopAfterTrades
) {
const stopping: SurplusControllerState = {
version: 1,
phase: "stopping",
offerId: currentState.offerId,
intent: currentState.intent,
highestTrades24h: ownBookOffer.trades24h,
tradeObservedAt: new Date(this.now()).toISOString(),
pauseIdempotencyKey: this.mutationId(),
};
await this.stateStore.save(stopping);
this.state = stopping;
await this.finishStopping();
return;
}
const ownIds = new Set(offers.map((offer) => offer.id));
const competitors = eligibleCompetitors(
book.offers,
this.config.sellerWallet,
ownIds,
);
const quote = quoteSurplusMultiplier({
upstreamInputMicroUsdPer1m: Math.ceil(selected.inputUsdPer1m * 1_000_000),
upstreamOutputMicroUsdPer1m: Math.ceil(selected.outputUsdPer1m * 1_000_000),
recoveryBps: this.config.recoveryBps,
undercutBps: this.config.undercutBps,
competitors,
});
const nextIntent: SurplusOfferIntent = {
...currentState.intent,
costMultiplierPpm: quote.costMultiplierPpm,
inputMicroUsdPer1m: quote.inputMicroUsdPer1m,
outputMicroUsdPer1m: quote.outputMicroUsdPer1m,
};
const priceChanged =
managed.costMultiplierPpm === null ||
Math.abs(managed.costMultiplierPpm - quote.costMultiplierPpm) > 1 ||
managed.capDailyUsd !== Number(this.config.perOfferCapUsd);
if (this.config.live && priceChanged) {
await this.client.updateOffer(currentState.offerId, {
costMultiplier: quote.costMultiplier,
dailyCapUsd: Number(this.config.perOfferCapUsd),
idempotencyKey: this.mutationId(),
});
}
if (this.config.live && managed.status === "inactive") {
await this.client.resumeOffer(currentState.offerId, this.mutationId());
}
const activeState: SurplusControllerState = {
...currentState,
intent: nextIntent,
highestTrades24h: ownBookOffer?.trades24h ?? currentState.highestTrades24h,
};
await this.stateStore.save(activeState);
this.state = activeState;
const earnings = await this.client.getEarnings();
this.markSuccess("active", allowance, {
intent: nextIntent,
offerId: currentState.offerId,
competitorId: quote.competitorId,
totalEarnedMicroUsd: earnings.totalEarnedMicroUsd,
});
}
private async finishStopping(): Promise<void> {
if (this.state?.phase !== "stopping") return;
const stopping = this.state;
const before = await this.client.listAllOffers();
const active = before.filter((offer) => offer.status === "active");
for (const offer of active) {
const key = offer.id === stopping.offerId
? stopping.pauseIdempotencyKey
: this.mutationId();
await this.client.pauseOffer(offer.id, key);
}
const after = await this.client.listAllOffers();
if (after.some((offer) => offer.status === "active")) {
throw new Error("Surplus proof stop could not confirm all offers inactive");
}
const awaiting: SurplusControllerState = {
version: 1,
phase: "awaiting_settlement",
offerId: stopping.offerId,
intent: stopping.intent,
tradeObservedAt: stopping.tradeObservedAt,
};
await this.stateStore.save(awaiting);
this.state = awaiting;
await this.awaitSettlement();
}
private async awaitSettlement(): Promise<void> {
if (this.state?.phase !== "awaiting_settlement") return;
const awaiting = this.state;
const offers = await this.client.listAllOffers();
const active = offers.filter((offer) => offer.status === "active");
if (active.length > 0) {
await this.pauseOffers(active);
this.markSuccess("suspended", null, null);
return;
}
const earnings = await this.client.getEarnings();
const sale = this.findSettledSale(earnings, awaiting);
if (!sale) {
this.markSuccess("awaiting_settlement", null, {
intent: awaiting.intent,
offerId: awaiting.offerId,
competitorId: null,
totalEarnedMicroUsd: earnings.totalEarnedMicroUsd,
});
return;
}
const completed: SurplusControllerState = {
version: 1,
phase: "completed",
offerId: awaiting.offerId,
model: awaiting.intent.model,
providerModelId: awaiting.intent.providerModelId,
tradeObservedAt: awaiting.tradeObservedAt,
completedAt: new Date(this.now()).toISOString(),
settlement: {
offerId: sale.offerId,
createdAt: sale.createdAt ?? awaiting.tradeObservedAt,
sellerCostMicroUsd: sale.sellerCostMicroUsd,
settlementStatus: sale.settlementStatus,
},
};
await this.stateStore.save(completed);
this.state = completed;
this.markSuccess("completed", null, {
intent: awaiting.intent,
offerId: awaiting.offerId,
competitorId: null,
totalEarnedMicroUsd: earnings.totalEarnedMicroUsd,
});
}
private findSettledSale(
earnings: SurplusEarnings,
state: Extract<SurplusControllerState, { phase: "awaiting_settlement" }>,
): SurplusEarnings["recentSales"][number] | null {
const tradeObservedAt = Date.parse(state.tradeObservedAt);
return earnings.recentSales.find((sale) => {
if (
!this.config.settledStatuses.includes(
sale.settlementStatus.toLowerCase(),
)
) {
return false;
}
if (
sale.offerId !== state.offerId ||
sale.createdAt === null ||
sale.model !== state.intent.model ||
sale.sellerCostMicroUsd <= 0
) {
return false;
}
return Date.parse(sale.createdAt) >= tradeObservedAt;
}) ?? null;
}
private requireSelectedDiscovery(
discovered: SurplusDiscoveredModel[],
intent: SurplusOfferIntent,
): SurplusDiscoveredModel {
const selected = discovered.find((model) =>
model.model === intent.model && model.providerModelId === intent.providerModelId
);
if (
!selected ||
!selected.supported ||
selected.availabilityStatus !== "available" ||
selected.modelType !== "text" ||
selected.priceUnit !== "M" ||
selected.priceVariable ||
selected.inputUsdPer1m <= 0 ||
selected.outputUsdPer1m <= 0
) {
throw new Error(`Selected OpenRouter model is no longer safely sellable: ${intent.model}`);
}
return selected;
}
private assertAggregateCap(offers: SurplusOffer[]): void {
let activeCap = 0n;
for (const offer of offers) {
if (offer.status !== "active") continue;
if (offer.capDailyUsd === null) {
throw new Error(`Active Surplus offer has no daily cap: ${offer.id}`);
}
const capMicroUsd = offer.capDailyUsd * 1_000_000;
if (!Number.isSafeInteger(capMicroUsd)) {
throw new Error(`Active Surplus offer cap has excess precision: ${offer.id}`);
}
activeCap += BigInt(capMicroUsd) * 1_000n;
}
const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor");
if (activeCap > aggregateCap) {
throw new Error("Active Surplus offer caps exceed the aggregate cap");
}
}
private providerExposureUsdNanos(sellerCapUsdNanos: bigint): bigint {
const numerator = sellerCapUsdNanos * 10_000n;
const denominator = BigInt(this.config.recoveryBps);
return (numerator + denominator - 1n) / denominator;
}
private assertAllowance(
allowance: OpenRouterKeyAllowance,
exposureUsdNanos: bigint,
): void {
const reserve = parseUsdDecimal(this.config.reserveUsd, "ceil");
const maxLimit = parseUsdDecimal(this.config.maxProviderLimitUsd, "floor");
if (
allowance.limitUsdNanos > maxLimit ||
allowance.remainingUsdNanos > allowance.limitUsdNanos
) {
throw new Error("OpenRouter key limit exceeds the configured containment limit");
}
if (allowance.remainingUsdNanos < reserve + exposureUsdNanos) {
throw new Error("OpenRouter allowance cannot cover reserve threshold plus exposure");
}
}
private async pauseOffers(offers: SurplusOffer[]): Promise<void> {
if (!this.config.live) return;
for (const offer of offers) {
await this.client.pauseOffer(offer.id, this.mutationId());
}
}
private safeError(error: unknown): string {
const detail = error instanceof Error ? error.message : String(error);
return detail
.replaceAll(this.config.sellerApiKey, "[redacted]")
.replaceAll(this.config.providerApiKey, "[redacted]")
.replace(/(?:si_seller_|sk-or-v1-)[A-Za-z0-9_-]+/g, "[redacted]")
.slice(0, 500);
}
private markSuccess(
phase: SurplusControllerStatus["phase"],
allowance: OpenRouterKeyAllowance | null,
details: {
intent: SurplusOfferIntent;
offerId: string | null;
competitorId: string | null;
totalEarnedMicroUsd: number | null;
} | null,
): void {
const successAt = new Date(this.now()).toISOString();
const state = this.state;
const stateModel = state && "intent" in state
? state.intent.model
: state?.phase === "completed"
? state.model
: null;
const stateProviderModel = state && "intent" in state
? state.intent.providerModelId
: state?.phase === "completed"
? state.providerModelId
: null;
const stateOfferId = state && "offerId" in state ? state.offerId : null;
this.status = {
...this.status,
ok: true,
phase,
model: details?.intent.model ?? stateModel,
providerModelId: details?.intent.providerModelId ?? stateProviderModel,
offerId: details?.offerId ?? stateOfferId,
inputUsdPer1m: details
? formatMicroUsd(details.intent.inputMicroUsdPer1m)
: this.status.inputUsdPer1m,
outputUsdPer1m: details
? formatMicroUsd(details.intent.outputMicroUsdPer1m)
: this.status.outputUsdPer1m,
competitorId: details?.competitorId ?? null,
remainingAllowanceUsd: allowance
? formatUsdNanos(allowance.remainingUsdNanos, 6)
: this.status.remainingAllowanceUsd,
totalEarnedUsd: details?.totalEarnedMicroUsd === null || details === null
? this.status.totalEarnedUsd
: formatMicroUsd(details.totalEarnedMicroUsd),
lastSuccessAt: successAt,
lastError: null,
};
this.log(`Surplus controller phase=${phase} model=${this.status.model ?? "none"}`);
}
}
function normalizeUrl(value: string): string {
return value.replace(/\/+$/, "").toLowerCase();
}

View File

@@ -0,0 +1,301 @@
import type { SurplusManagerConfig } from "./config.js";
import type {
SurplusClient,
SurplusDailyEarnings,
SurplusEarnings,
SurplusOffer,
SurplusOrderBook,
SurplusSale,
} from "./client.js";
import {
hashSurplusSale,
type SurplusDashboardVectorProof,
} from "./vectorProof.js";
import type {
SurplusControllerStatus,
SurplusSellerController,
} from "./controller.js";
import type { OpenRouterKeyClient } from "./openRouterKey.js";
import { formatUsdNanos } from "../reseller/money.js";
import { formatMicroUsd } from "./policy.js";
export interface VectorMigrationSummary {
status: "retired";
model: string;
supplierWallet: string;
advertRef: string;
retirementTransaction: string;
retiredOn: string;
historicalAp3xEarned: string;
historicalSettledJobs: number;
historicalUpstreamSpendUsd: string;
}
export interface DashboardSaleProof {
status: "pending" | "confirmed" | "failed";
saleHash: string | null;
batchRoot: string | null;
txHash: string | null;
leafIndex: number | null;
siblings: string[];
}
export interface DashboardProofSource {
dashboardProof(saleId: string): SurplusDashboardVectorProof | null;
}
export interface SurplusDashboardSnapshot {
generatedAt: string;
controller: SurplusControllerStatus;
identity: {
sellerWallet: string;
payoutAddress: string;
};
provider: {
name: "OpenRouter";
hardLimitUsd: string;
remainingUsd: string;
usedUsd: string;
reserveUsd: string;
estimatedDailyExposureUsd: string;
};
surplus: {
offer: {
id: string | null;
model: string | null;
providerModel: string | null;
status: "active" | "inactive" | "missing";
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: SurplusDailyEarnings | 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;
transactionHash: string | null;
vectorProof: DashboardSaleProof;
}>;
};
vector: VectorMigrationSummary;
}
interface SurplusDashboardOptions {
controller: Pick<SurplusSellerController, "snapshot">;
client: Pick<SurplusClient, "listAllOffers" | "getEarnings" | "getOrderBook">;
allowance: Pick<OpenRouterKeyClient, "readAllowance">;
config: SurplusManagerConfig;
vector?: VectorMigrationSummary;
proofs?: DashboardProofSource;
cacheMs?: number;
now?: () => number;
}
const DEFAULT_VECTOR_MIGRATION: VectorMigrationSummary = {
status: "retired",
model: "deepseek/deepseek-v4-flash",
supplierWallet: "addr1v8zy05zsugau8su0lqn8q37xlryh39cww9mw9t6g7vt207c9xfc9d",
advertRef:
"29bc9e7ec9f4a26f33e419436cfe95239b5d06b75db637e8dbb4cbc478d1a445#0",
retirementTransaction:
"d7fdae603e5744c98efc20c25d73a83ebfef0308806026a370d9f81c975c11fa",
retiredOn: "2026-08-27",
historicalAp3xEarned: "1.600000",
historicalSettledJobs: 8,
historicalUpstreamSpendUsd: "0.000093",
};
export class SurplusDashboardService {
private readonly controller: Pick<SurplusSellerController, "snapshot">;
private readonly client: Pick<
SurplusClient,
"listAllOffers" | "getEarnings" | "getOrderBook"
>;
private readonly allowance: Pick<OpenRouterKeyClient, "readAllowance">;
private readonly config: SurplusManagerConfig;
private readonly proofs: DashboardProofSource | null;
private readonly vector: VectorMigrationSummary;
private readonly cacheMs: number;
private readonly now: () => number;
private cache: { fetchedAt: number; value: SurplusDashboardSnapshot } | null = null;
private inFlight: Promise<SurplusDashboardSnapshot> | null = null;
constructor(options: SurplusDashboardOptions) {
this.controller = options.controller;
this.client = options.client;
this.allowance = options.allowance;
this.config = options.config;
this.proofs = options.proofs ?? null;
this.vector = options.vector ?? DEFAULT_VECTOR_MIGRATION;
this.cacheMs = options.cacheMs ?? 15_000;
this.now = options.now ?? Date.now;
}
async getSnapshot(): Promise<SurplusDashboardSnapshot> {
const now = this.now();
if (this.cache && now - this.cache.fetchedAt < this.cacheMs) {
return this.cache.value;
}
if (this.inFlight) return this.inFlight;
const load = this.loadSnapshot();
this.inFlight = load;
try {
const value = await load;
this.cache = { fetchedAt: this.now(), value };
return value;
} finally {
this.inFlight = null;
}
}
private async loadSnapshot(): Promise<SurplusDashboardSnapshot> {
const controller = this.controller.snapshot();
const [offers, earnings, allowance, orderBook] = await Promise.all([
this.client.listAllOffers(),
this.client.getEarnings(),
this.allowance.readAllowance(),
controller.model ? this.client.getOrderBook(controller.model) : null,
]);
const offer = findOffer(offers, controller.offerId);
const marketOffer = findMarketOffer(orderBook, controller.offerId);
const usedUsdNanos = allowance.limitUsdNanos - allowance.remainingUsdNanos;
const estimatedDailyExposure =
Number(this.config.perOfferCapUsd) * 10_000 / this.config.recoveryBps;
const today = earnings.daily.find((day) =>
day.day === new Date(this.now()).toISOString().slice(0, 10)
) ?? null;
return {
generatedAt: new Date(this.now()).toISOString(),
controller,
identity: {
sellerWallet: this.config.sellerWallet,
payoutAddress: this.config.payoutAddress,
},
provider: {
name: "OpenRouter",
hardLimitUsd: formatUsdNanos(allowance.limitUsdNanos, 6),
remainingUsd: formatUsdNanos(allowance.remainingUsdNanos, 6),
usedUsd: formatUsdNanos(usedUsdNanos, 6),
reserveUsd: this.config.reserveUsd,
estimatedDailyExposureUsd: estimatedDailyExposure.toFixed(6),
},
surplus: {
offer: {
id: offer?.id ?? controller.offerId,
model: offer?.model ?? controller.model,
providerModel: controller.providerModelId,
status: offer?.status ?? "missing",
dailyCapUsd: offer?.capDailyUsd === null || offer === undefined
? null
: offer.capDailyUsd.toFixed(6),
costMultiplier: offer?.costMultiplierPpm === null || offer === undefined
? null
: offer.costMultiplierPpm / 1_000_000,
inputUsdPer1m: controller.inputUsdPer1m,
outputUsdPer1m: controller.outputUsdPer1m,
rank: marketOffer?.rank ?? null,
available: marketOffer?.available ?? false,
healthy: marketOffer?.healthy ?? false,
trusted: marketOffer?.trusted ?? false,
trades24h: marketOffer?.trades24h ?? 0,
volume24hUsd: formatMicroUsd(marketOffer?.volume24hMicroUsd ?? 0),
capRemainingUsd: marketOffer?.capRemainingMicroUsd === null ||
marketOffer === undefined
? null
: formatMicroUsd(marketOffer.capRemainingMicroUsd),
},
earnings: earningsSummary(earnings, today),
recentSales: earnings.recentSales.slice(0, 25).map((sale) => ({
id: sale.id,
model: sale.model,
createdAt: sale.createdAt,
settlementStatus: sale.settlementStatus,
inputTokens: sale.inputTokens,
outputTokens: sale.outputTokens,
cacheReadTokens: sale.cacheReadTokens,
revenueUsd: formatMicroUsd(sale.sellerCostMicroUsd),
transactionHash: sale.transactionHash,
vectorProof: this.proofFor(sale),
})),
},
vector: this.vector,
};
}
private proofFor(sale: SurplusSale): DashboardSaleProof {
const ledger = this.proofs?.dashboardProof(sale.id) ?? null;
if (ledger !== null) return ledger;
let saleHash: string | null = null;
try {
saleHash = hashSurplusSale(sale);
} catch {
saleHash = null;
}
return {
status: "pending",
saleHash,
batchRoot: null,
txHash: null,
leafIndex: null,
siblings: [],
};
}
}
function findOffer(
offers: SurplusOffer[],
offerId: string | null,
): SurplusOffer | undefined {
return offerId ? offers.find((offer) => offer.id === offerId) : undefined;
}
function findMarketOffer(
orderBook: SurplusOrderBook | null,
offerId: string | null,
) {
return offerId
? orderBook?.offers.find((offer) => offer.id === offerId)
: undefined;
}
function earningsSummary(
earnings: SurplusEarnings,
today: SurplusDailyEarnings | null,
): SurplusDashboardSnapshot["surplus"]["earnings"] {
return {
totalUsd: formatMicroUsd(earnings.totalEarnedMicroUsd),
pendingUsd: formatMicroUsd(earnings.pendingMicroUsd),
paidUsd: formatMicroUsd(earnings.paidMicroUsd),
requests: earnings.requestCount,
tokens: earnings.tokenCount,
today,
topModel: earnings.topModel,
payoutHoldReason: earnings.payoutHoldReason,
payoutHoldReleasesAt: earnings.payoutHoldReleasesAt,
};
}

View File

@@ -0,0 +1,215 @@
/**
* historyExport.ts — loads the Surplus support export
* (request_id, settled_at, model, input_tokens, output_tokens,
* seller_earned_usd, settlement_status, settlement_type, tx_hash) and maps the
* settled rows onto the publisher's sale shape.
*
* The export carries less detail than the live API. The mapping records what
* the export proves and nothing more:
* - `createdAt` uses the export's `settled_at` timestamp,
* - `cacheReadTokens` and both effective prices are zero (absent from the export),
* - `offerId` comes from configuration because the export omits it.
* Rows that are not settled (`settlement_status` !== "confirmed") are skipped;
* they surface later through the live window or a newer export.
*/
import { readFile } from "node:fs/promises";
import type { SurplusSale } from "./client.js";
const EXPECTED_HEADER = [
"request_id",
"settled_at",
"model",
"input_tokens",
"output_tokens",
"seller_earned_usd",
"settlement_status",
"settlement_type",
"tx_hash",
] as const;
const BASE_TX_RE = /^0x[0-9a-fA-F]{64}$/;
const TOKENS_RE = /^(?:0|[1-9]\d*)$/;
const USD_RE = /^(?:0|[1-9]\d*)(?:\.(\d{1,6}))?$/;
export interface HistoricalSalesExport {
sales: SurplusSale[];
settledWithoutTx: number;
skippedUnsettled: number;
}
export async function loadHistoricalSalesCsv(
path: string,
offerId: string,
): Promise<HistoricalSalesExport> {
if (offerId.trim() === "") {
throw new Error("The historical export needs a non-empty offer id");
}
const rows = parseCsv(await readFile(path, "utf8"));
const header = rows.shift();
if (
header === undefined ||
header.length !== EXPECTED_HEADER.length ||
EXPECTED_HEADER.some((name, index) => header[index] !== name)
) {
throw new Error(
`Surplus export header must be exactly: ${EXPECTED_HEADER.join(",")}`,
);
}
const result: HistoricalSalesExport = {
sales: [],
settledWithoutTx: 0,
skippedUnsettled: 0,
};
const seen = new Set<string>();
for (const [index, row] of rows.entries()) {
const label = `Surplus export row ${index + 2}`;
if (row.length !== EXPECTED_HEADER.length) {
throw new Error(`${label} has ${row.length} fields, expected 9`);
}
const [
requestId,
settledAt,
model,
inputTokens,
outputTokens,
earnedUsd,
settlementStatus,
,
txHash,
] = row;
if (requestId === "") throw new Error(`${label} is missing request_id`);
if (seen.has(requestId)) {
throw new Error(`${label} repeats request_id ${requestId}`);
}
seen.add(requestId);
if (settlementStatus !== "confirmed") {
result.skippedUnsettled += 1;
continue;
}
const createdAtMs = Date.parse(settledAt);
if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) {
throw new Error(`${label} has an invalid settled_at`);
}
const transactionHash = txHash === "" ? null : normalizeBaseTx(txHash, label);
if (transactionHash === null) result.settledWithoutTx += 1;
result.sales.push({
id: requestId,
model: nonEmpty(model, `${label} model`),
offerId,
settlementStatus,
createdAt: new Date(createdAtMs).toISOString(),
createdAtMs,
sellerCostMicroUsd: usdToMicro(earnedUsd, `${label} seller_earned_usd`),
inputTokens: tokens(inputTokens, `${label} input_tokens`),
outputTokens: tokens(outputTokens, `${label} output_tokens`),
cacheReadTokens: 0,
effectiveInputUsdPer1m: 0,
effectiveOutputUsdPer1m: 0,
transactionHash,
});
}
return result;
}
function normalizeBaseTx(value: string, label: string): string {
if (!BASE_TX_RE.test(value)) {
throw new Error(`${label} has an invalid tx_hash`);
}
return value.toLowerCase();
}
function nonEmpty(value: string, label: string): string {
if (value.trim() === "") throw new Error(`${label} must not be empty`);
return value;
}
function tokens(value: string, label: string): number {
if (!TOKENS_RE.test(value)) {
throw new Error(`${label} must be a non-negative integer`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${label} exceeds the safe integer range`);
}
return parsed;
}
/** Exact decimal → micro-USD integer without float rounding. */
function usdToMicro(value: string, label: string): number {
const match = USD_RE.exec(value);
if (match === null) {
throw new Error(`${label} must be a non-negative USD decimal with at most 6 places`);
}
const whole = Number(value.split(".")[0]);
const fraction = Number((match[1] ?? "").padEnd(6, "0"));
const micro = whole * 1_000_000 + fraction;
if (!Number.isSafeInteger(micro)) {
throw new Error(`${label} exceeds the safe integer range`);
}
return micro;
}
/** Minimal RFC-4180 parser: quoted fields, escaped quotes, CRLF or LF rows. */
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let index = 0;
const pushField = (): void => {
row.push(field);
field = "";
};
const pushRow = (): void => {
pushField();
rows.push(row);
row = [];
};
while (index < text.length) {
const char = text[index];
if (quoted) {
if (char === '"') {
if (text[index + 1] === '"') {
field += '"';
index += 2;
continue;
}
quoted = false;
index += 1;
continue;
}
field += char;
index += 1;
continue;
}
if (char === '"') {
if (field !== "") throw new Error(`Unexpected quote at offset ${index}`);
quoted = true;
index += 1;
continue;
}
if (char === ",") {
pushField();
index += 1;
continue;
}
if (char === "\n" || char === "\r") {
if (char === "\r" && text[index + 1] === "\n") index += 1;
pushRow();
index += 1;
continue;
}
field += char;
index += 1;
}
if (quoted) throw new Error("Unterminated quoted field");
if (field !== "" || row.length > 0) pushRow();
return rows.filter((entry) => !(entry.length === 1 && entry[0] === ""));
}

View File

@@ -0,0 +1,127 @@
import { parseUsdDecimal } from "../reseller/money.js";
const MAX_KEY_RESPONSE_BYTES = 1024 * 1024;
export interface OpenRouterKeyAllowance {
limitUsdNanos: bigint;
remainingUsdNanos: bigint;
reset: string | null;
checkedAtMs: number;
}
interface OpenRouterKeyClientOptions {
baseUrl: string;
apiKey: string;
timeoutMs: number;
fetchFn?: typeof globalThis.fetch;
now?: () => number;
}
export class OpenRouterKeyClient {
private readonly url: string;
private readonly apiKey: string;
private readonly timeoutMs: number;
private readonly fetchFn: typeof globalThis.fetch;
private readonly now: () => number;
constructor(options: OpenRouterKeyClientOptions) {
this.url = `${options.baseUrl.replace(/\/+$/, "")}/v1/auth/key`;
this.apiKey = options.apiKey;
this.timeoutMs = options.timeoutMs;
this.fetchFn = options.fetchFn ?? globalThis.fetch;
this.now = options.now ?? Date.now;
}
async readAllowance(): Promise<OpenRouterKeyAllowance> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await this.fetchFn(this.url, {
headers: { authorization: `Bearer ${this.apiKey}` },
redirect: "error",
signal: controller.signal,
});
const text = await readBoundedText(response, controller);
if (!response.ok) {
throw new Error(
`OpenRouter key endpoint returned HTTP ${response.status}`,
);
}
let body: unknown;
try {
body = JSON.parse(text);
} catch {
throw new Error("OpenRouter key endpoint response was not JSON");
}
const root = object(body, "OpenRouter key response");
const data = object(root.data, "OpenRouter key response.data");
if (data.limit === null || data.limit_remaining === null) {
throw new Error("OpenRouter resale key must have a finite spending limit");
}
const limit = usdNumberToNanos(data.limit, "OpenRouter key limit");
const remaining = usdNumberToNanos(
data.limit_remaining,
"OpenRouter key limit_remaining",
);
const reset = data.limit_reset;
if (reset !== null && reset !== undefined && typeof reset !== "string") {
throw new Error("OpenRouter key limit_reset must be a string or null");
}
return {
limitUsdNanos: limit,
remainingUsdNanos: remaining,
reset: typeof reset === "string" ? reset : null,
checkedAtMs: this.now(),
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`OpenRouter allowance check failed: ${detail.replaceAll(this.apiKey, "[redacted]")}`,
);
} finally {
clearTimeout(timer);
}
}
}
async function readBoundedText(
response: Response,
controller: AbortController,
): Promise<string> {
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let text = "";
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > MAX_KEY_RESPONSE_BYTES) {
controller.abort();
throw new Error(
`OpenRouter key response exceeded ${MAX_KEY_RESPONSE_BYTES} bytes`,
);
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
} finally {
reader.releaseLock();
}
}
function usdNumberToNanos(value: unknown, field: string): bigint {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number`);
}
return parseUsdDecimal(value.toFixed(9), "floor");
}
function object(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object`);
}
return value as Record<string, unknown>;
}

View File

@@ -0,0 +1,163 @@
export interface SurplusCompetitor {
id: string;
inputMicroUsdPer1m: number;
outputMicroUsdPer1m: number;
}
export interface SurplusMultiplierPolicyInput {
upstreamInputMicroUsdPer1m: number;
upstreamOutputMicroUsdPer1m: number;
recoveryBps: number;
undercutBps: number;
competitors: SurplusCompetitor[];
}
export interface SurplusMultiplierQuote {
costMultiplierPpm: number;
costMultiplier: number;
inputMicroUsdPer1m: number;
outputMicroUsdPer1m: number;
floorMultiplierPpm: number;
competitorId: string | null;
competitive: boolean;
}
const BPS_SCALE = 10_000n;
const PPM_SCALE = 1_000_000n;
const MICRO_USD = 1_000_000;
export function quoteSurplusMultiplier(
input: SurplusMultiplierPolicyInput,
): SurplusMultiplierQuote {
positiveSafeInteger(
input.upstreamInputMicroUsdPer1m,
"upstream input price",
);
positiveSafeInteger(
input.upstreamOutputMicroUsdPer1m,
"upstream output price",
);
boundedBps(input.recoveryBps, "recoveryBps", 1, 10_000);
boundedBps(input.undercutBps, "undercutBps", 0, 9_999);
const floorMultiplierPpm = input.recoveryBps * 100;
const competitor = cheapestCompetitor(input.competitors);
const competitorMultiplierPpm = competitor
? Math.min(
ratioPpm(
competitor.inputMicroUsdPer1m,
input.upstreamInputMicroUsdPer1m,
),
ratioPpm(
competitor.outputMicroUsdPer1m,
input.upstreamOutputMicroUsdPer1m,
),
)
: floorMultiplierPpm;
let undercutMultiplierPpm = Number(
BigInt(competitorMultiplierPpm) *
BigInt(10_000 - input.undercutBps) /
BPS_SCALE,
);
if (
input.undercutBps > 0 &&
undercutMultiplierPpm >= competitorMultiplierPpm
) {
undercutMultiplierPpm = Math.max(1, competitorMultiplierPpm - 1);
}
const quotedMultiplierPpm = Math.max(
floorMultiplierPpm,
undercutMultiplierPpm,
);
const quotedInput = applyMultiplier(
input.upstreamInputMicroUsdPer1m,
quotedMultiplierPpm,
);
const quotedOutput = applyMultiplier(
input.upstreamOutputMicroUsdPer1m,
quotedMultiplierPpm,
);
return {
costMultiplierPpm: quotedMultiplierPpm,
costMultiplier: quotedMultiplierPpm / Number(PPM_SCALE),
inputMicroUsdPer1m: quotedInput,
outputMicroUsdPer1m: quotedOutput,
floorMultiplierPpm,
competitorId: competitor?.id ?? null,
competitive: competitor === null || (
quotedInput < competitor.inputMicroUsdPer1m &&
quotedOutput < competitor.outputMicroUsdPer1m
),
};
}
export function usdPer1mToMicroUsd(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
const result = Math.ceil(value * MICRO_USD);
positiveSafeInteger(result, field);
return result;
}
export function formatMicroUsd(value: number): string {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("micro-USD value must be a non-negative safe integer");
}
const whole = Math.floor(value / MICRO_USD);
const fraction = (value % MICRO_USD).toString().padStart(6, "0");
return `${whole}.${fraction}`;
}
function cheapestCompetitor(
competitors: SurplusCompetitor[],
): SurplusCompetitor | null {
let result: SurplusCompetitor | null = null;
for (const competitor of competitors) {
positiveSafeInteger(competitor.inputMicroUsdPer1m, "competitor input price");
positiveSafeInteger(competitor.outputMicroUsdPer1m, "competitor output price");
if (result === null || compareCompetitors(competitor, result) < 0) {
result = competitor;
}
}
return result;
}
function compareCompetitors(
left: SurplusCompetitor,
right: SurplusCompetitor,
): number {
return (
left.inputMicroUsdPer1m + left.outputMicroUsdPer1m -
(right.inputMicroUsdPer1m + right.outputMicroUsdPer1m) ||
left.inputMicroUsdPer1m - right.inputMicroUsdPer1m ||
left.outputMicroUsdPer1m - right.outputMicroUsdPer1m ||
left.id.localeCompare(right.id)
);
}
function ratioPpm(numerator: number, denominator: number): number {
return Number(BigInt(numerator) * PPM_SCALE / BigInt(denominator));
}
function applyMultiplier(value: number, multiplierPpm: number): number {
return Number(BigInt(value) * BigInt(multiplierPpm) / PPM_SCALE);
}
function positiveSafeInteger(value: number, field: string): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${field} must be a positive safe integer`);
}
}
function boundedBps(
value: number,
field: string,
minimum: number,
maximum: number,
): void {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new Error(`${field} must be from ${minimum} to ${maximum}`);
}
}

View File

@@ -0,0 +1,110 @@
import type {
SurplusDiscoveredModel,
SurplusMarketSummary,
SurplusOrderBookOffer,
} from "./client.js";
import {
quoteSurplusMultiplier,
usdPer1mToMicroUsd,
type SurplusCompetitor,
type SurplusMultiplierQuote,
} from "./policy.js";
export interface SurplusModelCandidate {
model: string;
providerModelId: string;
upstreamInputMicroUsdPer1m: number;
upstreamOutputMicroUsdPer1m: number;
requests24h: number;
volume24h: number;
summaryQuote: SurplusMultiplierQuote;
}
export function selectSurplusModelCandidates(
discovered: SurplusDiscoveredModel[],
markets: SurplusMarketSummary[],
recoveryBps: number,
undercutBps: number,
): SurplusModelCandidate[] {
const marketByModel = new Map(markets.map((market) => [market.model, market]));
const candidates: SurplusModelCandidate[] = [];
for (const model of discovered) {
const market = marketByModel.get(model.model);
if (
!market ||
!model.supported ||
model.providerModelId === "" ||
model.availabilityStatus !== "available" ||
model.modelType !== "text" ||
model.priceUnit !== "M" ||
model.priceVariable ||
model.inputUsdPer1m <= 0 ||
model.outputUsdPer1m <= 0 ||
market.healthySellerCount <= 0 ||
market.bestInputMicroUsdPer1m <= 0 ||
market.bestOutputMicroUsdPer1m <= 0
) {
continue;
}
const inputPrice = usdPer1mToMicroUsd(
model.inputUsdPer1m,
`${model.model} discovery input price`,
);
const outputPrice = usdPer1mToMicroUsd(
model.outputUsdPer1m,
`${model.model} discovery output price`,
);
const quote = quoteSurplusMultiplier({
upstreamInputMicroUsdPer1m: inputPrice,
upstreamOutputMicroUsdPer1m: outputPrice,
recoveryBps,
undercutBps,
competitors: [{
id: `summary:${model.model}`,
inputMicroUsdPer1m: market.bestInputMicroUsdPer1m,
outputMicroUsdPer1m: market.bestOutputMicroUsdPer1m,
}],
});
if (!quote.competitive) continue;
candidates.push({
model: model.model,
providerModelId: model.providerModelId,
upstreamInputMicroUsdPer1m: inputPrice,
upstreamOutputMicroUsdPer1m: outputPrice,
requests24h: market.requests24h,
volume24h: market.volume24h,
summaryQuote: quote,
});
}
return candidates.sort(compareCandidates);
}
export function eligibleCompetitors(
offers: SurplusOrderBookOffer[],
sellerWallet: string,
ownOfferIds: ReadonlySet<string>,
): SurplusCompetitor[] {
const normalizedWallet = sellerWallet.toLowerCase();
return offers
.filter((offer) =>
offer.available &&
offer.healthy &&
offer.seller !== normalizedWallet &&
!ownOfferIds.has(offer.id)
)
.map((offer) => ({
id: offer.id,
inputMicroUsdPer1m: offer.inputMicroUsdPer1m,
outputMicroUsdPer1m: offer.outputMicroUsdPer1m,
}));
}
function compareCandidates(
left: SurplusModelCandidate,
right: SurplusModelCandidate,
): number {
return right.requests24h - left.requests24h ||
right.volume24h - left.volume24h ||
left.model.localeCompare(right.model) ||
left.providerModelId.localeCompare(right.providerModelId);
}

View File

@@ -0,0 +1,355 @@
import { constants, type Stats } from "node:fs";
import {
chmod,
lstat,
mkdir,
open,
rename,
unlink,
type FileHandle,
} from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";
export interface SurplusOfferIntent {
model: string;
providerModelId: string;
costMultiplierPpm: number;
inputMicroUsdPer1m: number;
outputMicroUsdPer1m: number;
dailyCapUsd: number;
baselineRemainingUsdNanos: string;
idempotencyKey: string;
createdAt: string;
baselineTrades24h: number;
}
export interface SurplusSettlementEvidence {
offerId: string | null;
createdAt: string;
sellerCostMicroUsd: number;
settlementStatus: string;
}
export type SurplusControllerState =
| { version: 1; phase: "selecting" }
| { version: 1; phase: "create_pending"; intent: SurplusOfferIntent }
| {
version: 1;
phase: "active";
offerId: string;
intent: SurplusOfferIntent;
highestTrades24h: number;
}
| {
version: 1;
phase: "stopping";
offerId: string;
intent: SurplusOfferIntent;
highestTrades24h: number;
tradeObservedAt: string;
pauseIdempotencyKey: string;
}
| {
version: 1;
phase: "awaiting_settlement";
offerId: string;
intent: SurplusOfferIntent;
tradeObservedAt: string;
}
| {
version: 1;
phase: "completed";
offerId: string;
model: string;
providerModelId: string;
tradeObservedAt: string;
completedAt: string;
settlement: SurplusSettlementEvidence;
};
export interface SurplusStateStore {
load(): Promise<SurplusControllerState>;
save(state: SurplusControllerState): Promise<void>;
}
export class FileSurplusStateStore implements SurplusStateStore {
private readonly path: string;
constructor(path: string) {
this.path = path;
}
async load(): Promise<SurplusControllerState> {
let handle: FileHandle;
try {
handle = await open(
this.path,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
} catch (error) {
if (isNodeError(error, "ENOENT")) return { version: 1, phase: "selecting" };
throw error;
}
try {
const info = await handle.stat();
assertSecureOwner(info, false, "Surplus state file");
const text = await handle.readFile("utf8");
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error("Surplus state file is not valid JSON");
}
return parseState(value);
} finally {
await handle.close();
}
}
async save(state: SurplusControllerState): Promise<void> {
const directory = dirname(this.path);
await mkdir(directory, { recursive: true, mode: 0o700 });
await chmod(directory, 0o700);
const directoryInfo = await lstat(directory);
assertSecureOwner(directoryInfo, true, "Surplus state directory");
const tempPath = join(directory, `.${basename(this.path)}.${randomUUID()}.tmp`);
const body = `${JSON.stringify(state)}\n`;
let handle: FileHandle | null = null;
try {
handle = await open(
tempPath,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o600,
);
await handle.writeFile(body, "utf8");
await handle.sync();
await handle.close();
handle = null;
await rename(tempPath, this.path);
const directoryHandle = await open(
directory,
constants.O_RDONLY | constants.O_DIRECTORY,
);
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
} finally {
if (handle !== null) await handle.close().catch(() => undefined);
await unlink(tempPath).catch((error) => {
if (!isNodeError(error, "ENOENT")) throw error;
});
}
}
}
function assertSecureOwner(
info: Stats,
directory: boolean,
field: string,
): void {
if (directory ? !info.isDirectory() : !info.isFile()) {
throw new Error(`${field} has the wrong file type`);
}
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
throw new Error(`${field} must be owned by the service user`);
}
if ((info.mode & 0o077) !== 0) {
throw new Error(`${field} must not allow group or other access`);
}
}
function parseState(value: unknown): SurplusControllerState {
const root = object(value, "Surplus state");
if (root.version !== 1) throw new Error("Surplus state version must be 1");
const phase = nonEmptyString(root.phase, "Surplus state phase");
if (phase === "selecting") return { version: 1, phase };
if (phase === "create_pending") {
return { version: 1, phase, intent: parseIntent(root.intent) };
}
if (phase === "active") {
return {
version: 1,
phase,
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
intent: parseIntent(root.intent),
highestTrades24h: nonNegativeInteger(
root.highestTrades24h,
"Surplus state highestTrades24h",
),
};
}
if (phase === "stopping") {
return {
version: 1,
phase,
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
intent: parseIntent(root.intent),
highestTrades24h: nonNegativeInteger(
root.highestTrades24h,
"Surplus state highestTrades24h",
),
tradeObservedAt: isoTimestamp(
root.tradeObservedAt,
"Surplus state tradeObservedAt",
),
pauseIdempotencyKey: nonEmptyString(
root.pauseIdempotencyKey,
"Surplus state pauseIdempotencyKey",
),
};
}
if (phase === "awaiting_settlement") {
return {
version: 1,
phase,
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
intent: parseIntent(root.intent),
tradeObservedAt: isoTimestamp(
root.tradeObservedAt,
"Surplus state tradeObservedAt",
),
};
}
if (phase === "completed") {
return {
version: 1,
phase,
offerId: nonEmptyString(root.offerId, "Surplus state offerId"),
model: nonEmptyString(root.model, "Surplus state model"),
providerModelId: nonEmptyString(
root.providerModelId,
"Surplus state providerModelId",
),
tradeObservedAt: isoTimestamp(
root.tradeObservedAt,
"Surplus state tradeObservedAt",
),
completedAt: isoTimestamp(root.completedAt, "Surplus state completedAt"),
settlement: parseSettlement(root.settlement),
};
}
throw new Error(`Surplus state phase is unsupported: ${phase}`);
}
function parseSettlement(value: unknown): SurplusSettlementEvidence {
const settlement = object(value, "Surplus state settlement");
const offerId = settlement.offerId;
if (offerId !== null && (typeof offerId !== "string" || offerId === "")) {
throw new Error("Surplus state settlement.offerId must be a string or null");
}
return {
offerId,
createdAt: isoTimestamp(
settlement.createdAt,
"Surplus state settlement.createdAt",
),
sellerCostMicroUsd: positiveInteger(
settlement.sellerCostMicroUsd,
"Surplus state settlement.sellerCostMicroUsd",
),
settlementStatus: nonEmptyString(
settlement.settlementStatus,
"Surplus state settlement.settlementStatus",
),
};
}
function parseIntent(value: unknown): SurplusOfferIntent {
const intent = object(value, "Surplus state intent");
return {
model: nonEmptyString(intent.model, "Surplus state intent.model"),
providerModelId: nonEmptyString(
intent.providerModelId,
"Surplus state intent.providerModelId",
),
costMultiplierPpm: positiveInteger(
intent.costMultiplierPpm,
"Surplus state intent.costMultiplierPpm",
),
inputMicroUsdPer1m: positiveInteger(
intent.inputMicroUsdPer1m,
"Surplus state intent.inputMicroUsdPer1m",
),
outputMicroUsdPer1m: positiveInteger(
intent.outputMicroUsdPer1m,
"Surplus state intent.outputMicroUsdPer1m",
),
dailyCapUsd: positiveNumber(
intent.dailyCapUsd,
"Surplus state intent.dailyCapUsd",
),
baselineRemainingUsdNanos: nonNegativeIntegerString(
intent.baselineRemainingUsdNanos,
"Surplus state intent.baselineRemainingUsdNanos",
),
idempotencyKey: nonEmptyString(
intent.idempotencyKey,
"Surplus state intent.idempotencyKey",
),
createdAt: isoTimestamp(intent.createdAt, "Surplus state intent.createdAt"),
baselineTrades24h: nonNegativeInteger(
intent.baselineTrades24h,
"Surplus state intent.baselineTrades24h",
),
};
}
function object(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object`);
}
return value as Record<string, unknown>;
}
function nonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value === "") {
throw new Error(`${field} must be a non-empty string`);
}
return value;
}
function nonNegativeIntegerString(value: unknown, field: string): string {
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)$/.test(value)) {
throw new Error(`${field} must be a non-negative integer string`);
}
return value;
}
function isoTimestamp(value: unknown, field: string): string {
const timestamp = nonEmptyString(value, field);
if (!Number.isFinite(Date.parse(timestamp))) {
throw new Error(`${field} must be an ISO timestamp`);
}
return timestamp;
}
function positiveInteger(value: unknown, field: string): number {
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
throw new Error(`${field} must be a positive safe integer`);
}
return value as number;
}
function nonNegativeInteger(value: unknown, field: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new Error(`${field} must be a non-negative safe integer`);
}
return value as number;
}
function positiveNumber(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`${field} must be a positive finite number`);
}
return value;
}
function isNodeError(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
/**
* vectorPublisher.ts — anchors Surplus sale Merkle batches on Vector.
*
* Policy (2026-08-28): every settled Base USDC settlement transaction gets its
* own Vector counterpart. Sales are grouped by their Base `tx_hash`; each group
* becomes one Merkle batch anchored in one label-674 Vector transaction.
* Observed sales that fell out of the Surplus API window before settling are
* anchored together in a trailing catch-up batch so no captured sale is left
* without a proof.
*
* The publisher is fail-closed:
* - it never anchors while the wallet cannot cover reserve + fee budget,
* - every batch is persisted as a pending intent before any submission,
* - a stale pending intent found at cycle start is failed as "submit" and
* rebuilt deterministically (same sales ⇒ same root) on the same cycle.
*/
import type { SurplusEarnings, SurplusSale } from "./client.js";
import {
SURPLUS_SALE_PROOF_PROTOCOL,
SURPLUS_VECTOR_BATCH_SIZE,
type CanonicalSurplusSale,
type FileSurplusVectorProofLedger,
type SurplusPendingBatchIntent,
} from "./vectorProof.js";
export interface SurplusProofAnchorMetadata {
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
root: string;
count: number;
first: string;
last: string;
}
export type SurplusProofLedgerSeam = Pick<
FileSurplusVectorProofLedger,
| "ingestSales"
| "selectUnprovedSales"
| "persistPendingBatch"
| "pendingBatchIntent"
| "confirmPendingBatch"
| "failPendingBatch"
| "dashboardProof"
>;
export interface SurplusVectorProofPublisherOptions {
ledger: SurplusProofLedgerSeam;
earnings: () => Promise<SurplusEarnings>;
/**
* Optional support-export source for sales the live window can no longer
* show. Observed sales win on id collisions; ids already in the ledger are
* never re-ingested, so a later live observation cannot conflict.
*/
historicalSales?: () => Promise<readonly SurplusSale[]>;
anchor: (
metadata: SurplusProofAnchorMetadata,
) => Promise<{ expectedTxHash: string }>;
awaitTx: (txHash: string, timeoutMs: number) => Promise<void>;
balanceLovelace: () => Promise<bigint>;
reserveLovelace: bigint;
feeBudgetLovelace: bigint;
settledStatuses: readonly string[];
intervalMs: number;
confirmTimeoutMs: number;
log: (message: string) => void;
}
export interface SurplusPublisherCycleResult {
ingested: number;
anchored: number;
fundingBlocked: boolean;
failed: boolean;
}
export class SurplusVectorProofPublisher {
private readonly options: SurplusVectorProofPublisherOptions;
private timer: NodeJS.Timeout | null = null;
private inFlight: Promise<SurplusPublisherCycleResult> | null = null;
private stopped = false;
constructor(options: SurplusVectorProofPublisherOptions) {
if (options.reserveLovelace < 0n || options.feeBudgetLovelace <= 0n) {
throw new Error(
"Surplus proof publisher needs a non-negative reserve and a positive fee budget",
);
}
this.options = options;
}
start(): void {
if (this.timer !== null) return;
this.stopped = false;
void this.tick();
this.timer = setInterval(() => void this.tick(), this.options.intervalMs);
this.timer.unref?.();
}
async stop(): Promise<void> {
this.stopped = true;
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
if (this.inFlight !== null) {
await this.inFlight.catch(() => undefined);
}
}
private async tick(): Promise<void> {
if (this.inFlight !== null || this.stopped) return;
const cycle = this.runCycle();
this.inFlight = cycle;
try {
await cycle;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.options.log(`surplus-proof: cycle failed: ${detail}`);
} finally {
this.inFlight = null;
}
}
async runCycle(): Promise<SurplusPublisherCycleResult> {
const { ledger, log } = this.options;
const result: SurplusPublisherCycleResult = {
ingested: 0,
anchored: 0,
fundingBlocked: false,
failed: false,
};
const settled = await this.observeSettledSales();
const historical = this.options.historicalSales === undefined
? []
: await this.options.historicalSales();
const candidates = dedupeById([...settled, ...historical]);
const fresh = candidates.filter((sale) =>
ledger.dashboardProof(sale.id) === null
);
const { inserted } = await ledger.ingestSales(fresh);
result.ingested = inserted;
if (inserted > 0) {
log(`surplus-proof: captured ${inserted} newly settled sale(s)`);
}
const stale = ledger.pendingBatchIntent();
if (stale !== null) {
await ledger.failPendingBatch(stale.batchRoot, "submit");
log(
`surplus-proof: failed stale pending batch ${stale.batchRoot} from an interrupted run; retrying this cycle`,
);
}
for (const batch of this.planBatches(candidates, ledger.selectUnprovedSales())) {
if (this.stopped) break;
if (!(await this.fundsAvailable())) {
result.fundingBlocked = true;
break;
}
const anchored = await this.anchorBatch(batch);
if (!anchored) {
result.failed = true;
break;
}
result.anchored += 1;
}
return result;
}
/** Settled = carries a Base settlement tx hash and passes the status filter. */
private async observeSettledSales(): Promise<SurplusSale[]> {
const { earnings, settledStatuses } = this.options;
const snapshot = await earnings();
return snapshot.recentSales.filter((sale) =>
sale.transactionHash !== null &&
(settledStatuses.length === 0 ||
settledStatuses.includes(sale.settlementStatus))
);
}
/**
* One batch per known Base settlement tx (oldest first), then catch-up
* batches for unproved sales with no known settlement tx, chunked to the
* ledger batch-size limit.
*/
private planBatches(
settled: readonly SurplusSale[],
unproved: readonly CanonicalSurplusSale[],
): CanonicalSurplusSale[][] {
const txBySaleId = new Map<string, string>();
for (const sale of settled) {
if (sale.transactionHash !== null) {
txBySaleId.set(sale.id, sale.transactionHash);
}
}
const groups = new Map<string, CanonicalSurplusSale[]>();
const leftovers: CanonicalSurplusSale[] = [];
for (const sale of unproved) {
const settlementTx = txBySaleId.get(sale.saleId);
if (settlementTx === undefined) {
leftovers.push(sale);
continue;
}
const group = groups.get(settlementTx);
if (group === undefined) groups.set(settlementTx, [sale]);
else group.push(sale);
}
const batches = [...groups.values()].sort(
(left, right) => earliest(left) - earliest(right),
);
for (let start = 0; start < leftovers.length; start += SURPLUS_VECTOR_BATCH_SIZE) {
batches.push(leftovers.slice(start, start + SURPLUS_VECTOR_BATCH_SIZE));
}
return batches;
}
private async fundsAvailable(): Promise<boolean> {
const { balanceLovelace, reserveLovelace, feeBudgetLovelace, log } =
this.options;
const balance = await balanceLovelace();
if (balance >= reserveLovelace + feeBudgetLovelace) return true;
log(
`surplus-proof: funding required — wallet holds ${balance} lovelace, ` +
`needs reserve ${reserveLovelace} + fee budget ${feeBudgetLovelace}; anchoring paused`,
);
return false;
}
private async anchorBatch(
sales: readonly CanonicalSurplusSale[],
): Promise<boolean> {
const { ledger, anchor, awaitTx, confirmTimeoutMs, log } = this.options;
const saleIds = sales.map((sale) => sale.saleId).sort();
const intent: SurplusPendingBatchIntent = await ledger.persistPendingBatch(
saleIds,
);
let expectedTxHash: string;
try {
const built = await anchor({
p: SURPLUS_SALE_PROOF_PROTOCOL,
root: intent.batchRoot,
count: intent.count,
first: intent.firstSaleId,
last: intent.lastSaleId,
});
expectedTxHash = built.expectedTxHash;
} catch (error) {
await ledger.failPendingBatch(intent.batchRoot, "submit");
const detail = error instanceof Error ? error.message : String(error);
log(
`surplus-proof: submit failed for batch ${intent.batchRoot} (${intent.count} sale(s)): ${detail}`,
);
return false;
}
try {
await awaitTx(expectedTxHash, confirmTimeoutMs);
} catch (error) {
await ledger.failPendingBatch(intent.batchRoot, "confirm");
const detail = error instanceof Error ? error.message : String(error);
log(
`surplus-proof: confirmation timed out for batch ${intent.batchRoot} in tx ${expectedTxHash}: ${detail}`,
);
return false;
}
await ledger.confirmPendingBatch(intent.batchRoot, expectedTxHash);
log(
`surplus-proof: anchored ${intent.count} sale(s) [${intent.firstSaleId}..${intent.lastSaleId}] ` +
`root ${intent.batchRoot} in Vector tx ${expectedTxHash}`,
);
return true;
}
}
function earliest(sales: readonly CanonicalSurplusSale[]): number {
let value = Number.POSITIVE_INFINITY;
for (const sale of sales) {
if (sale.createdAtEpochMs < value) value = sale.createdAtEpochMs;
}
return value;
}
/** First occurrence wins; callers put observed live sales before export rows. */
function dedupeById(sales: readonly SurplusSale[]): SurplusSale[] {
const seen = new Set<string>();
const unique: SurplusSale[] = [];
for (const sale of sales) {
if (seen.has(sale.id)) continue;
seen.add(sale.id);
unique.push(sale);
}
return unique;
}

View File

@@ -34,6 +34,7 @@ export function buildSampleConfig(): SupplierConfig {
maxChatSessions: 1,
chatSettleMode: "full",
liveChain: false,
reseller: null,
};
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../../buyer/src/server.js";
describe("buyer resale dashboard proxy", () => {
it("serves the private supplier snapshot from a same-origin buyer route", async () => {
const payload = {
generatedAt: "2026-08-28T12:00:00.000Z",
controller: { ok: true, phase: "active" },
surplus: { earnings: { requests: 1_333 } },
vector: { status: "retired" },
};
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get("accept")).toBe("application/json");
expect(init?.redirect).toBe("error");
return Response.json(payload);
});
const app = createApp({
resaleDashboardUrl:
"http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard/",
fetchImpl,
});
const response = await request(app).get("/v1/resale-dashboard");
expect(response.status).toBe(200);
expect(response.body).toEqual(payload);
expect(response.headers["cache-control"]).toBe("no-store");
expect(fetchImpl).toHaveBeenCalledWith(
"http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard",
expect.any(Object),
);
});
it("fails without exposing an upstream error body", async () => {
const app = createApp({
resaleDashboardUrl: "http://seller/internal/resale-dashboard",
fetchImpl: async () => new Response("secret upstream detail", { status: 500 }),
});
const response = await request(app).get("/v1/resale-dashboard");
expect(response.status).toBe(502);
expect(response.body.error).toBe("dashboard_upstream_error");
expect(JSON.stringify(response.body)).not.toContain("secret upstream detail");
});
it("returns a bounded feature-disabled response when unconfigured", async () => {
const fetchImpl = vi.fn();
const app = createApp({ fetchImpl });
const response = await request(app).get("/v1/resale-dashboard");
expect(response.status).toBe(503);
expect(response.body.error).toBe("service_unavailable");
expect(fetchImpl).not.toHaveBeenCalled();
});
});

View File

@@ -30,6 +30,7 @@ import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/ty
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
import type { ChatMessage } from "../../packages/shared/src/tx/types.js";
import type { ProgressEvent } from "../../buyer/src/sdk/types.js";
import { BOUNDED_INPUT_DETAIL_MARKER } from "../../packages/shared/src/tx/inputBound.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -473,6 +474,43 @@ describe("Marketplace.submitPrompt() — rejection paths", () => {
expect(e.name).toBe("ReceiptVerificationError");
expect(e.reason).toBe("test reason");
});
it("rejects a bounded-input request before posting escrow", async () => {
const advert = makeActiveAdvert({
detail_uri: `https://supplier.example.com/reseller${BOUNDED_INPUT_DETAIL_MARKER}`,
});
seedAdvertUtxo(chain, advert);
const submitSpy = vi.spyOn(chain, "submitTx");
fetchSpy.mockImplementation((url: unknown) => {
if (String(url).endsWith("/capability")) {
return jsonFetch({
capability_id: advert.capability_id,
model: advert.model,
max_output_tokens: advert.max_output_tokens,
max_processing_ms: advert.max_processing_ms,
max_input_tokens: 1,
price_lovelace: advert.price_lovelace.toString(),
advert_ref: `${ADVERT_REF.txHash}#${ADVERT_REF.index}`,
supplier_pkh: advert.supplier_pkh,
pub_key_hex: supplier.pubKeyHex,
});
}
return jsonFetch({});
});
const mp = makeMarketplace(chain, fetchSpy);
await expect(
mp.submitPrompt({
advertRef: ADVERT_REF,
messages: SAMPLE_MESSAGES,
payment_lovelace: PAYMENT,
}),
).rejects.toSatisfy(
(error: unknown) =>
error instanceof TxConstructionError &&
error.reason === "input_cap_exceeded",
);
expect(submitSpy).not.toHaveBeenCalled();
});
});
// ─── M1-F-1: isSyncThrow dead-code removal (RED) ─────────────────────────────

View File

@@ -87,6 +87,37 @@ describe("loadConfig(env)", () => {
const env = { ...VALID_ENV, COOKIE_SECURE: "true" };
expect(() => loadConfig(env)).toThrow(/COOKIE_SECURE/);
});
it("defaults pdfEnabled to true when PDF_ENABLED is not provided", () => {
expect(loadConfig(VALID_ENV).pdfEnabled).toBe(true);
});
it('honours PDF_ENABLED="0" (summarizer kill switch)', () => {
const config = loadConfig({ ...VALID_ENV, PDF_ENABLED: "0" });
expect(config.pdfEnabled).toBe(false);
});
it("throws when PDF_ENABLED is not 0 or 1", () => {
expect(() => loadConfig({ ...VALID_ENV, PDF_ENABLED: "false" })).toThrow(/PDF_ENABLED/);
});
it("normalizes the private resale dashboard URL", () => {
const config = loadConfig({
...VALID_ENV,
SURPLUS_DASHBOARD_URL:
"http://surplus-seller:8080/internal/resale-dashboard/",
});
expect(config.resaleDashboardUrl).toBe(
"http://surplus-seller:8080/internal/resale-dashboard",
);
});
it("rejects a resale dashboard URL with an unsupported protocol", () => {
expect(() => loadConfig({
...VALID_ENV,
SURPLUS_DASHBOARD_URL: "file:///tmp/dashboard.json",
})).toThrow(/SURPLUS_DASHBOARD_URL/);
});
});
// ─── createApp tests ──────────────────────────────────────────────────────────

View File

@@ -0,0 +1,155 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import App from "../../buyer/src/ui/App.js";
import { AuthProvider } from "../../buyer/src/ui/state/AuthContext.js";
const DASHBOARD = {
generatedAt: "2026-08-28T12:00:00.000Z",
controller: { ok: true, phase: "active", lastError: null },
identity: {
sellerWallet: "0x1111111111111111111111111111111111111111",
payoutAddress: "0x1111111111111111111111111111111111111111",
},
provider: {
hardLimitUsd: "20.000000",
remainingUsd: "15.157733",
usedUsd: "4.842267",
reserveUsd: "1.00",
estimatedDailyExposureUsd: "1.020408",
},
surplus: {
offer: {
id: "offer-1",
model: "deepseek-v4-flash",
providerModel: "deepseek/deepseek-v4-flash",
status: "active",
dailyCapUsd: "0.050000",
costMultiplier: 0.05065,
inputUsdPer1m: "0.004027",
outputUsdPer1m: "0.008055",
rank: 11,
available: true,
healthy: true,
trusted: true,
trades24h: 654,
volume24hUsd: "0.112434",
capRemainingUsd: "0.000000",
},
earnings: {
totalUsd: "0.239903",
pendingUsd: "0.219945",
paidUsd: "0.019958",
requests: 1_333,
tokens: 138_612_442,
today: { earnedUsd: 0.053093, requests: 240, totalTokens: 33_913_537 },
topModel: "deepseek-v4-flash",
payoutHoldReason: "new_seller",
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
},
recentSales: [
{
id: "sale-1",
model: "deepseek-v4-flash",
createdAt: "2026-08-28T11:00:00.000Z",
settlementStatus: "confirmed",
inputTokens: 10,
outputTokens: 2,
cacheReadTokens: 3,
revenueUsd: "0.000083",
vectorProof: {
status: "confirmed",
saleHash: "1".repeat(64),
batchRoot: "2".repeat(64),
txHash: "d".repeat(64),
leafIndex: 0,
siblings: ["3".repeat(64)],
},
},
{
id: "sale-2",
model: "deepseek-v4-flash",
createdAt: "2026-08-28T11:01:00.000Z",
settlementStatus: "confirmed",
inputTokens: 20,
outputTokens: 4,
cacheReadTokens: 6,
revenueUsd: "0.000166",
vectorProof: {
status: "pending",
saleHash: "4".repeat(64),
batchRoot: null,
txHash: null,
leafIndex: null,
siblings: [],
},
},
{
id: "sale-3",
model: "deepseek-v4-flash",
createdAt: "2026-08-28T11:02:00.000Z",
settlementStatus: "confirmed",
inputTokens: 30,
outputTokens: 6,
cacheReadTokens: 9,
revenueUsd: "0.000249",
vectorProof: {
status: "failed",
saleHash: "5".repeat(64),
batchRoot: null,
txHash: null,
leafIndex: null,
siblings: [],
},
},
],
},
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Capacity markets dashboard", () => {
it("renders the authenticated route with a Vector proof state for every sale", async () => {
const fetchMock = vi.fn(async () => Response.json(DASHBOARD));
vi.stubGlobal("fetch", fetchMock);
render(
<MemoryRouter initialEntries={["/dashboard"]}>
<AuthProvider initialStatus="authenticated">
<App />
</AuthProvider>
</MemoryRouter>,
);
expect(await screen.findByRole("heading", { name: "Capacity Markets" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Capacity" })).toHaveAttribute("href", "/dashboard");
expect(screen.getByRole("heading", { name: "Surplus Intelligence" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Vector proof" })).toBeInTheDocument();
expect(screen.getByText("$0.2399")).toBeInTheDocument();
expect(screen.getByTestId("resale-dashboard")).toHaveClass("max-w-7xl");
expect(document.querySelectorAll(".bg-white").length).toBeGreaterThan(3);
const proofSummary = screen.getByRole("group", { name: "Vector proof summary" });
expect(within(proofSummary).getByText("Confirmed 1")).toHaveClass("bg-green-100");
expect(within(proofSummary).getByText("Pending 1")).toHaveClass("bg-amber-100");
expect(within(proofSummary).getByText("Failed 1")).toHaveClass("bg-red-100");
expect(screen.getByText("pending")).toHaveClass("bg-amber-100");
expect(screen.getByText("failed")).toHaveClass("bg-red-100");
const proofLink = screen.getByRole("link", { name: /Confirmed Vector proof transaction/ });
expect(proofLink).toHaveAttribute(
"href",
`https://vector.apexscan.org/en/transaction/${"d".repeat(64)}/summary/`,
);
expect(within(proofLink).getByText("dddddddddddd…ddddddddd")).toBeInTheDocument();
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
"/v1/resale-dashboard",
{ cache: "no-store" },
));
});
});

View File

@@ -0,0 +1,132 @@
/**
* gateway-account-locked.test.ts — /account locked_in_escrow_lovelace accuracy.
*
* The indexer never updates escrow rows on terminal spends (Accept/Reclaim/
* Release consume the UTxO without producing a new escrow output), so rows
* can sit in an "active" state long after the funds moved. The account
* handler must verify each active row's UTxO is still unspent on-chain
* before counting it as locked; a spent ref is a stale row, not locked funds.
*
* Note: supplier_bond stays in the sum on purpose — the buyer fronts
* payment + buyer_bond + supplier_bond at PostEscrow (see postEscrow.ts
* totalLocked), so all three legs are the key's money while the escrow lives.
*/
import { describe, it, expect, vi } from "vitest";
import type { Request, Response } from "express";
import { makeAccountHandler } from "../../gateway/src/account/routes.js";
const LIVE_REF = "a".repeat(64) + "#0";
const STALE_REF = "b".repeat(64) + "#1";
function escrowRow(utxoRef: string, state: string) {
return {
utxo_ref: utxoRef,
state,
payment_lovelace: "2000000",
buyer_bond_lovelace: "1000000",
supplier_bond_lovelace: "1000000",
};
}
function fakeKeyRow() {
return {
id: "key-1",
key_hash: "h",
key_prefix: "vk_live_abc",
label: null,
wallet_pkh: "c".repeat(56),
deposit_address: "addr1_deposit",
enc_priv_nonce: "",
enc_priv_ct: "",
enc_priv_tag: "",
master_key_version: 1,
created_at: 0,
disabled: 0,
demo: 0,
};
}
function makeDeps(rows: Array<Record<string, unknown>>, opts?: { queryUtxo?: (ref: { txHash: string; index: number }) => Promise<unknown> }) {
const queryUtxo =
opts?.queryUtxo ??
(async (ref: { txHash: string; index: number }) => {
const canonical = `${ref.txHash}#${ref.index}`;
return canonical === LIVE_REF ? { ref, lovelace: 4_000_000n } : null;
});
return {
config: { indexerUrl: "http://indexer.test", networkId: 1, masterKeyHex: "00" },
store: {
sumCostLovelace: () => 0n,
countUsage: () => 0,
listUsage: () => [],
},
chain: {
queryUtxosByAddress: async () => [],
queryUtxo: vi.fn(queryUtxo),
},
registry: {},
fetchFn: vi.fn(async () => ({
ok: true,
json: async () => rows,
})),
} as never;
}
/**
* asyncHandler returns a fire-and-forget (req, res) => void wrapper, so the
* test must await the res.json() call itself, not the handler invocation.
* Errors also surface through json() (sendError maps them to a JSON body).
*/
async function callAccount(deps: never): Promise<Record<string, never>> {
const handler = makeAccountHandler(deps);
const req = { gatewayKey: fakeKeyRow() } as unknown as Request;
const body = await new Promise<unknown>((resolveBody, rejectBody) => {
const timer = setTimeout(() => rejectBody(new Error("handler never responded")), 2_000);
const res = {
status: (_code: number) => res,
json: (b: unknown) => {
clearTimeout(timer);
resolveBody(b);
return res;
},
} as unknown as Response;
handler(req, res);
});
if ((body as { error?: unknown }).error) {
throw new Error(`handler returned error body: ${JSON.stringify(body)}`);
}
return body as Record<string, never>;
}
describe("GET /account — locked_in_escrow_lovelace", () => {
it("counts payment + both bonds for an active escrow whose UTxO is live", async () => {
const body = await callAccount(makeDeps([escrowRow(LIVE_REF, "Open")]));
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
});
it("excludes an active-state row whose UTxO is terminally spent (stale indexer row)", async () => {
const body = await callAccount(
makeDeps([escrowRow(LIVE_REF, "Open"), escrowRow(STALE_REF, "Submitted")]),
);
// Only the live escrow counts: 4_000_000, not 8_000_000.
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
});
it("skips terminal-state rows without a chain lookup", async () => {
const deps = makeDeps([escrowRow(LIVE_REF, "Accepted"), escrowRow(STALE_REF, "Reclaimed")]);
const body = await callAccount(deps);
expect(body.balance.locked_in_escrow_lovelace).toBe("0");
expect((deps as { chain: { queryUtxo: ReturnType<typeof vi.fn> } }).chain.queryUtxo).not.toHaveBeenCalled();
});
it("counts a row conservatively when the chain lookup fails (best-effort)", async () => {
const deps = makeDeps([escrowRow(LIVE_REF, "Open")], {
queryUtxo: async () => {
throw new Error("ogmios unreachable");
},
});
const body = await callAccount(deps);
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
});
});

View File

@@ -0,0 +1,121 @@
/**
* gateway-mutex.test.ts — Mutex deadline + queue bound (2026-08-12 freeze fix).
*
* Incident: one chain call that never settled, run under a per-key Mutex with
* no deadline, convoyed every subsequent request on that key silently while
* /models and /account stayed healthy. The fix gives run() a deadline that
* abandons the stuck operation (advancing the chain) and a queue bound that
* rejects new work instead of queueing without limit.
*
* Serialization semantics of the original class must be preserved.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
Mutex,
MutexTimeoutError,
MutexQueueFullError,
} from "../../gateway/src/sdk/registry.js";
afterEach(() => {
vi.useRealTimers();
});
// ─── Preserved semantics ─────────────────────────────────────────────────────
describe("Mutex — serialization (preserved semantics)", () => {
it("runs fns strictly one at a time, in order", async () => {
const mutex = new Mutex();
const order: number[] = [];
let firstRunning = false;
const first = mutex.run(async () => {
firstRunning = true;
await new Promise((r) => setTimeout(r, 20));
order.push(1);
firstRunning = false;
});
const second = mutex.run(async () => {
expect(firstRunning).toBe(false);
order.push(2);
});
await Promise.all([first, second]);
expect(order).toEqual([1, 2]);
});
it("a rejecting fn does not poison the chain", async () => {
const mutex = new Mutex();
await expect(mutex.run(async () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
await expect(mutex.run(async () => "alive")).resolves.toBe("alive");
});
});
// ─── Deadline (the freeze fix) ───────────────────────────────────────────────
describe("Mutex — per-run deadline", () => {
it("rejects a stuck run with MutexTimeoutError once the deadline passes", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck-chain-read");
// Attach the rejection expectation BEFORE advancing time so the rejection
// is not treated as unhandled.
const assertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
await vi.advanceTimersByTimeAsync(1_001);
await assertion;
});
it("queued work proceeds after a stuck run is abandoned (no convoy)", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck");
const stuckAssertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
const queued = mutex.run(async () => "unblocked");
await vi.advanceTimersByTimeAsync(1_001);
await stuckAssertion;
await expect(queued).resolves.toBe("unblocked");
});
it("a run that finishes in time is unaffected by the deadline", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const quick = mutex.run(async () => "done");
await vi.advanceTimersByTimeAsync(1);
await expect(quick).resolves.toBe("done");
});
});
// ─── Queue bound ─────────────────────────────────────────────────────────────
describe("Mutex — queue bound", () => {
it("rejects immediately with MutexQueueFullError when the queue is full", async () => {
const mutex = new Mutex({ maxQueue: 2 });
let releaseFirst!: () => void;
const gate = new Promise<void>((r) => {
releaseFirst = r;
});
const first = mutex.run(() => gate.then(() => "first"));
const second = mutex.run(async () => "second");
const third = mutex.run(async () => "third");
await expect(third).rejects.toBeInstanceOf(MutexQueueFullError);
releaseFirst();
await expect(first).resolves.toBe("first");
await expect(second).resolves.toBe("second");
});
it("slots free up once runs settle", async () => {
const mutex = new Mutex({ maxQueue: 1 });
await mutex.run(async () => "a");
await expect(mutex.run(async () => "b")).resolves.toBe("b");
});
});

View File

@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import { chatInputTokenUpperBound } from "../../packages/shared/src/tx/inputBound.js";
import { CapacityGate } from "../../supplier/src/reseller/capacityGate.js";
import type {
CapacityProvider,
ProviderCapacityReading,
} from "../../supplier/src/reseller/provider.js";
import { OpenRouterCapacityProvider } from "../../supplier/src/reseller/openRouterCapacity.js";
function reading(remaining: bigint): ProviderCapacityReading {
return {
provider: "openrouter",
keyLimitUsdNanos: 10_000_000_000n,
keyRemainingUsdNanos: remaining,
limitReset: "monthly",
checkedAtMs: 1_000,
pricing: {
model: "provider/model",
promptUsdNanosPerToken: 1_000_000n,
completionUsdNanosPerToken: 2_000_000n,
requestUsdNanos: 0n,
fetchedAtMs: 1_000,
},
};
}
describe("chatInputTokenUpperBound", () => {
it("uses canonical UTF-8 bytes and fixed chat overhead", () => {
expect(chatInputTokenUpperBound([])).toBe(5);
expect(chatInputTokenUpperBound([{ role: "user", content: "a" }])).toBe(42);
expect(chatInputTokenUpperBound([{ role: "user", content: "é" }])).toBe(43);
expect(chatInputTokenUpperBound([{ role: "user", content: "🙂" }])).toBe(45);
});
it("adds each message independently", () => {
expect(chatInputTokenUpperBound([
{ role: "system", content: "x" },
{ role: "user", content: "yz" },
])).toBe(83);
});
it("counts tool-call arguments present on direct SDK messages", () => {
expect(chatInputTokenUpperBound([{
role: "assistant",
content: "",
tool_calls: [{
id: "x",
type: "function",
function: { name: "f", arguments: "a".repeat(1000) },
}],
}])).toBe(1129);
});
});
describe("CapacityGate", () => {
it("opens only when allowance above reserve covers a worst-case job", async () => {
let current = reading(2_000_000_000n);
const provider: CapacityProvider = {
id: "openrouter",
readCapacity: vi.fn(async () => current),
};
const gate = new CapacityGate({
provider,
model: "provider/model",
reserveUsd: "1",
maxInputTokens: 100,
maxOutputTokens: 10,
staleAfterMs: 5_000,
now: () => 1_000,
});
const open = await gate.refresh();
expect(open.canServe).toBe(true);
expect(open.worstCaseJobUsdNanos).toBe(120_000_000n);
expect(open.sellableUsdNanos).toBe(1_000_000_000n);
expect(open.availableJobs).toBe(8n);
current = reading(1_100_000_000n);
const closed = await gate.refresh();
expect(closed.canServe).toBe(false);
expect(closed.reason).toBe("insufficient_for_job");
});
it("fails closed when the provider check fails", async () => {
const provider: CapacityProvider = {
id: "openrouter",
readCapacity: vi.fn(async () => {
throw new Error("provider down");
}),
};
const gate = new CapacityGate({
provider,
model: "provider/model",
reserveUsd: "0",
maxInputTokens: 10,
maxOutputTokens: 10,
staleAfterMs: 5_000,
});
const snapshot = await gate.refresh();
expect(snapshot.canServe).toBe(false);
expect(snapshot.reason).toBe("provider_unavailable");
});
});
describe("OpenRouterCapacityProvider", () => {
it("reads a capped key and rounds model prices up", async () => {
const fetchFn = vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.endsWith("/v1/key")) {
return new Response(JSON.stringify({
data: { limit: 5, limit_remaining: 4.5, limit_reset: "monthly" },
}), { status: 200 });
}
return new Response(JSON.stringify({
data: [{
id: "provider/model",
pricing: { prompt: "0.00000007952", completion: "0.00000015904" },
}],
}), { status: 200 });
});
const provider = new OpenRouterCapacityProvider({
baseUrl: "https://openrouter.ai/api",
apiKey: "secret",
timeoutMs: 1_000,
fetchFn: fetchFn as typeof fetch,
now: () => 123,
});
const result = await provider.readCapacity("provider/model");
expect(result.keyRemainingUsdNanos).toBe(4_499_999_999n);
expect(result.pricing.promptUsdNanosPerToken).toBe(80n);
expect(result.pricing.completionUsdNanosPerToken).toBe(160n);
});
it("rejects an uncapped inference key", async () => {
const fetchFn = vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith("/v1/key")) {
return new Response(JSON.stringify({
data: { limit: null, limit_remaining: null, limit_reset: null },
}), { status: 200 });
}
return new Response(JSON.stringify({ data: [] }), { status: 200 });
});
const provider = new OpenRouterCapacityProvider({
baseUrl: "https://openrouter.ai/api",
apiKey: "secret",
timeoutMs: 1_000,
fetchFn: fetchFn as typeof fetch,
});
await expect(provider.readCapacity("provider/model")).rejects.toThrow(/finite/);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { redactPublicPreview } from "../../supplier/src/reseller/evidenceStore.js";
describe("redactPublicPreview", () => {
it("removes common credentials and personal data before publication", () => {
const preview = redactPublicPreview(
[
"SUPPLIER_PRIV_KEY_HEX=" + "a".repeat(64),
"password=hunter2",
"Authorization: Bearer secret-token",
'{"password":"hunter two words"}',
"GATEWAY_API_KEY=plain-secret-value",
"Authorization: Basic dXNlcjpwYXNz",
"email test@example.com",
"https://example.com/private",
"eyJheader.payload.signature",
"ghp_1234567890abcdefghijklmnop",
].join(" "),
1000,
);
expect(preview).not.toContain("hunter2");
expect(preview).not.toContain("secret-token");
expect(preview).not.toContain("hunter two words");
expect(preview).not.toContain("plain-secret-value");
expect(preview).not.toContain("dXNlcjpwYXNz");
expect(preview).not.toContain("test@example.com");
expect(preview).not.toContain("example.com/private");
expect(preview).not.toContain("eyJheader");
expect(preview).not.toContain("ghp_");
expect(preview).not.toContain("a".repeat(64));
});
it("applies the configured public preview limit", () => {
const preview = redactPublicPreview("x".repeat(100), 20);
expect(preview).toHaveLength(20);
expect(preview.endsWith("…")).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadResellerSetupConfig } from "../../supplier/src/reseller/setupConfig.js";
const tempDirs: string[] = [];
async function configPath(overrides: Record<string, unknown> = {}): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "reseller-config-"));
tempDirs.push(dir);
const path = join(dir, "config.json");
await writeFile(path, JSON.stringify({
networkId: 1,
ogmiosUrl: "https://ogmios.vector.mainnet.apexfusion.org",
endpointUrl: "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
marketplaceModel: "vector/resold-model",
openRouterModel: "provider/model",
maxInputTokens: 4096,
maxOutputTokens: 256,
maxProcessingMs: 600000,
inferenceTimeoutMs: 180000,
priceLovelace: "200000",
supplierBondLovelace: "1000000",
buyerBondLovelace: "1000000",
reserveUsd: "1.00",
composeFile: "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml",
...overrides,
}));
return path;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true })));
});
describe("loadResellerSetupConfig", () => {
it("accepts a bounded mainnet setup", async () => {
const config = await loadResellerSetupConfig(await configPath());
expect(config.inferenceTimeoutMs).toBe(180000);
expect(config.endpointUrl).toBe(
"https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
);
});
it("rejects unsupported Ogmios transports", async () => {
await expect(loadResellerSetupConfig(await configPath({
ogmiosUrl: "wss://ogmios.example",
}))).rejects.toThrow(/ogmiosUrl/);
});
it("rejects endpoint credentials, query data, and fragments", async () => {
for (const endpointUrl of [
"https://user:secret@example.com",
"https://example.com?token=secret",
"https://example.com#secret",
]) {
await expect(loadResellerSetupConfig(await configPath({ endpointUrl })))
.rejects.toThrow(/plain HTTPS origin/);
}
});
it("requires settlement time outside the inference timeout", async () => {
await expect(loadResellerSetupConfig(await configPath({
inferenceTimeoutMs: 500000,
}))).rejects.toThrow(/does not leave time/);
});
});

View File

@@ -37,7 +37,7 @@ function chatSessionConfig(): SupplierConfig {
} as SupplierConfig;
}
function makeAppWithSession() {
function makeAppWithSession(configOverrides?: Partial<SupplierConfig>) {
const chatSessions = new ChatSessionStore();
const record = chatSessions.create({
escrowRef: ESCROW_REF,
@@ -48,7 +48,7 @@ function makeAppWithSession() {
const app = createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: chatSessionConfig(),
config: { ...chatSessionConfig(), ...configOverrides } as SupplierConfig,
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
chatSessions,
@@ -91,6 +91,54 @@ describe("ChatSessionStore.truncateTranscript", () => {
});
});
describe("POST /v1/chat/message — stateful upstream mode (OPENAI_SESSION_PASSTHROUGH)", () => {
it("sends delta-only messages + user=escrowRef + overridden model; transcript still accumulates fully", async () => {
const { app, record } = makeAppWithSession({
openaiSessionPassthrough: true,
openaiModelOverride: "openclaw",
});
const okFetch = () =>
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
const fetch1 = okFetch();
vi.stubGlobal("fetch", fetch1);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
const fetch2 = okFetch();
vi.stubGlobal("fetch", fetch2);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "second" });
// Turn 2 upstream call: only the new delta, keyed to the escrow ref, with
// the fixed upstream model — NOT the accumulated transcript / advert model.
const body2 = JSON.parse(fetch2.mock.calls[0][1].body as string);
expect(body2.messages).toEqual([{ role: "user", content: "second" }]);
expect(body2.user).toBe(ESCROW_REF);
expect(body2.model).toBe("openclaw");
// The local transcript (receipt source of truth) still has every turn.
expect(record.transcript).toEqual([
{ role: "user", content: "first" },
{ role: "assistant", content: "Hello" },
{ role: "user", content: "second" },
{ role: "assistant", content: "Hello" },
]);
});
it("keeps full-transcript + advert-model behavior when passthrough is off", async () => {
const { app } = makeAppWithSession();
const fetch1 = vi
.fn()
.mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
vi.stubGlobal("fetch", fetch1);
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
const body = JSON.parse(fetch1.mock.calls[0][1].body as string);
expect(body.messages).toEqual([{ role: "user", content: "first" }]);
expect("user" in body).toBe(false);
expect(body.model).toBe("kimi");
});
});
describe("POST /v1/chat/message — failed-turn rollback", () => {
it("truncates the delta on backend failure, then a retry appends exactly once", async () => {
const { app, record } = makeAppWithSession();

View File

@@ -294,3 +294,99 @@ describe("loadConfig() — MAX_CHAT_SESSIONS / CHAT_SETTLE_MODE", () => {
expect(() => loadConfig({ ...chatSessionEnv(), CHAT_SETTLE_MODE: "nope" })).toThrow();
});
});
// ─── loadConfig — OPENAI_SESSION_PASSTHROUGH ─────────────────────────────────
describe("loadConfig() — OPENAI_SESSION_PASSTHROUGH", () => {
const chatSessionEnv = () => ({
...buildSampleEnv(),
CAPABILITY_KIND: "chat-session",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: "http://up",
});
it("defaults off", () => {
expect(loadConfig(chatSessionEnv()).openaiSessionPassthrough).toBe(false);
});
it('parses "1" for chat-session kind', () => {
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "1" });
expect(cfg.openaiSessionPassthrough).toBe(true);
});
it('treats values other than "1" as off (mirrors LIVE_CHAIN strictness)', () => {
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "true" });
expect(cfg.openaiSessionPassthrough).toBe(false);
});
it("rejects it for non-chat-session kinds", () => {
expect(() => loadConfig({ ...buildSampleEnv(), OPENAI_SESSION_PASSTHROUGH: "1" })).toThrow(
/chat-session/,
);
});
it("parses OPENAI_MODEL_OVERRIDE (default empty = advert model verbatim)", () => {
expect(loadConfig(chatSessionEnv()).openaiModelOverride).toBe("");
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_MODEL_OVERRIDE: "openclaw" });
expect(cfg.openaiModelOverride).toBe("openclaw");
});
});
describe("loadConfig() — OpenRouter reseller", () => {
const resellerEnv = () => ({
...buildSampleEnv(),
CAPABILITY_KIND: "chat",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: "https://openrouter.ai/api",
OPENAI_API_KEY: "seller-only-key",
OPENAI_MAX_TOKENS: "256",
RESELLER_PROVIDER: "openrouter",
RESELLER_DATABASE_URL: "postgresql://reseller:secret@postgres:5432/reseller",
RESELLER_RESERVE_USD: "1.25",
RESELLER_MAX_INPUT_TOKENS: "4096",
});
it("stays disabled for every legacy config", () => {
expect(loadConfig(buildSampleEnv()).reseller).toBeNull();
});
it("parses a complete balance-aware config", () => {
const cfg = loadConfig(resellerEnv());
expect(cfg.reseller).toMatchObject({
provider: "openrouter",
reserveUsd: "1.25",
maxInputTokens: 4096,
pollIntervalMs: 30_000,
providerTimeoutMs: 10_000,
});
});
it("rejects partial and unsafe configurations", () => {
expect(() => loadConfig({
...buildSampleEnv(),
RESELLER_RESERVE_USD: "1",
})).toThrow(/RESELLER_PROVIDER/);
expect(() => loadConfig({
...resellerEnv(),
OPENAI_BASE_URL: "https://example.com",
})).toThrow(/OPENAI_BASE_URL/);
for (const OPENAI_BASE_URL of [
"http://openrouter.ai/api",
"https://openrouter.ai/v1",
"https://user:secret@openrouter.ai/api",
]) {
expect(() => loadConfig({
...resellerEnv(),
OPENAI_BASE_URL,
})).toThrow(/OPENAI_BASE_URL/);
}
expect(() => loadConfig({
...resellerEnv(),
OPENAI_MAX_TOKENS: "",
})).toThrow(/OPENAI_MAX_TOKENS/);
expect(() => loadConfig({
...resellerEnv(),
RESELLER_RESERVE_USD: "-1",
})).toThrow(/RESELLER_RESERVE_USD/);
});
});

View File

@@ -52,6 +52,22 @@ describe("callOpenAiStream — tools passthrough", () => {
expect("tool_choice" in body).toBe(false);
});
it("includes user in the payload when provided (session passthrough)", async () => {
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
vi.stubGlobal("fetch", fetchFn);
await callOpenAiStream({ ...BASE, user: `${"f".repeat(64)}#0` }, () => {});
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
expect(body.user).toBe(`${"f".repeat(64)}#0`);
});
it("omits user from the payload when not provided", async () => {
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
vi.stubGlobal("fetch", fetchFn);
await callOpenAiStream(BASE, () => {});
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
expect("user" in body).toBe(false);
});
it("accumulates fragmented delta.tool_calls across frames", async () => {
const frames = sse([
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "" } }] }, finish_reason: null }] },

View File

@@ -103,6 +103,20 @@ describe("callOpenAi() — request shape", () => {
expect(body.stream).toBe(false);
});
it("includes user in the payload when provided (session passthrough)", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS, user: `${"f".repeat(64)}#0` });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.user).toBe(`${"f".repeat(64)}#0`);
});
it("omits user from the payload when not provided", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect("user" in body).toBe(false);
});
it("omits authorization header when apiKey is not provided (ChatMock path)", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];

View File

@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import request from "supertest";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { createApp } from "../../supplier/src/server.js";
import { SupplierState } from "../../supplier/src/state.js";
import type { ResellerRuntime } from "../../supplier/src/reseller/runtime.js";
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const ESCROW_REF = `${"f".repeat(64)}#0`;
function fakeReseller(canServe: boolean, maxInputTokens = 100): ResellerRuntime {
const snapshot = {
provider: "openrouter",
model: "provider/model",
canServe,
reason: canServe ? "ready" as const : "insufficient_for_job" as const,
checkedAtMs: 1_000,
lastSuccessAtMs: 1_000,
keyLimitUsdNanos: 5_000_000_000n,
remainingAllowanceUsdNanos: 2_000_000_000n,
reserveUsdNanos: 1_000_000_000n,
sellableUsdNanos: 1_000_000_000n,
worstCaseJobUsdNanos: 100_000_000n,
promptPriceUsdNanosPerToken: 1n,
completionPriceUsdNanosPerToken: 1n,
requestPriceUsdNanos: 0n,
availableJobs: 10n,
limitReset: "monthly",
error: null,
};
return {
maxInputTokens,
effectiveStatus: vi.fn(() => canServe ? "free" : "offline"),
capacitySnapshot: vi.fn(() => snapshot),
refreshCapacity: vi.fn(async () => snapshot),
} as unknown as ResellerRuntime;
}
describe("reseller supplier routes", () => {
it("reports offline without retiring an exhausted supplier", async () => {
const app = createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: buildSampleConfig(),
supplierKey: buildSupplierWalletKey(),
reseller: fakeReseller(false),
});
const response = await request(app).get("/status");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
status: "offline",
capacity: {
provider: "openrouter",
reason: "insufficient_for_job",
},
});
});
it("rejects oversized input before any chain submission", async () => {
const chain = new MockChainProvider();
const submitSpy = vi.spyOn(chain, "submitTx");
const app = createApp({
chain,
state: new SupplierState(),
config: buildSampleConfig(),
supplierKey: buildSupplierWalletKey(),
reseller: fakeReseller(true, 1),
});
const response = await request(app)
.post("/v1/chat/completions")
.set("X-Escrow-Ref", ESCROW_REF)
.send({ messages: [{ role: "user", content: "too large" }] });
expect(response.status).toBe(413);
expect(response.body.reason).toBe("input_cap_exceeded");
expect(submitSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,466 @@
import { describe, expect, it, vi } from "vitest";
import { SurplusClient } from "../../supplier/src/surplus/client.js";
const SELLER_KEY = "si_seller_test_only";
const PROVIDER_KEY = "sk-or-v1-test-only";
function ndjsonResponse(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "content-type": "application/x-ndjson" },
});
}
function discoveryRow(model: string, providerModelId: string): string {
return JSON.stringify({
model,
supported: true,
pricing: {
input_per_1m: 0.07952,
output_per_1m: 0.15904,
price_unit: "M",
price_variable: false,
},
metadata: {
provider_model_id: providerModelId,
model_type: "text",
availability_status: "available",
},
});
}
function saleRow(
id: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
id,
model: "alpha",
offer_id: "offer-1",
settlement_status: "confirmed",
created_at: 1_787_831_146_968,
seller_cost_usdc: "83",
input_tokens: 10,
output_tokens: 2,
cache_read_tokens: 4,
effective_input_per_1m: 0.004,
effective_output_per_1m: 0.008,
tx_hash: null,
...overrides,
};
}
function earningsEnvelope(
recentSales: Record<string, unknown>[],
): Record<string, unknown> {
return {
total_earned_usdc: "166",
pending_usdc: "166",
paid_usdc: "0",
daily: [],
by_model: [],
share: { requests: recentSales.length, tokens: 0, top_model: "alpha" },
recent_sales: recentSales,
payout_hold: null,
};
}
describe("SurplusClient", () => {
it("authenticates and parses fragmented NDJSON discovery", async () => {
const first = discoveryRow("alpha", "vendor/alpha");
const second = discoveryRow("beta", "vendor/beta");
const fetchFn = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe(`Bearer ${SELLER_KEY}`);
expect(JSON.parse(String(init?.body))).toEqual({
api_key: PROVIDER_KEY,
base_url: "https://openrouter.ai/api/v1",
});
return ndjsonResponse([
first.slice(0, 25),
`${first.slice(25)}\r\n\r\n${second.slice(0, 11)}`,
second.slice(11),
]);
});
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn,
});
const result = await client.discoverModels(
PROVIDER_KEY,
"https://openrouter.ai/api/v1",
);
expect(result.map((model) => [model.model, model.providerModelId])).toEqual([
["alpha", "vendor/alpha"],
["beta", "vendor/beta"],
]);
});
it("normalizes unsupported discovery rows with null pricing as ineligible", async () => {
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () => ndjsonResponse([JSON.stringify({
model: "unsupported",
supported: false,
pricing: null,
metadata: {
provider_model_id: "vendor/unsupported",
model_type: null,
availability_status: null,
},
})]),
});
await expect(
client.discoverModels(PROVIDER_KEY, "https://openrouter.ai/api/v1"),
).resolves.toEqual([expect.objectContaining({
model: "unsupported",
inputUsdPer1m: 0,
outputUsdPer1m: 0,
priceUnit: "unknown",
modelType: "unknown",
availabilityStatus: "unknown",
})]);
});
it("rejects one malformed discovery line without returning a partial catalog", async () => {
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () => ndjsonResponse([
`${discoveryRow("alpha", "vendor/alpha")}\n`,
'{"model":"truncated"',
]),
});
await expect(client.discoverModels(PROVIDER_KEY, "https://openrouter.ai/api/v1"))
.rejects.toThrow("line 2 was not complete JSON");
});
it("follows every seller-offer page and normalizes micro-USD prices", async () => {
const requested: string[] = [];
const fetchFn = vi.fn(async (url: string | URL | Request) => {
const value = String(url);
requested.push(value);
if (value.includes("next_token=page-2")) {
return Response.json({
items: [{
offer_id: "offer-2",
model: "beta",
seller_base_url: "https://openrouter.ai/api/v1",
status: "inactive",
cap_daily_usd: 1,
price_input_per_1m: 4_100,
price_output_per_1m: 8_200,
}],
next_token: null,
});
}
return Response.json({
items: [{
id: "offer-1",
model: "alpha",
seller_base_url: "https://openrouter.ai/api/v1",
status: "active",
cap_daily_usd: 1,
price_input_per_1m: 3_972,
price_output_per_1m: 7_944,
}],
next_token: "page-2",
});
});
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn,
});
const result = await client.listAllOffers();
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
id: "offer-1",
status: "active",
inputMicroUsdPer1m: 3_972,
outputMicroUsdPer1m: 7_944,
});
expect(requested[1]).toContain("next_token=page-2");
});
it("fails closed when offer pagination repeats a token", async () => {
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () => Response.json({ items: [], next_token: "same" }),
});
await expect(client.listAllOffers()).rejects.toThrow("repeated a next_token");
});
it("normalizes live market envelopes and effective micro-USD fields", async () => {
const fetchFn = vi.fn(async (url: string | URL | Request) => {
if (String(url).endsWith("/api/markets")) {
return Response.json({ markets: [{
model: "alpha:model",
requests_24h: 100,
volume_24h: 1_000,
best_input_per_1m: 3_976,
best_output_per_1m: 7_952,
healthy_seller_count: 2,
}] });
}
return Response.json({
model: "alpha:model",
offers: [{
id: "external",
seller: "0x2222222222222222222222222222222222222222",
seller_base_url: "https://openrouter.ai/api/v1",
effective_input_per_1m: 3_976,
effective_output_per_1m: 7_952,
available: true,
healthy: true,
trusted: true,
trades_24h: 7,
rank: 1,
cap_remaining: null,
volume_24h: 0,
}],
});
});
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn,
});
expect(await client.getMarkets()).toEqual([expect.objectContaining({
model: "alpha:model",
bestInputMicroUsdPer1m: 3_976,
})]);
expect(await client.getOrderBook("alpha:model")).toEqual({
model: "alpha:model",
offers: [expect.objectContaining({ id: "external", trades24h: 7 })],
});
expect(String(fetchFn.mock.calls[1][0])).toContain("alpha%3Amodel");
});
it("normalizes live earnings, payout hold, and settlement records", async () => {
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () => Response.json({
total_earned_usdc: "186810",
pending_usdc: "176817",
paid_usdc: "9993",
daily: [{
day: "2026-08-27",
earned_usd: 0.18681,
input_tokens: 100,
output_tokens: 20,
requests: 3,
total_tokens: 120,
}],
by_model: [{
model: "alpha",
earned_usd: 0.18681,
requests: 3,
total_tokens: 120,
last_sale_at: 1787831146968,
}],
share: {
requests: 3,
tokens: 120,
top_model: "alpha",
},
recent_sales: [{
id: "sale-1",
model: "alpha",
offer_id: "offer-1",
settlement_status: "confirmed",
created_at: 1787831146968,
seller_cost_usdc: "83",
input_tokens: 10,
output_tokens: 2,
cache_read_tokens: 4,
effective_input_per_1m: 0.004,
effective_output_per_1m: 0.008,
tx_hash: "0x" + "a".repeat(64),
}],
payout_hold: {
reason: "new_seller",
releases_at: "2026-08-29T11:24:42.552Z",
},
}),
});
const earnings = await client.getEarnings();
expect(earnings).toMatchObject({
totalEarnedMicroUsd: 186_810,
pendingMicroUsd: 176_817,
paidMicroUsd: 9_993,
requestCount: 3,
tokenCount: 120,
topModel: "alpha",
payoutHoldReason: "new_seller",
});
expect(earnings.daily[0]).toMatchObject({ requests: 3, totalTokens: 120 });
expect(earnings.recentSales[0]).toMatchObject({
id: "sale-1",
settlementStatus: "confirmed",
cacheReadTokens: 4,
sellerCostMicroUsd: 83,
});
expect(earnings.recentSales[0].createdAt).toBe(
new Date(1787831146968).toISOString(),
);
expect(earnings.recentSales[0].createdAtMs).toBe(1787831146968);
});
it("defaults omitted cache_read_tokens to zero as the live API sends", async () => {
const sparse = saleRow("sale-sparse");
delete sparse.cache_read_tokens;
delete sparse.tx_hash;
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: "si_seller_test",
timeoutMs: 1_000,
fetchFn: async () => Response.json(earningsEnvelope([sparse])),
});
const earnings = await client.getEarnings();
expect(earnings.recentSales[0]).toMatchObject({
id: "sale-sparse",
cacheReadTokens: 0,
transactionHash: null,
});
});
it("returns the deduplicated observed sale window in deterministic sale-id order", async () => {
const duplicate = saleRow("sale-a", {
buyer_address: "must-not-be-retained",
});
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () =>
Response.json(earningsEnvelope([
saleRow("sale-b"),
duplicate,
{ ...duplicate },
])),
});
const sales = await client.listObservedSales();
expect(sales.map((sale) => sale.id)).toEqual(["sale-a", "sale-b"]);
expect(sales[0]).not.toHaveProperty("buyerAddress");
expect(sales[0]).not.toHaveProperty("buyer_address");
});
it("fails closed on conflicting immutable fields in the observed sale window", async () => {
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () =>
Response.json(earningsEnvelope([
saleRow("sale-a"),
saleRow("sale-a", { input_tokens: 11 }),
])),
});
await expect(client.listObservedSales()).rejects.toThrow(
"conflicting immutable fields for sale sale-a",
);
});
it("requires every immutable proof field in observed sales", async () => {
const requiredFields = [
"id",
"model",
"offer_id",
"created_at",
"seller_cost_usdc",
"input_tokens",
"output_tokens",
"effective_input_per_1m",
"effective_output_per_1m",
];
for (const field of requiredFields) {
const row = saleRow("sale-a");
delete row[field];
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async () => Response.json(earningsEnvelope([row])),
});
await expect(client.listObservedSales()).rejects.toThrow();
}
});
it("fails clearly instead of claiming unavailable historical completeness", async () => {
const fetchFn = vi.fn();
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn,
});
await expect(client.listAllSales()).rejects.toThrow(
"does not provide historical sale pagination",
);
expect(fetchFn).not.toHaveBeenCalled();
});
it("sends one absolute cost-multiplier PATCH with the cap", async () => {
let request: RequestInit | undefined;
const client = new SurplusClient({
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: SELLER_KEY,
timeoutMs: 1_000,
fetchFn: async (_url, init) => {
request = init;
return Response.json({ ok: true });
},
});
await client.updateOffer("offer/one", {
costMultiplier: 0.04995,
dailyCapUsd: 1,
idempotencyKey: "mutation-1",
});
expect(request?.method).toBe("PATCH");
expect(JSON.parse(String(request?.body))).toEqual({
pricing_mode: "cost_multiplier",
cost_multiplier: 0.04995,
cap_daily_usd: 1,
});
const headers = new Headers(request?.headers);
expect(headers.get("idempotency-key")).toBe("mutation-1");
});
});

View File

@@ -0,0 +1,397 @@
import { describe, expect, it } from "vitest";
import type { SurplusManagerConfig } from "../../supplier/src/surplus/config.js";
import {
SurplusSellerController,
type SurplusControllerClient,
} from "../../supplier/src/surplus/controller.js";
import {
SurplusHttpError,
type SurplusDiscoveredModel,
type SurplusOffer,
type SurplusOfferPatch,
type SurplusOfferWrite,
} from "../../supplier/src/surplus/client.js";
import type { OpenRouterKeyAllowance } from "../../supplier/src/surplus/openRouterKey.js";
import type {
SurplusControllerState,
SurplusStateStore,
} from "../../supplier/src/surplus/state.js";
const SELLER_WALLET = "0x1111111111111111111111111111111111111111";
function config(): SurplusManagerConfig {
return {
live: true,
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: "si_seller_test",
providerApiKey: "sk-or-v1-test",
providerBaseUrl: "https://openrouter.ai/api/v1",
capacityBaseUrl: "https://openrouter.ai/api",
perOfferCapUsd: "0.05",
aggregateCapUsd: "0.05",
sellerWallet: SELLER_WALLET,
payoutAddress: SELLER_WALLET,
maxCandidateOrderBooks: 10,
reserveUsd: "1.00",
maxProviderLimitUsd: "20.00",
recoveryBps: 490,
undercutBps: 10,
pollIntervalMs: 300_000,
requestTimeoutMs: 120_000,
stopAfterTrades: 1,
settledStatuses: ["confirmed"],
statePath: "/tmp/surplus-test-state.json",
port: 8080,
};
}
class MemoryStateStore implements SurplusStateStore {
state: SurplusControllerState = { version: 1, phase: "selecting" };
async load(): Promise<SurplusControllerState> {
return structuredClone(this.state);
}
async save(state: SurplusControllerState): Promise<void> {
this.state = structuredClone(state);
}
}
class FakeSurplusClient implements SurplusControllerClient {
offer: SurplusOffer | null = null;
trades24h = 0;
settled = false;
createCalls = 0;
pauseCalls = 0;
externalCalls = 0;
createError: Error | null = null;
discoveryError: Error | null = null;
createdPayload: SurplusOfferWrite | null = null;
patchPayload: SurplusOfferPatch | null = null;
async listAllOffers(): Promise<SurplusOffer[]> {
this.externalCalls += 1;
return this.offer ? [{ ...this.offer }] : [];
}
async discoverModels(): Promise<SurplusDiscoveredModel[]> {
this.externalCalls += 1;
if (this.discoveryError) throw this.discoveryError;
return [{
model: "beta-model",
providerModelId: "vendor/beta-model",
supported: true,
inputUsdPer1m: 0.07952,
outputUsdPer1m: 0.15904,
priceUnit: "M",
priceVariable: false,
modelType: "text",
availabilityStatus: "available",
}];
}
async getMarkets() {
this.externalCalls += 1;
return [{
model: "beta-model",
requests24h: 100,
volume24h: 1_000,
bestInputMicroUsdPer1m: 3_976,
bestOutputMicroUsdPer1m: 7_952,
healthySellerCount: 1,
}];
}
async getOrderBook(model: string) {
this.externalCalls += 1;
const offers = [{
id: "external",
seller: "0x2222222222222222222222222222222222222222",
sellerBaseUrl: "https://openrouter.ai/api/v1",
inputMicroUsdPer1m: 3_976,
outputMicroUsdPer1m: 7_952,
available: true,
healthy: true,
trusted: true,
trades24h: 0,
rank: 1,
capRemainingMicroUsd: null,
volume24hMicroUsd: 0,
}];
if (this.offer) {
offers.push({
id: this.offer.id,
seller: SELLER_WALLET,
sellerBaseUrl: this.offer.sellerBaseUrl,
inputMicroUsdPer1m: this.offer.inputMicroUsdPer1m ?? 3_972,
outputMicroUsdPer1m: this.offer.outputMicroUsdPer1m ?? 7_944,
available: this.offer.status === "active",
healthy: true,
trusted: true,
trades24h: this.trades24h,
rank: 2,
capRemainingMicroUsd: 50_000,
volume24hMicroUsd: 0,
});
}
return { model, offers };
}
async testConnection(): Promise<number> {
this.externalCalls += 1;
return 10;
}
async createOffer(input: SurplusOfferWrite): Promise<string> {
this.externalCalls += 1;
this.createCalls += 1;
this.createdPayload = input;
if (this.createError) throw this.createError;
this.offer = {
id: "managed-offer",
model: input.model,
sellerBaseUrl: input.sellerBaseUrl,
status: "active",
capDailyUsd: input.dailyCapUsd,
costMultiplierPpm: Math.round(input.costMultiplier * 1_000_000),
inputMicroUsdPer1m: Math.floor(79_520 * input.costMultiplier),
outputMicroUsdPer1m: Math.floor(159_040 * input.costMultiplier),
};
return this.offer.id;
}
async updateOffer(_id: string, patch: SurplusOfferPatch): Promise<void> {
this.externalCalls += 1;
this.patchPayload = patch;
if (!this.offer) throw new Error("missing fake offer");
this.offer.costMultiplierPpm = Math.round(
patch.costMultiplier * 1_000_000,
);
this.offer.inputMicroUsdPer1m = Math.floor(
79_520 * patch.costMultiplier,
);
this.offer.outputMicroUsdPer1m = Math.floor(
159_040 * patch.costMultiplier,
);
this.offer.capDailyUsd = patch.dailyCapUsd;
}
async pauseOffer(): Promise<void> {
this.externalCalls += 1;
this.pauseCalls += 1;
if (this.offer) this.offer.status = "inactive";
}
async resumeOffer(): Promise<void> {
this.externalCalls += 1;
if (this.offer) this.offer.status = "active";
}
async getEarnings() {
this.externalCalls += 1;
return {
totalEarnedMicroUsd: this.settled ? 100 : 0,
pendingMicroUsd: 0,
paidMicroUsd: this.settled ? 100 : 0,
daily: [],
byModel: [],
requestCount: this.settled ? 1 : 0,
tokenCount: this.settled ? 2 : 0,
topModel: this.settled ? "beta-model" : null,
payoutHoldReason: null,
payoutHoldReleasesAt: null,
recentSales: this.settled ? [{
id: "sale-1",
model: "beta-model",
offerId: "managed-offer",
settlementStatus: "confirmed",
createdAt: "2026-08-27T10:00:01.000Z",
sellerCostMicroUsd: 100,
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
effectiveInputUsdPer1m: 0.003972,
effectiveOutputUsdPer1m: 0.007944,
transactionHash: "0x" + "a".repeat(64),
}] : [],
};
}
}
const ALLOWANCE: OpenRouterKeyAllowance = {
limitUsdNanos: 20_000_000_000n,
remainingUsdNanos: 20_000_000_000n,
reset: null,
checkedAtMs: Date.parse("2026-08-27T10:00:00.000Z"),
};
function controller(
client: FakeSurplusClient,
store: MemoryStateStore,
allowance: { readAllowance(): Promise<OpenRouterKeyAllowance> } = {
readAllowance: async () => ALLOWANCE,
},
managerConfig: SurplusManagerConfig = config(),
): SurplusSellerController {
let mutation = 0;
return new SurplusSellerController({
config: managerConfig,
client,
allowance,
stateStore: store,
now: () => Date.parse("2026-08-27T10:00:00.000Z"),
mutationId: () => `mutation-${++mutation}`,
});
}
describe("SurplusSellerController", () => {
it("selects a discovered model, creates one bounded offer, and stops after settlement", async () => {
const client = new FakeSurplusClient();
const store = new MemoryStateStore();
const activeController = controller(client, store);
await activeController.runOnce();
expect(client.createCalls).toBe(1);
expect(client.createdPayload).toMatchObject({
model: "beta-model",
costMultiplier: 0.04995,
dailyCapUsd: 0.05,
payoutAddress: SELLER_WALLET,
});
expect(store.state.phase).toBe("active");
expect(activeController.snapshot()).toMatchObject({
ok: true,
phase: "active",
model: "beta-model",
providerModelId: "vendor/beta-model",
});
client.trades24h = 1;
client.settled = true;
await activeController.runOnce();
expect(client.pauseCalls).toBeGreaterThan(0);
expect(client.offer?.status).toBe("inactive");
expect(store.state.phase).toBe("completed");
expect(activeController.snapshot()).toMatchObject({ ok: true, phase: "completed" });
const callsBeforeRestart = client.externalCalls;
const restarted = controller(client, store);
await restarted.start();
expect(client.externalCalls).toBe(callsBeforeRestart);
expect(restarted.snapshot()).toMatchObject({ ok: true, phase: "completed" });
await restarted.stop();
});
it("pauses when provider spend reaches the per-offer cap", async () => {
const client = new FakeSurplusClient();
const store = new MemoryStateStore();
let remaining = 20_000_000_000n;
const activeController = controller(client, store, {
readAllowance: async () => ({
...ALLOWANCE,
remainingUsdNanos: remaining,
}),
});
await activeController.runOnce();
expect(client.offer?.status).toBe("active");
remaining = 18_700_000_000n;
await activeController.runOnce();
expect(client.offer?.status).toBe("inactive");
expect(store.state.phase).toBe("awaiting_settlement");
});
it("keeps selling in continuous mode after trades and provider spend", async () => {
const client = new FakeSurplusClient();
const store = new MemoryStateStore();
let remaining = 20_000_000_000n;
const activeController = controller(
client,
store,
{
readAllowance: async () => ({
...ALLOWANCE,
remainingUsdNanos: remaining,
}),
},
{ ...config(), stopAfterTrades: 0 },
);
await activeController.runOnce();
client.trades24h = 1;
remaining = 18_700_000_000n;
await activeController.runOnce();
expect(client.offer?.status).toBe("active");
expect(store.state.phase).toBe("active");
});
it("adopts and resumes an inactive offer after an ambiguous create", async () => {
const client = new FakeSurplusClient();
client.offer = {
id: "managed-offer",
model: "beta-model",
sellerBaseUrl: "https://openrouter.ai/api/v1",
status: "inactive",
capDailyUsd: 0.05,
costMultiplierPpm: 49_950,
inputMicroUsdPer1m: 3_972,
outputMicroUsdPer1m: 7_944,
};
const store = new MemoryStateStore();
store.state = {
version: 1,
phase: "create_pending",
intent: {
model: "beta-model",
providerModelId: "vendor/beta-model",
costMultiplierPpm: 49_950,
inputMicroUsdPer1m: 3_972,
outputMicroUsdPer1m: 7_944,
dailyCapUsd: 0.05,
baselineRemainingUsdNanos: "20000000000",
idempotencyKey: "create-1",
createdAt: "2026-08-27T10:00:00.000Z",
baselineTrades24h: 0,
},
};
await controller(client, store).runOnce();
expect(client.createCalls).toBe(0);
expect(client.offer.status).toBe("active");
expect(store.state.phase).toBe("active");
});
it("never retries an ambiguous create when no matching offer is visible", async () => {
const client = new FakeSurplusClient();
client.createError = new SurplusHttpError("network lost", null);
const store = new MemoryStateStore();
const activeController = controller(client, store);
await activeController.runOnce();
expect(client.createCalls).toBe(1);
expect(store.state.phase).toBe("create_pending");
await activeController.runOnce();
expect(client.createCalls).toBe(1);
expect(activeController.snapshot()).toMatchObject({ ok: false, phase: "error" });
});
it("pauses the managed offer when a required pricing snapshot fails", async () => {
const client = new FakeSurplusClient();
const store = new MemoryStateStore();
const activeController = controller(client, store);
await activeController.runOnce();
expect(client.offer?.status).toBe("active");
client.discoveryError = new Error("discovery unavailable");
await activeController.runOnce();
expect(client.offer?.status).toBe("inactive");
expect(activeController.snapshot()).toMatchObject({ ok: false, phase: "error" });
});
});

View File

@@ -0,0 +1,241 @@
import { describe, expect, it, vi } from "vitest";
import request from "supertest";
import type { SurplusManagerConfig } from "../../supplier/src/surplus/config.js";
import {
SurplusDashboardService,
type SurplusDashboardSnapshot,
} from "../../supplier/src/surplus/dashboard.js";
import { createSurplusServer } from "../../supplier/src/cli/surplus-seller.js";
import type { SurplusControllerStatus } from "../../supplier/src/surplus/controller.js";
const STATUS: SurplusControllerStatus = {
ok: true,
phase: "active",
live: true,
model: "deepseek-v4-flash",
providerModelId: "deepseek/deepseek-v4-flash",
offerId: "offer-1",
inputUsdPer1m: "0.003972",
outputUsdPer1m: "0.007944",
competitorId: "external",
remainingAllowanceUsd: "16.313443",
reserveThresholdUsd: "1.00",
totalEarnedUsd: "0.186810",
lastCycleAt: "2026-08-28T12:00:00.000Z",
lastSuccessAt: "2026-08-28T12:00:00.000Z",
lastError: null,
};
function config(): SurplusManagerConfig {
return {
live: true,
apiBaseUrl: "https://api.surplusintelligence.ai",
sellerApiKey: "si_seller_test_secret",
providerApiKey: "sk-or-v1-test-secret",
providerBaseUrl: "https://openrouter.ai/api/v1",
capacityBaseUrl: "https://openrouter.ai/api",
perOfferCapUsd: "0.05",
aggregateCapUsd: "0.05",
sellerWallet: "0x1111111111111111111111111111111111111111",
payoutAddress: "0x1111111111111111111111111111111111111111",
maxCandidateOrderBooks: 10,
reserveUsd: "1.00",
maxProviderLimitUsd: "20.00",
recoveryBps: 490,
undercutBps: 10,
pollIntervalMs: 300_000,
requestTimeoutMs: 120_000,
stopAfterTrades: 0,
settledStatuses: ["confirmed"],
statePath: "/tmp/surplus-dashboard-test.json",
port: 8080,
};
}
function snapshot(): SurplusDashboardSnapshot {
return {
generatedAt: "2026-08-28T12:00:00.000Z",
controller: STATUS,
identity: {
sellerWallet: "0x1111111111111111111111111111111111111111",
payoutAddress: "0x1111111111111111111111111111111111111111",
},
provider: {
name: "OpenRouter",
hardLimitUsd: "20.000000",
remainingUsd: "16.313443",
usedUsd: "3.686557",
reserveUsd: "1.00",
estimatedDailyExposureUsd: "1.020408",
},
surplus: {
offer: {
id: "offer-1",
model: "deepseek-v4-flash",
providerModel: "deepseek/deepseek-v4-flash",
status: "active",
dailyCapUsd: "0.050000",
costMultiplier: 0.04995,
inputUsdPer1m: "0.003972",
outputUsdPer1m: "0.007944",
rank: 10,
available: true,
healthy: true,
trusted: true,
trades24h: 429,
volume24hUsd: "0.064030",
capRemainingUsd: "0.050000",
},
earnings: {
totalUsd: "0.186810",
pendingUsd: "0.176817",
paidUsd: "0.009993",
requests: 1_093,
tokens: 104_698_905,
today: null,
topModel: "deepseek-v4-flash",
payoutHoldReason: "new_seller",
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
},
recentSales: [],
},
vector: {
status: "retired",
model: "deepseek/deepseek-v4-flash",
supplierWallet: "addr1test",
advertRef: `${"a".repeat(64)}#0`,
retirementTransaction: "b".repeat(64),
retiredOn: "2026-08-27",
historicalAp3xEarned: "1.600000",
historicalSettledJobs: 8,
historicalUpstreamSpendUsd: "0.000093",
},
};
}
describe("SurplusDashboardService", () => {
it("combines safe live metrics with the Vector migration record", async () => {
const listAllOffers = vi.fn(async () => [{
id: "offer-1",
model: "deepseek-v4-flash",
sellerBaseUrl: "https://openrouter.ai/api/v1",
status: "active" as const,
capDailyUsd: 0.05,
costMultiplierPpm: 49_950,
inputMicroUsdPer1m: 3_972,
outputMicroUsdPer1m: 7_944,
}]);
const getEarnings = vi.fn(async () => ({
totalEarnedMicroUsd: 186_810,
pendingMicroUsd: 176_817,
paidMicroUsd: 9_993,
daily: [{
day: "2026-08-28",
earnedUsd: 0.01,
inputTokens: 10,
outputTokens: 2,
requests: 1,
totalTokens: 12,
}],
byModel: [],
requestCount: 1_093,
tokenCount: 104_698_905,
topModel: "deepseek-v4-flash",
payoutHoldReason: "new_seller",
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
recentSales: [{
id: "sale-1",
model: "deepseek-v4-flash",
offerId: "offer-1",
settlementStatus: "confirmed",
createdAt: "2026-08-28T11:00:00.000Z",
sellerCostMicroUsd: 83,
inputTokens: 10,
outputTokens: 2,
cacheReadTokens: 3,
effectiveInputUsdPer1m: 0.003972,
effectiveOutputUsdPer1m: 0.007944,
transactionHash: "0x" + "c".repeat(64),
}],
}));
const getOrderBook = vi.fn(async () => ({
model: "deepseek-v4-flash",
offers: [{
id: "offer-1",
seller: "0x1111111111111111111111111111111111111111",
sellerBaseUrl: "https://openrouter.ai/api/v1",
inputMicroUsdPer1m: 3_972,
outputMicroUsdPer1m: 7_944,
available: true,
healthy: true,
trusted: true,
trades24h: 429,
rank: 10,
capRemainingMicroUsd: 50_000,
volume24hMicroUsd: 64_030,
}],
}));
const service = new SurplusDashboardService({
controller: { snapshot: () => STATUS },
client: { listAllOffers, getEarnings, getOrderBook },
allowance: {
readAllowance: async () => ({
limitUsdNanos: 20_000_000_000n,
remainingUsdNanos: 16_313_443_131n,
reset: null,
checkedAtMs: Date.parse("2026-08-28T12:00:00.000Z"),
}),
},
config: config(),
now: () => Date.parse("2026-08-28T12:00:00.000Z"),
});
const first = await service.getSnapshot();
const second = await service.getSnapshot();
expect(first.surplus.offer).toMatchObject({
id: "offer-1",
rank: 10,
available: true,
dailyCapUsd: "0.050000",
});
expect(first.surplus.earnings).toMatchObject({
totalUsd: "0.186810",
requests: 1_093,
topModel: "deepseek-v4-flash",
});
expect(first.provider).toMatchObject({
usedUsd: "3.686556",
estimatedDailyExposureUsd: "1.020408",
});
expect(first.vector).toMatchObject({ status: "retired", historicalSettledJobs: 8 });
expect(first.surplus.recentSales[0]).toMatchObject({
settlementStatus: "confirmed",
revenueUsd: "0.000083",
});
expect(JSON.stringify(first)).not.toContain("si_seller_test_secret");
expect(JSON.stringify(first)).not.toContain("sk-or-v1-test-secret");
expect(second).toBe(first);
expect(listAllOffers).toHaveBeenCalledTimes(1);
expect(getEarnings).toHaveBeenCalledTimes(1);
expect(getOrderBook).toHaveBeenCalledTimes(1);
});
});
describe("private supplier dashboard data surface", () => {
it("serves redacted data internally without hosting a dashboard page", async () => {
const data = snapshot();
const server = createSurplusServer(
{ snapshot: () => STATUS, healthy: () => true },
{ getSnapshot: async () => data },
);
const api = await request(server).get("/internal/resale-dashboard");
expect(api.status).toBe(200);
expect(api.body).toEqual(data);
expect(JSON.stringify(api.body)).not.toContain("test-secret");
expect((await request(server).get("/reseller")).status).toBe(404);
expect((await request(server).get("/")).status).toBe(404);
server.close();
});
});

Some files were not shown because too many files have changed in this diff Show More