diff --git a/deploy/mainnet/deploy.sh b/deploy/mainnet/deploy.sh index fc1e7ed..588d77a 100755 --- a/deploy/mainnet/deploy.sh +++ b/deploy/mainnet/deploy.sh @@ -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=() diff --git a/deploy/mainnet/docker-compose.surplus-seller.yml b/deploy/mainnet/docker-compose.surplus-seller.yml new file mode 100644 index 0000000..c924c41 --- /dev/null +++ b/deploy/mainnet/docker-compose.surplus-seller.yml @@ -0,0 +1,39 @@ +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 + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost: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 diff --git a/supplier/.env.example b/supplier/.env.example index 1f09711..510937b 100644 --- a/supplier/.env.example +++ b/supplier/.env.example @@ -132,3 +132,28 @@ 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 proof offer. +SURPLUS_PER_OFFER_CAP_USD=1.00 +SURPLUS_AGGREGATE_CAP_USD=1.00 +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 +SURPLUS_STOP_AFTER_SETTLED_REQUESTS=1 +SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json diff --git a/supplier/Dockerfile b/supplier/Dockerfile index 1887adc..a8caf8a 100644 --- a/supplier/Dockerfile +++ b/supplier/Dockerfile @@ -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 diff --git a/supplier/package.json b/supplier/package.json index b8c1873..37d4d8c 100644 --- a/supplier/package.json +++ b/supplier/package.json @@ -10,7 +10,8 @@ "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", - "reseller:setup": "tsx src/cli/setup-reseller.ts" + "reseller:setup": "tsx src/cli/setup-reseller.ts", + "surplus:start": "tsx src/cli/surplus-seller.ts" }, "dependencies": { "@marketplace/shared": "workspace:*", diff --git a/supplier/src/cli/surplus-seller.ts b/supplier/src/cli/surplus-seller.ts new file mode 100644 index 0000000..ea0a378 --- /dev/null +++ b/supplier/src/cli/surplus-seller.ts @@ -0,0 +1,92 @@ +import { createServer, type Server } from "node:http"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { loadSurplusManagerConfig } from "../surplus/config.js"; +import { SurplusClient } from "../surplus/client.js"; +import { SurplusSellerController } from "../surplus/controller.js"; +import { OpenRouterKeyClient } from "../surplus/openRouterKey.js"; +import { FileSurplusStateStore } from "../surplus/state.js"; + +export async function runSurplusSeller( + env: Record, +): Promise<{ server: Server; controller: SurplusSellerController }> { + 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), + }); + const server = createStatusServer(controller); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(config.port, "0.0.0.0", () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + await controller.start(); + return { server, controller }; +} + +function createStatusServer(controller: SurplusSellerController): Server { + return createServer((request, response) => { + const status = controller.snapshot(); + if (request.method === "GET" && request.url === "/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" && request.url === "/status") { + response.statusCode = 200; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(status)); + return; + } + response.statusCode = 404; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ error: "not_found" })); + }); +} + +export async function main( + env: Record = process.env, +): Promise { + const { server, controller } = await runSurplusSeller(env); + console.log("Surplus seller controller listening"); + let shuttingDown = false; + const shutdown = async (signal: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`Surplus seller controller shutting down (${signal})`); + await controller.stop(); + await new Promise((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; + }); +} diff --git a/supplier/src/surplus/client.ts b/supplier/src/surplus/client.ts new file mode 100644 index 0000000..36b7e0c --- /dev/null +++ b/supplier/src/surplus/client.ts @@ -0,0 +1,508 @@ +export interface SurplusOffer { + id: string; + model: string; + sellerBaseUrl: string; + status: "active" | "inactive"; + capDailyUsd: 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; +} + +export interface SurplusOrderBook { + model: string; + offers: SurplusOrderBookOffer[]; +} + +export interface SurplusOfferWrite { + model: string; + apiKey: string; + sellerBaseUrl: string; + inputUsdPer1m: number; + outputUsdPer1m: number; + dailyCapUsd: number; + payoutAddress: string; + idempotencyKey: string; +} + +export interface SurplusOfferPatch { + inputUsdPer1m: number; + outputUsdPer1m: number; + dailyCapUsd: number; + idempotencyKey: string; +} + +export interface SurplusSale { + model: string; + offerId: string | null; + settlementStatus: string; + createdAt: string | null; + sellerCostMicroUsd: number; +} + +export interface SurplusEarnings { + totalEarnedMicroUsd: number; + pendingMicroUsd: number; + paidMicroUsd: number; + 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 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 { + const items: SurplusOffer[] = []; + const seenTokens = new Set(); + 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 { + const response = await this.requestText("/v1/seller/discover", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ api_key: apiKey, base_url: baseUrl }), + }); + 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 { + 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 { + 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(); + 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 { + 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 }), + }); + 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 { + 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: "per_token", + price_input_per_1m: input.inputUsdPer1m, + price_output_per_1m: input.outputUsdPer1m, + cap_daily_usd: input.dailyCapUsd, + payout_address: input.payoutAddress, + }), + }); + const root = record(body, "Surplus create offer response"); + return offerId(root, "Surplus create offer response"); + } + + async updateOffer(id: string, patch: SurplusOfferPatch): Promise { + await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: mutationHeaders(patch.idempotencyKey), + body: JSON.stringify({ + pricing_mode: "per_token", + price_input_per_1m: patch.inputUsdPer1m, + price_output_per_1m: patch.outputUsdPer1m, + cap_daily_usd: patch.dailyCapUsd, + }), + }); + } + + async pauseOffer(id: string, idempotencyKey: string): Promise { + 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 { + await this.requestText(`/v1/seller/offers/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: mutationHeaders(idempotencyKey), + body: JSON.stringify({ status: "active" }), + }); + } + + async getEarnings(): Promise { + const body = await this.requestJson("/v1/seller/earnings?range=lifetime"); + const root = record(body, "Surplus earnings response"); + const rawSales = root.recent_sales; + if (!Array.isArray(rawSales)) { + throw new Error("Surplus earnings response.recent_sales must be an array"); + } + return { + totalEarnedMicroUsd: microUsd(root.total_earned_usdc, "total_earned_usdc"), + pendingMicroUsd: microUsd(root.pending_usdc, "pending_usdc"), + paidMicroUsd: microUsd(root.paid_usdc, "paid_usdc"), + recentSales: rawSales.map((sale, index) => parseSale(sale, index)), + }; + } + + private async requestJson( + path: string, + init: RequestInit = {}, + authenticated = true, + ): Promise { + const response = await this.requestText(path, init, authenticated); + 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, + ): 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)}` : "" + }`, + 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 { + 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, `Surplus offer ${index}.seller_base_url`), + status, + capDailyUsd: optionalNonNegativeNumber(item.cap_daily_usd, `Surplus offer ${index}.cap_daily_usd`), + 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 pricing = record(item.pricing, `Surplus discovery item ${index}.pricing`); + const metadata = record(item.metadata, `Surplus discovery item ${index}.metadata`); + return { + model: string(item.model, `Surplus discovery item ${index}.model`), + supported: boolean(item.supported, `Surplus discovery item ${index}.supported`), + inputUsdPer1m: nonNegativeNumber(pricing.input_per_1m, `Surplus discovery item ${index}.pricing.input_per_1m`), + outputUsdPer1m: nonNegativeNumber(pricing.output_per_1m, `Surplus discovery item ${index}.pricing.output_per_1m`), + priceUnit: optionalString(pricing.price_unit) ?? "M", + priceVariable: pricing.price_variable === undefined ? false : boolean(pricing.price_variable, `Surplus discovery item ${index}.pricing.price_variable`), + providerModelId: string(metadata.provider_model_id, `Surplus discovery item ${index}.metadata.provider_model_id`), + modelType: string(metadata.model_type, `Surplus discovery item ${index}.metadata.model_type`), + availabilityStatus: string(metadata.availability_status, `Surplus discovery item ${index}.metadata.availability_status`), + }; +} + +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`), + }; +} + +function parseSale(value: unknown, index: number): SurplusSale { + const item = record(value, `Surplus sale ${index}`); + return { + model: string(item.model, `Surplus sale ${index}.model`), + offerId: optionalString(item.offer_id), + settlementStatus: string(item.settlement_status, `Surplus sale ${index}.settlement_status`), + createdAt: optionalString(item.created_at), + sellerCostMicroUsd: microUsd(item.seller_cost_usdc, `Surplus sale ${index}.seller_cost_usdc`), + }; +} + +function offerId(item: Record, field: string): string { + const value = item.offer_id ?? item.id; + return string(value, `${field}.id`); +} + +function record(value: unknown, field: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as Record; +} + +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 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 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 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); +} + +function sanitizedErrorSnippet(value: string): string { + return value + .slice(0, 300) + .replace(/(?:si_seller_|sk-or-v1-)[A-Za-z0-9_-]+/g, "[redacted]") + .replace(/[\r\n]+/g, " "); +} diff --git a/supplier/src/surplus/config.ts b/supplier/src/surplus/config.ts new file mode 100644 index 0000000..3b7d820 --- /dev/null +++ b/supplier/src/surplus/config.ts @@ -0,0 +1,202 @@ +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*)$/; + +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; + statePath: string; + port: number; +} + +export function loadSurplusManagerConfig( + env: Record, +): 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 ?? "1.00", + "SURPLUS_PER_OFFER_CAP_USD", + ), + aggregateCapUsd: usd( + env.SURPLUS_AGGREGATE_CAP_USD ?? "1.00", + "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 ?? "1", + "SURPLUS_STOP_AFTER_SETTLED_REQUESTS", + 1, + 1, + ), + statePath: requiredWithDefault( + env.SURPLUS_STATE_PATH, + "/var/lib/surplus-manager/state.json", + "SURPLUS_STATE_PATH", + ), + port: boundedInteger(env.PORT ?? "8080", "PORT", 1, 65_535), + }; +} + +function required( + env: Record, + 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 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(/\/+$/, ""); +} diff --git a/supplier/src/surplus/controller.ts b/supplier/src/surplus/controller.ts new file mode 100644 index 0000000..1773411 --- /dev/null +++ b/supplier/src/surplus/controller.ts @@ -0,0 +1,734 @@ +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, + quoteSurplusPrice, +} 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; + +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; +} + +const SETTLED_STATUSES: Record = { + confirmed: true, + paid: true, + settled: true, +}; + +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 | 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 { + if (this.state !== null) return; + this.state = await this.stateStore.load(); + await this.runOnce(); + this.schedule(); + } + + async stop(): Promise { + this.stopped = true; + clearTimeout(this.timer ?? undefined); + this.timer = null; + if (this.inFlight) await this.inFlight; + } + + async runOnce(): Promise { + 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 { + 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 { + 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, 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 = quoteSurplusPrice({ + 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, + inputMicroUsdPer1m: quote.inputMicroUsdPer1m, + outputMicroUsdPer1m: quote.outputMicroUsdPer1m, + 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, + inputUsdPer1m: intent.inputMicroUsdPer1m / 1_000_000, + outputUsdPer1m: intent.outputMicroUsdPer1m / 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 { + 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 matches = active + .filter((offer) => + offer.model === pending.intent.model && + normalizeUrl(offer.sellerBaseUrl) === + normalizeUrl(this.config.providerBaseUrl) + ) + .sort((left, right) => left.id.localeCompare(right.id)); + if (matches.length === 0) { + const inactiveMatch = offers.some((offer) => + offer.status === "inactive" && + offer.model === pending.intent.model && + normalizeUrl(offer.sellerBaseUrl) === + normalizeUrl(this.config.providerBaseUrl) + ); + if (inactiveMatch && active.length === 0) { + const selecting: SurplusControllerState = { + version: 1, + phase: "selecting", + }; + await this.stateStore.save(selecting); + this.state = selecting; + this.markSuccess("suspended", null, null); + return; + } + if (active.length > 0) await this.pauseOffers(active); + throw new Error( + "Create result is ambiguous and no matching offer is visible; refusing another POST", + ); + } + const primary = matches[0]; + await this.pauseOffers(active.filter((offer) => offer.id !== primary.id)); + const confirmedOffers = await this.client.listAllOffers(); + const confirmedActive = confirmedOffers.filter( + (offer) => offer.status === "active", + ); + if (confirmedActive.length !== 1 || confirmedActive[0].id !== primary.id) { + await this.pauseOffers(confirmedActive); + throw new Error("Adopted Surplus create did not reconcile to one active offer"); + } + this.assertAggregateCap(confirmedOffers); + const adopted: SurplusControllerState = { + version: 1, + phase: "active", + offerId: primary.id, + intent: pending.intent, + highestTrades24h: pending.intent.baselineTrades24h, + }; + try { + await this.stateStore.save(adopted); + } catch (error) { + await this.client.pauseOffer(primary.id, this.mutationId()).catch(() => undefined); + throw error; + } + this.state = adopted; + this.markSuccess("active", null, { + intent: adopted.intent, + offerId: adopted.offerId, + competitorId: null, + totalEarnedMicroUsd: null, + }); + } + + private async manageActiveOffer(): Promise { + if (this.state?.phase !== "active") return; + const currentState = this.state; + const offers = await this.client.listAllOffers(); + const managed = offers.find((offer) => offer.id === currentState.offerId); + if (!managed) throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`); + const otherActive = offers.filter( + (offer) => offer.status === "active" && 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, + parseUsdDecimal(this.config.perOfferCapUsd, "ceil"), + ); + 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 ( + 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 = quoteSurplusPrice({ + 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, + inputMicroUsdPer1m: quote.inputMicroUsdPer1m, + outputMicroUsdPer1m: quote.outputMicroUsdPer1m, + }; + const priceChanged = managed.inputMicroUsdPer1m !== quote.inputMicroUsdPer1m || + managed.outputMicroUsdPer1m !== quote.outputMicroUsdPer1m || + managed.capDailyUsd !== Number(this.config.perOfferCapUsd); + if (this.config.live && priceChanged) { + await this.client.updateOffer(currentState.offerId, { + inputUsdPer1m: quote.inputUsdPer1m, + outputUsdPer1m: quote.outputUsdPer1m, + 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 { + 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 { + 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, + ): SurplusEarnings["recentSales"][number] | null { + const tradeObservedAt = Date.parse(state.tradeObservedAt); + return earnings.recentSales.find((sale) => { + if (!SETTLED_STATUSES[sale.settlementStatus.toLowerCase()]) return false; + if (sale.model !== state.intent.model || sale.sellerCostMicroUsd <= 0) return false; + if (sale.offerId !== null) return sale.offerId === state.offerId; + return sale.createdAt !== null && + 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}`); + } + activeCap += parseUsdDecimal(offer.capDailyUsd.toFixed(6), "ceil"); + } + const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor"); + if (activeCap > aggregateCap) { + throw new Error("Active Surplus offer caps exceed the aggregate cap"); + } + } + + 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 { + 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(); +} diff --git a/supplier/src/surplus/openRouterKey.ts b/supplier/src/surplus/openRouterKey.ts new file mode 100644 index 0000000..f528cd9 --- /dev/null +++ b/supplier/src/surplus/openRouterKey.ts @@ -0,0 +1,129 @@ +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 { + 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}${ + text ? `: ${text.slice(0, 200).replace(/[\r\n]+/g, " ")}` : "" + }`, + ); + } + 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 { + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as Record; +} diff --git a/supplier/src/surplus/policy.ts b/supplier/src/surplus/policy.ts new file mode 100644 index 0000000..6b71f03 --- /dev/null +++ b/supplier/src/surplus/policy.ts @@ -0,0 +1,150 @@ +export interface SurplusCompetitor { + id: string; + inputMicroUsdPer1m: number; + outputMicroUsdPer1m: number; +} + +export interface SurplusPricePolicyInput { + upstreamInputMicroUsdPer1m: number; + upstreamOutputMicroUsdPer1m: number; + recoveryBps: number; + undercutBps: number; + competitors: SurplusCompetitor[]; +} + +export interface SurplusPriceQuote { + inputMicroUsdPer1m: number; + outputMicroUsdPer1m: number; + inputUsdPer1m: number; + outputUsdPer1m: number; + floorInputMicroUsdPer1m: number; + floorOutputMicroUsdPer1m: number; + competitorId: string | null; + competitive: boolean; +} + +const BPS_SCALE = 10_000n; +const MICRO_USD = 1_000_000; + +export function quoteSurplusPrice( + input: SurplusPricePolicyInput, +): SurplusPriceQuote { + 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 floorInput = multiplyBpsCeil( + input.upstreamInputMicroUsdPer1m, + input.recoveryBps, + ); + const floorOutput = multiplyBpsCeil( + input.upstreamOutputMicroUsdPer1m, + input.recoveryBps, + ); + + const competitor = cheapestCompetitor(input.competitors); + const candidateInput = competitor + ? undercut(competitor.inputMicroUsdPer1m, input.undercutBps) + : floorInput; + const candidateOutput = competitor + ? undercut(competitor.outputMicroUsdPer1m, input.undercutBps) + : floorOutput; + const quotedInput = Math.max(floorInput, candidateInput); + const quotedOutput = Math.max(floorOutput, candidateOutput); + + return { + inputMicroUsdPer1m: quotedInput, + outputMicroUsdPer1m: quotedOutput, + inputUsdPer1m: quotedInput / MICRO_USD, + outputUsdPer1m: quotedOutput / MICRO_USD, + floorInputMicroUsdPer1m: floorInput, + floorOutputMicroUsdPer1m: floorOutput, + 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 undercut(value: number, bps: number): number { + const discounted = Number( + (BigInt(value) * BigInt(10_000 - bps)) / BPS_SCALE, + ); + if (discounted >= value && value > 1) return value - 1; + return Math.max(1, discounted); +} + +function multiplyBpsCeil(value: number, bps: number): number { + const numerator = BigInt(value) * BigInt(bps); + return Number((numerator + BPS_SCALE - 1n) / BPS_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}`); + } +} diff --git a/supplier/src/surplus/selection.ts b/supplier/src/surplus/selection.ts new file mode 100644 index 0000000..0de5632 --- /dev/null +++ b/supplier/src/surplus/selection.ts @@ -0,0 +1,110 @@ +import type { + SurplusDiscoveredModel, + SurplusMarketSummary, + SurplusOrderBookOffer, +} from "./client.js"; +import { + quoteSurplusPrice, + usdPer1mToMicroUsd, + type SurplusCompetitor, + type SurplusPriceQuote, +} from "./policy.js"; + +export interface SurplusModelCandidate { + model: string; + providerModelId: string; + upstreamInputMicroUsdPer1m: number; + upstreamOutputMicroUsdPer1m: number; + requests24h: number; + volume24h: number; + summaryQuote: SurplusPriceQuote; +} + +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.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 = quoteSurplusPrice({ + 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, +): SurplusCompetitor[] { + const normalizedWallet = sellerWallet.toLowerCase(); + return offers + .filter((offer) => + offer.available && + offer.healthy && + offer.trusted && + 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); +} diff --git a/supplier/src/surplus/state.ts b/supplier/src/surplus/state.ts new file mode 100644 index 0000000..51e0e0b --- /dev/null +++ b/supplier/src/surplus/state.ts @@ -0,0 +1,338 @@ +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; + inputMicroUsdPer1m: number; + outputMicroUsdPer1m: number; + dailyCapUsd: number; + 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; + save(state: SurplusControllerState): Promise; +} + +export class FileSurplusStateStore implements SurplusStateStore { + private readonly path: string; + + constructor(path: string) { + this.path = path; + } + + async load(): Promise { + 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 { + 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", + ), + inputMicroUsdPer1m: positiveInteger( + intent.inputMicroUsdPer1m, + "Surplus state intent.inputMicroUsdPer1m", + ), + outputMicroUsdPer1m: positiveInteger( + intent.outputMicroUsdPer1m, + "Surplus state intent.outputMicroUsdPer1m", + ), + dailyCapUsd: positiveNumber( + intent.dailyCapUsd, + "Surplus state intent.dailyCapUsd", + ), + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as Record; +} + +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 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; +} diff --git a/tests/unit/surplus-client.test.ts b/tests/unit/surplus-client.test.ts new file mode 100644 index 0000000..39c5a2e --- /dev/null +++ b/tests/unit/surplus-client.test.ts @@ -0,0 +1,225 @@ +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({ + 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", + }, + }); +} + +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("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, + }], + }); + }); + 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("sends one absolute PATCH with plain USD prices and 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", { + inputUsdPer1m: 0.003972, + outputUsdPer1m: 0.007944, + dailyCapUsd: 1, + idempotencyKey: "mutation-1", + }); + + expect(request?.method).toBe("PATCH"); + expect(JSON.parse(String(request?.body))).toEqual({ + pricing_mode: "per_token", + price_input_per_1m: 0.003972, + price_output_per_1m: 0.007944, + cap_daily_usd: 1, + }); + const headers = new Headers(request?.headers); + expect(headers.get("idempotency-key")).toBe("mutation-1"); + }); +}); diff --git a/tests/unit/surplus-controller.test.ts b/tests/unit/surplus-controller.test.ts new file mode 100644 index 0000000..553fc7b --- /dev/null +++ b/tests/unit/surplus-controller.test.ts @@ -0,0 +1,283 @@ +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: "1.00", + aggregateCapUsd: "1.00", + 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, + statePath: "/tmp/surplus-test-state.json", + port: 8080, + }; +} + +class MemoryStateStore implements SurplusStateStore { + state: SurplusControllerState = { version: 1, phase: "selecting" }; + + async load(): Promise { + return structuredClone(this.state); + } + + async save(state: SurplusControllerState): Promise { + 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 { + this.externalCalls += 1; + return this.offer ? [{ ...this.offer }] : []; + } + + async discoverModels(): Promise { + 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, + }]; + 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, + }); + } + return { model, offers }; + } + + async testConnection(): Promise { + this.externalCalls += 1; + return 10; + } + + async createOffer(input: SurplusOfferWrite): Promise { + 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, + inputMicroUsdPer1m: Math.round(input.inputUsdPer1m * 1_000_000), + outputMicroUsdPer1m: Math.round(input.outputUsdPer1m * 1_000_000), + }; + return this.offer.id; + } + + async updateOffer(_id: string, patch: SurplusOfferPatch): Promise { + this.externalCalls += 1; + this.patchPayload = patch; + if (!this.offer) throw new Error("missing fake offer"); + this.offer.inputMicroUsdPer1m = Math.round(patch.inputUsdPer1m * 1_000_000); + this.offer.outputMicroUsdPer1m = Math.round(patch.outputUsdPer1m * 1_000_000); + this.offer.capDailyUsd = patch.dailyCapUsd; + } + + async pauseOffer(): Promise { + this.externalCalls += 1; + this.pauseCalls += 1; + if (this.offer) this.offer.status = "inactive"; + } + + async resumeOffer(): Promise { + 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, + recentSales: this.settled ? [{ + model: "beta-model", + offerId: "managed-offer", + settlementStatus: "confirmed", + createdAt: "2026-08-27T10:00:01.000Z", + sellerCostMicroUsd: 100, + }] : [], + }; + } +} + +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, +): SurplusSellerController { + let mutation = 0; + return new SurplusSellerController({ + config: config(), + client, + allowance: { readAllowance: async () => 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", + inputUsdPer1m: 0.003972, + outputUsdPer1m: 0.007944, + dailyCapUsd: 1, + 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("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" }); + }); +}); diff --git a/tests/unit/surplus-policy.test.ts b/tests/unit/surplus-policy.test.ts new file mode 100644 index 0000000..fde43fb --- /dev/null +++ b/tests/unit/surplus-policy.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { + quoteSurplusPrice, +} from "../../supplier/src/surplus/policy.js"; +import { + eligibleCompetitors, + selectSurplusModelCandidates, +} from "../../supplier/src/surplus/selection.js"; +import type { + SurplusDiscoveredModel, + SurplusMarketSummary, + SurplusOrderBookOffer, +} from "../../supplier/src/surplus/client.js"; + +function discovered( + model: string, + providerModelId: string, +): SurplusDiscoveredModel { + return { + model, + providerModelId, + supported: true, + inputUsdPer1m: 0.07952, + outputUsdPer1m: 0.15904, + priceUnit: "M", + priceVariable: false, + modelType: "text", + availabilityStatus: "available", + }; +} + +function market(model: string, requests24h: number): SurplusMarketSummary { + return { + model, + requests24h, + volume24h: 1_000, + bestInputMicroUsdPer1m: 4_052, + bestOutputMicroUsdPer1m: 8_104, + healthySellerCount: 1, + }; +} + +describe("Surplus bounded price policy", () => { + it("uses exact recovery floors and undercuts in integer micro-USD", () => { + const quote = quoteSurplusPrice({ + upstreamInputMicroUsdPer1m: 79_520, + upstreamOutputMicroUsdPer1m: 159_040, + recoveryBps: 490, + undercutBps: 10, + competitors: [{ + id: "external", + inputMicroUsdPer1m: 3_976, + outputMicroUsdPer1m: 7_952, + }], + }); + + expect(quote.floorInputMicroUsdPer1m).toBe(3_897); + expect(quote.floorOutputMicroUsdPer1m).toBe(7_793); + expect(quote.inputMicroUsdPer1m).toBe(3_972); + expect(quote.outputMicroUsdPer1m).toBe(7_944); + expect(quote.inputUsdPer1m).toBe(0.003972); + expect(quote.outputUsdPer1m).toBe(0.007944); + expect(quote.competitive).toBe(true); + }); + + it("keeps the recovery floor when the cheapest offer is too low", () => { + const quote = quoteSurplusPrice({ + upstreamInputMicroUsdPer1m: 79_520, + upstreamOutputMicroUsdPer1m: 159_040, + recoveryBps: 490, + undercutBps: 10, + competitors: [{ + id: "below-floor", + inputMicroUsdPer1m: 1_000, + outputMicroUsdPer1m: 2_000, + }], + }); + + expect(quote.inputMicroUsdPer1m).toBe(3_897); + expect(quote.outputMicroUsdPer1m).toBe(7_793); + expect(quote.competitive).toBe(false); + }); + + it("selects equal-price competitors with a stable total order", () => { + const input = { + upstreamInputMicroUsdPer1m: 100, + upstreamOutputMicroUsdPer1m: 100, + recoveryBps: 100, + undercutBps: 10, + }; + const left = { id: "left", inputMicroUsdPer1m: 100, outputMicroUsdPer1m: 300 }; + const right = { id: "right", inputMicroUsdPer1m: 200, outputMicroUsdPer1m: 200 }; + + expect(quoteSurplusPrice({ ...input, competitors: [right, left] }).competitorId) + .toBe("left"); + expect(quoteSurplusPrice({ ...input, competitors: [left, right] }).competitorId) + .toBe("left"); + }); +}); + +describe("Surplus multi-model selection", () => { + it("selects any eligible OpenRouter model by demand, not a hardcoded model", () => { + const discovery = [ + discovered("beta-model", "vendor/beta"), + discovered("alpha-model", "vendor/alpha"), + { ...discovered("deepseek-v4-flash", "deepseek/deepseek-v4-flash"), supported: false }, + ]; + const markets = [market("alpha-model", 50), market("beta-model", 100)]; + + const result = selectSurplusModelCandidates(discovery, markets, 490, 10); + + expect(result.map((candidate) => candidate.model)).toEqual([ + "beta-model", + "alpha-model", + ]); + expect(result[0].providerModelId).toBe("vendor/beta"); + }); + + it("resolves equal demand independently of discovery order", () => { + const alpha = discovered("alpha-model", "vendor/alpha"); + const beta = discovered("beta-model", "vendor/beta"); + const markets = [market("alpha-model", 100), market("beta-model", 100)]; + + expect(selectSurplusModelCandidates([beta, alpha], markets, 490, 10)[0].model) + .toBe("alpha-model"); + expect(selectSurplusModelCandidates([alpha, beta], markets, 490, 10)[0].model) + .toBe("alpha-model"); + }); + + it("excludes unhealthy, untrusted, unavailable, and self offers", () => { + const base: SurplusOrderBookOffer = { + id: "external", + seller: "0x2222222222222222222222222222222222222222", + sellerBaseUrl: "https://openrouter.ai/api/v1", + inputMicroUsdPer1m: 4_000, + outputMicroUsdPer1m: 8_000, + available: true, + healthy: true, + trusted: true, + trades24h: 0, + }; + const seller = "0x1111111111111111111111111111111111111111"; + const offers = [ + base, + { ...base, id: "self-id", seller }, + { ...base, id: "managed-id" }, + { ...base, id: "unhealthy", healthy: false }, + { ...base, id: "untrusted", trusted: false }, + { ...base, id: "unavailable", available: false }, + ]; + + expect(eligibleCompetitors(offers, seller, new Set(["managed-id"]))) + .toEqual([{ id: "external", inputMicroUsdPer1m: 4_000, outputMicroUsdPer1m: 8_000 }]); + }); +}); diff --git a/tests/unit/surplus-state.test.ts b/tests/unit/surplus-state.test.ts new file mode 100644 index 0000000..b04b043 --- /dev/null +++ b/tests/unit/surplus-state.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + chmod, + mkdtemp, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + FileSurplusStateStore, + type SurplusControllerState, +} from "../../supplier/src/surplus/state.js"; + +const directories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "surplus-state-")); + directories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => + rm(path, { recursive: true, force: true }) + )); +}); + +describe("FileSurplusStateStore", () => { + it("writes a private durable completion record and reloads its sale evidence", async () => { + const directory = await temporaryDirectory(); + const path = join(directory, "state.json"); + const store = new FileSurplusStateStore(path); + const state: SurplusControllerState = { + version: 1, + phase: "completed", + offerId: "offer-1", + model: "alpha-model", + providerModelId: "vendor/alpha-model", + tradeObservedAt: "2026-08-27T10:00:00.000Z", + completedAt: "2026-08-27T10:00:05.000Z", + settlement: { + offerId: "offer-1", + createdAt: "2026-08-27T10:00:03.000Z", + sellerCostMicroUsd: 100, + settlementStatus: "confirmed", + }, + }; + + await store.save(state); + + expect((await stat(path)).mode & 0o777).toBe(0o600); + await expect(store.load()).resolves.toEqual(state); + }); + + it("rejects a state symlink instead of following it", async () => { + const directory = await temporaryDirectory(); + const target = join(directory, "target.json"); + const path = join(directory, "state.json"); + await writeFile(target, '{"version":1,"phase":"selecting"}\n', { mode: 0o600 }); + await symlink(target, path); + + await expect(new FileSurplusStateStore(path).load()).rejects.toThrow(); + }); + + it("rejects an existing state file with broad permissions", async () => { + const directory = await temporaryDirectory(); + const path = join(directory, "state.json"); + await writeFile(path, '{"version":1,"phase":"selecting"}\n', { mode: 0o600 }); + await chmod(path, 0o644); + + await expect(new FileSurplusStateStore(path).load()) + .rejects.toThrow("must not allow group or other access"); + }); +});