diff --git a/supplier/src/surplus/controller.ts b/supplier/src/surplus/controller.ts index 0f14b86..7a1cf0f 100644 --- a/supplier/src/surplus/controller.ts +++ b/supplier/src/surplus/controller.ts @@ -262,6 +262,7 @@ export class SurplusSellerController { costMultiplierPpm: quote.costMultiplierPpm, inputMicroUsdPer1m: quote.inputMicroUsdPer1m, outputMicroUsdPer1m: quote.outputMicroUsdPer1m, + baselineRemainingUsdNanos: allowance.remainingUsdNanos.toString(), dailyCapUsd: Number(this.config.perOfferCapUsd), idempotencyKey: this.mutationId(), createdAt: new Date(this.now()).toISOString(), @@ -445,6 +446,28 @@ export class SurplusSellerController { allowance, parseUsdDecimal(this.config.perOfferCapUsd, "ceil"), ); + const baselineAllowance = BigInt( + currentState.intent.baselineRemainingUsdNanos, + ); + const providerSpend = baselineAllowance > allowance.remainingUsdNanos + ? baselineAllowance - allowance.remainingUsdNanos + : 0n; + const spendCap = parseUsdDecimal(this.config.perOfferCapUsd, "ceil"); + if (providerSpend >= spendCap) { + const stopping: SurplusControllerState = { + version: 1, + phase: "stopping", + offerId: currentState.offerId, + intent: currentState.intent, + highestTrades24h: currentState.highestTrades24h, + tradeObservedAt: currentState.intent.createdAt, + pauseIdempotencyKey: this.mutationId(), + }; + await this.stateStore.save(stopping); + this.state = stopping; + await this.finishStopping(); + return; + } const discovered = await this.client.discoverModels( this.config.providerApiKey, this.config.providerBaseUrl, diff --git a/supplier/src/surplus/state.ts b/supplier/src/surplus/state.ts index 5815fb6..3b39bb2 100644 --- a/supplier/src/surplus/state.ts +++ b/supplier/src/surplus/state.ts @@ -18,6 +18,7 @@ export interface SurplusOfferIntent { inputMicroUsdPer1m: number; outputMicroUsdPer1m: number; dailyCapUsd: number; + baselineRemainingUsdNanos: string; idempotencyKey: string; createdAt: string; baselineTrades24h: number; @@ -283,6 +284,10 @@ function parseIntent(value: unknown): SurplusOfferIntent { intent.dailyCapUsd, "Surplus state intent.dailyCapUsd", ), + baselineRemainingUsdNanos: nonNegativeIntegerString( + intent.baselineRemainingUsdNanos, + "Surplus state intent.baselineRemainingUsdNanos", + ), idempotencyKey: nonEmptyString( intent.idempotencyKey, "Surplus state intent.idempotencyKey", @@ -309,6 +314,13 @@ function nonEmptyString(value: unknown, field: string): string { return value; } +function nonNegativeIntegerString(value: unknown, field: string): string { + if (typeof value !== "string" || !/^(?:0|[1-9]\d*)$/.test(value)) { + throw new Error(`${field} must be a non-negative integer string`); + } + return value; +} + function isoTimestamp(value: unknown, field: string): string { const timestamp = nonEmptyString(value, field); if (!Number.isFinite(Date.parse(timestamp))) { diff --git a/tests/unit/surplus-controller.test.ts b/tests/unit/surplus-controller.test.ts index 6e73867..23fec7e 100644 --- a/tests/unit/surplus-controller.test.ts +++ b/tests/unit/surplus-controller.test.ts @@ -208,12 +208,15 @@ const ALLOWANCE: OpenRouterKeyAllowance = { function controller( client: FakeSurplusClient, store: MemoryStateStore, + allowance: { readAllowance(): Promise } = { + readAllowance: async () => ALLOWANCE, + }, ): SurplusSellerController { let mutation = 0; return new SurplusSellerController({ config: config(), client, - allowance: { readAllowance: async () => ALLOWANCE }, + allowance, stateStore: store, now: () => Date.parse("2026-08-27T10:00:00.000Z"), mutationId: () => `mutation-${++mutation}`, @@ -260,6 +263,26 @@ describe("SurplusSellerController", () => { await restarted.stop(); }); + it("pauses when provider spend reaches the per-offer cap", async () => { + const client = new FakeSurplusClient(); + const store = new MemoryStateStore(); + let remaining = 20_000_000_000n; + const activeController = controller(client, store, { + readAllowance: async () => ({ + ...ALLOWANCE, + remainingUsdNanos: remaining, + }), + }); + await activeController.runOnce(); + expect(client.offer?.status).toBe("active"); + + remaining = 18_700_000_000n; + await activeController.runOnce(); + + expect(client.offer?.status).toBe("inactive"); + expect(store.state.phase).toBe("awaiting_settlement"); + }); + it("adopts and resumes an inactive offer after an ambiguous create", async () => { const client = new FakeSurplusClient(); client.offer = { @@ -283,6 +306,7 @@ describe("SurplusSellerController", () => { inputMicroUsdPer1m: 3_972, outputMicroUsdPer1m: 7_944, dailyCapUsd: 1, + baselineRemainingUsdNanos: "20000000000", idempotencyKey: "create-1", createdAt: "2026-08-27T10:00:00.000Z", baselineTrades24h: 0,