From b714c09aca57a5292cc20f54823c196cbe466406 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 27 Aug 2026 12:51:15 +0200 Subject: [PATCH] fix: fail closed across Surplus seller state --- supplier/.env.example | 2 ++ supplier/src/surplus/client.ts | 10 +----- supplier/src/surplus/config.ts | 19 +++++++++++ supplier/src/surplus/controller.ts | 46 +++++++++++++++++---------- supplier/src/surplus/openRouterKey.ts | 4 +-- tests/unit/surplus-controller.test.ts | 1 + 6 files changed, 54 insertions(+), 28 deletions(-) diff --git a/supplier/.env.example b/supplier/.env.example index 510937b..82569f2 100644 --- a/supplier/.env.example +++ b/supplier/.env.example @@ -156,4 +156,6 @@ SURPLUS_MAX_CANDIDATE_ORDER_BOOKS=10 SURPLUS_REPRICE_INTERVAL_MS=300000 SURPLUS_REQUEST_TIMEOUT_MS=120000 SURPLUS_STOP_AFTER_SETTLED_REQUESTS=1 +# Leave empty until a live earnings record establishes the terminal status. +SURPLUS_SETTLED_STATUSES= SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json diff --git a/supplier/src/surplus/client.ts b/supplier/src/surplus/client.ts index 36b7e0c..37cc798 100644 --- a/supplier/src/surplus/client.ts +++ b/supplier/src/surplus/client.ts @@ -311,9 +311,7 @@ export class SurplusClient { const text = await readBoundedText(response, controller); if (!response.ok) { throw new SurplusHttpError( - `Surplus ${path} returned HTTP ${response.status}${ - text ? `: ${sanitizedErrorSnippet(text)}` : "" - }`, + `Surplus ${path} returned HTTP ${response.status}`, response.status, response.headers.get("retry-after"), ); @@ -500,9 +498,3 @@ function optionalMicroUsd(value: unknown, field: string): number | 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 index 3b7d820..027a2e3 100644 --- a/supplier/src/surplus/config.ts +++ b/supplier/src/surplus/config.ts @@ -22,6 +22,7 @@ export interface SurplusManagerConfig { pollIntervalMs: number; requestTimeoutMs: number; stopAfterTrades: number; + settledStatuses: string[]; statePath: string; port: number; } @@ -124,6 +125,10 @@ export function loadSurplusManagerConfig( 1, 1, ), + settledStatuses: statusList( + env.SURPLUS_SETTLED_STATUSES ?? "", + "SURPLUS_SETTLED_STATUSES", + ), statePath: requiredWithDefault( env.SURPLUS_STATE_PATH, "/var/lib/surplus-manager/state.json", @@ -188,6 +193,20 @@ function boundedInteger( return parsed; } +function statusList(value: string, name: string): string[] { + if (value.trim() === "") return []; + const statuses = value.split(",").map((status) => status.trim().toLowerCase()); + const seen = new Set(); + for (const status of statuses) { + if (!/^[a-z][a-z0-9_-]*$/.test(status)) { + throw new Error(`${name} contains an invalid status`); + } + if (seen.has(status)) throw new Error(`${name} contains a duplicate status`); + seen.add(status); + } + return statuses; +} + function httpsUrl(value: string, name: string, hostname: string): string { let parsed: URL; try { diff --git a/supplier/src/surplus/controller.ts b/supplier/src/surplus/controller.ts index 1773411..64800dd 100644 --- a/supplier/src/surplus/controller.ts +++ b/supplier/src/surplus/controller.ts @@ -70,11 +70,6 @@ interface SurplusControllerOptions { log?: (message: string) => void; } -const SETTLED_STATUSES: Record = { - confirmed: true, - paid: true, - settled: true, -}; export class SurplusSellerController { private readonly config: SurplusManagerConfig; @@ -403,7 +398,6 @@ export class SurplusSellerController { 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", @@ -412,9 +406,10 @@ export class SurplusSellerController { highestTrades24h: pending.intent.baselineTrades24h, }; try { + this.assertAggregateCap(confirmedOffers); await this.stateStore.save(adopted); } catch (error) { - await this.client.pauseOffer(primary.id, this.mutationId()).catch(() => undefined); + await this.pauseOffers(confirmedActive).catch(() => undefined); throw error; } this.state = adopted; @@ -430,10 +425,14 @@ export class SurplusSellerController { if (this.state?.phase !== "active") return; const currentState = this.state; const offers = await this.client.listAllOffers(); + const activeOffers = offers.filter((offer) => offer.status === "active"); const managed = offers.find((offer) => offer.id === currentState.offerId); - if (!managed) throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`); - const otherActive = offers.filter( - (offer) => offer.status === "active" && offer.id !== currentState.offerId, + if (!managed) { + await this.pauseOffers(activeOffers); + throw new Error(`Managed Surplus offer is missing: ${currentState.offerId}`); + } + const otherActive = activeOffers.filter( + (offer) => offer.id !== currentState.offerId, ); if (otherActive.length > 0) { await this.pauseOffers(otherActive); @@ -600,11 +599,22 @@ export class SurplusSellerController { ): 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; + if ( + !this.config.settledStatuses.includes( + sale.settlementStatus.toLowerCase(), + ) + ) { + return false; + } + if ( + sale.offerId !== state.offerId || + sale.createdAt === null || + sale.model !== state.intent.model || + sale.sellerCostMicroUsd <= 0 + ) { + return false; + } + return Date.parse(sale.createdAt) >= tradeObservedAt; }) ?? null; } @@ -637,7 +647,11 @@ export class SurplusSellerController { if (offer.capDailyUsd === null) { throw new Error(`Active Surplus offer has no daily cap: ${offer.id}`); } - activeCap += parseUsdDecimal(offer.capDailyUsd.toFixed(6), "ceil"); + const capMicroUsd = offer.capDailyUsd * 1_000_000; + if (!Number.isSafeInteger(capMicroUsd)) { + throw new Error(`Active Surplus offer cap has excess precision: ${offer.id}`); + } + activeCap += BigInt(capMicroUsd) * 1_000n; } const aggregateCap = parseUsdDecimal(this.config.aggregateCapUsd, "floor"); if (activeCap > aggregateCap) { diff --git a/supplier/src/surplus/openRouterKey.ts b/supplier/src/surplus/openRouterKey.ts index f528cd9..9df61a5 100644 --- a/supplier/src/surplus/openRouterKey.ts +++ b/supplier/src/surplus/openRouterKey.ts @@ -44,9 +44,7 @@ export class OpenRouterKeyClient { 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, " ")}` : "" - }`, + `OpenRouter key endpoint returned HTTP ${response.status}`, ); } let body: unknown; diff --git a/tests/unit/surplus-controller.test.ts b/tests/unit/surplus-controller.test.ts index 553fc7b..f4df5fb 100644 --- a/tests/unit/surplus-controller.test.ts +++ b/tests/unit/surplus-controller.test.ts @@ -39,6 +39,7 @@ function config(): SurplusManagerConfig { pollIntervalMs: 300_000, requestTimeoutMs: 120_000, stopAfterTrades: 1, + settledStatuses: ["confirmed"], statePath: "/tmp/surplus-test-state.json", port: 8080, };