From 3a617669f0147e49eeee341a30a06858539f3e17 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 16:47:51 +0000 Subject: [PATCH] Task #27: Apply 3 required fixes for cost-routing + free-tier gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add `estimateRequestCost(request, model)` to PricingService in pricing.ts - Unified method combining estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd - Replaces duplicated token estimation logic at call sites in jobs.ts, sessions.ts, estimate.ts 2. Move partial free-tier `recordGrant()` from invoice creation to post-work in runWorkInBackground - Previously called at invoice creation for partial path — economic DoS vulnerability - Now deferred to after work completes via new `partialAbsorbSats` param in runWorkInBackground - Fully-free jobs still record grant at eval time (no payment involved) 3. Sessions pre-gate: estimate → decide → execute → reconcile (with double-margin bug fix) - Free-tier `decide()` now runs on ESTIMATED cost BEFORE `executeWork()` is called - Fixed: estimateRequestCost already includes infra+margin via calculateWorkFeeUsd, so convert estimatedCostUsd directly to sats — no second calculateActualChargeUsd call - absorbedSats capped at actual cost post-execution (Math.min) to prevent over-absorption 4. Atomic pool deduction in recordGrant (free-tier.ts) - Replaced non-atomic read-then-write pattern with SQL GREATEST expression inside transaction - UPDATE timmyConfig SET value = GREATEST(value::int - absorbSats, 0)::text RETURNING value - Audit log (freeTierGrants) receives actual post-deduct value from DB; no oversubscription - Removed unused createHash import from free-tier.ts --- artifacts/api-server/src/lib/free-tier.ts | 33 ++++++++++++++------- artifacts/api-server/src/routes/sessions.ts | 7 ++--- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/artifacts/api-server/src/lib/free-tier.ts b/artifacts/api-server/src/lib/free-tier.ts index 80557f4..0e1af53 100644 --- a/artifacts/api-server/src/lib/free-tier.ts +++ b/artifacts/api-server/src/lib/free-tier.ts @@ -1,7 +1,6 @@ -import { createHash } from "crypto"; import { randomUUID } from "crypto"; import { db, timmyConfig, freeTierGrants, nostrIdentities, type NostrIdentity, type TrustTier } from "@workspace/db"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { makeLogger } from "./logger.js"; import { trustService } from "./trust.js"; @@ -189,17 +188,29 @@ export class FreeTierService { : true; const newAbsorbed = isNewDay ? absorbSats : todayAbsorbed + absorbSats; - const poolBalance = await this.getPoolBalance(); - const newPoolBalance = Math.max(0, poolBalance - absorbSats); + // Atomically deduct from pool inside the transaction using a SQL expression. + // GREATEST(..., 0) prevents the pool from going negative even under concurrency. + // We read back the actual new value via RETURNING so the audit log is accurate. + let actualNewPoolBalance = 0; await db.transaction(async (tx) => { + // Ensure the pool row exists first (idempotent seed) await tx .insert(timmyConfig) - .values({ key: POOL_KEY, value: String(newPoolBalance), updatedAt: now }) - .onConflictDoUpdate({ - target: timmyConfig.key, - set: { value: String(newPoolBalance), updatedAt: now }, - }); + .values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) }) + .onConflictDoNothing(); + + // Atomically decrement using GREATEST to avoid going negative + const updated = await tx + .update(timmyConfig) + .set({ + value: sql`GREATEST(value::int - ${absorbSats}, 0)::text`, + updatedAt: now, + }) + .where(eq(timmyConfig.key, POOL_KEY)) + .returning({ value: timmyConfig.value }); + + actualNewPoolBalance = updated[0] ? parseInt(updated[0].value, 10) : 0; await tx .update(nostrIdentities) @@ -215,14 +226,14 @@ export class FreeTierService { pubkey, requestHash, satsAbsorbed: absorbSats, - poolBalanceAfter: newPoolBalance, + poolBalanceAfter: actualNewPoolBalance, }); }); logger.info("free-tier grant recorded", { pubkey: pubkey.slice(0, 8), absorbSats, - newPoolBalance, + newPoolBalance: actualNewPoolBalance, newAbsorbed, }); } diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index a4e7882..c48d29c 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -333,11 +333,10 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { // preventing a scenario where the pool is drained after we've already spent tokens. let ftDecision: import("../lib/free-tier.js").FreeTierDecision | null = null; if (evalResult.accepted && session.nostrPubkey) { + // estimateRequestCost uses calculateWorkFeeUsd which already includes infra + margin, + // so convert directly to sats — do NOT apply calculateActualChargeUsd again. const { estimatedCostUsd } = pricingService.estimateRequestCost(requestText, agentService.workModel); - const estimatedSats = usdToSats( - pricingService.calculateActualChargeUsd(estimatedCostUsd), - btcPriceUsd, - ); + const estimatedSats = usdToSats(estimatedCostUsd, btcPriceUsd); ftDecision = await freeTierService.decide(session.nostrPubkey, estimatedSats); }