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