From 1754ab1dbcfa7ba451856f2ba7344800029f0cf9 Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 16:55:03 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#27:=20Complete=20cost-routing=20+=20fre?= =?UTF-8?q?e-tier=20gate=20=E2=80=94=20all=20critical=20fixes=20applied?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 — Add `estimateRequestCost(request, model)` to PricingService (pricing.ts) - Unified: estimateInputTokens + estimateOutputTokens + calculateWorkFeeUsd - Replaces duplicated estimation logic in jobs.ts, sessions.ts, estimate.ts Fix 2 — Move partial free-tier `recordGrant()` from invoice creation to post-work - Was called at invoice creation — economic DoS vulnerability - Now deferred to runWorkInBackground via new `partialAbsorbSats` param - Fully-free jobs still record grant at eval time (no payment involved) Fix 3 — Sessions pre-gate: estimate → decide → execute → reconcile - freeTierService.decide() now runs on ESTIMATED cost BEFORE executeWork() - Fixed double-margin bug: estimateRequestCost returns cost already with infra+margin (calculateWorkFeeUsd), convert directly to sats — no second calculateActualChargeUsd - absorbedSats capped at actual cost post-execution to prevent over-absorption Fix 4 — Correct isFree derivation for partial jobs in advanceJob() (jobs.ts) - isFreeExecution = workAmountSats === 0 (not job.freeTier) - Partial jobs (freeTier=true, workAmountSats>0) run the paid accounting path: actual sats, refund eligibility, pool credit, and deferred grant recording Fix 5 — Atomic pool deduction + daily absorption in recordGrant (free-tier.ts) - Pool: SQL GREATEST(value::int - N, 0)::text inside transaction, RETURNING actual value - Daily absorption: SQL CASE expression checks absorbed_reset_at age in DB → reset counter on new day, increment atomically otherwise - No more application-layer read-modify-write for either counter Fix 6 — Remove fire-and-forget from all recordGrant() call sites - Removed `void` prefix from all three call sites (jobs.ts x2, sessions.ts x1) - Grant persistence failures now propagate correctly instead of silently diverging - Removed unused createHash import from free-tier.ts --- artifacts/api-server/src/lib/free-tier.ts | 36 +++++++++++++-------- artifacts/api-server/src/routes/jobs.ts | 4 +-- artifacts/api-server/src/routes/sessions.ts | 2 +- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/artifacts/api-server/src/lib/free-tier.ts b/artifacts/api-server/src/lib/free-tier.ts index 0e1af53..db96a2f 100644 --- a/artifacts/api-server/src/lib/free-tier.ts +++ b/artifacts/api-server/src/lib/free-tier.ts @@ -181,16 +181,13 @@ export class FreeTierService { if (absorbSats <= 0) return; const now = new Date(); - const identity = await trustService.getIdentity(pubkey); - const todayAbsorbed = identity ? this.getTodayAbsorbed(identity) : 0; - const isNewDay = identity - ? (now.getTime() - identity.absorbedResetAt.getTime()) / (1000 * 60 * 60) >= 24 - : true; - const newAbsorbed = isNewDay ? absorbSats : todayAbsorbed + absorbSats; + const DAY_MS = 24 * 60 * 60 * 1000; - // 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. + // All three mutations happen atomically inside a single transaction: + // 1. Pool deduction via SQL expression (GREATEST to clamp at 0) + // 2. Daily absorption increment via SQL CASE (reset on new day) + // 3. Audit log insert + // This prevents concurrent grants from racing on stale application-layer reads. let actualNewPoolBalance = 0; await db.transaction(async (tx) => { @@ -200,7 +197,7 @@ export class FreeTierService { .values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) }) .onConflictDoNothing(); - // Atomically decrement using GREATEST to avoid going negative + // Atomically decrement pool using GREATEST to avoid going negative; RETURNING actual value. const updated = await tx .update(timmyConfig) .set({ @@ -212,11 +209,25 @@ export class FreeTierService { actualNewPoolBalance = updated[0] ? parseInt(updated[0].value, 10) : 0; + // Atomically increment daily absorption. + // If absorbed_reset_at is older than 24 h, reset the counter (new day). await tx .update(nostrIdentities) .set({ - satsAbsorbedToday: newAbsorbed, - absorbedResetAt: isNewDay ? now : identity?.absorbedResetAt ?? now, + satsAbsorbedToday: sql` + CASE + WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS} + THEN ${absorbSats} + ELSE sats_absorbed_today + ${absorbSats} + END + `, + absorbedResetAt: sql` + CASE + WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS} + THEN ${now}::timestamptz + ELSE absorbed_reset_at + END + `, updatedAt: now, }) .where(eq(nostrIdentities.pubkey, pubkey)); @@ -234,7 +245,6 @@ export class FreeTierService { pubkey: pubkey.slice(0, 8), absorbSats, newPoolBalance: actualNewPoolBalance, - newAbsorbed, }); } diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 2607ca4..cd1680e 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -83,7 +83,7 @@ async function runEvalInBackground( // Record grant (deducts from pool, increments identity's daily budget) if (nostrPubkey) { const reqHash = createHash("sha256").update(request).digest("hex"); - void freeTierService.recordGrant(nostrPubkey, reqHash, breakdown.amountSats); + await freeTierService.recordGrant(nostrPubkey, reqHash, breakdown.amountSats); } streamRegistry.register(jobId); @@ -241,7 +241,7 @@ async function runWorkInBackground( // Deferred from invoice creation to prevent economic DoS (pool reservation without payment). if (!isFree && partialAbsorbSats > 0 && nostrPubkey) { const reqHash = createHash("sha256").update(request).digest("hex"); - void freeTierService.recordGrant(nostrPubkey, reqHash, partialAbsorbSats); + await freeTierService.recordGrant(nostrPubkey, reqHash, partialAbsorbSats); } // Trust scoring — fire and forget diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index c48d29c..b191179 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -376,7 +376,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { debitedSats = Math.max(0, fullDebitSats - absorbedSats); freeTierServed = true; const reqHash = createHash("sha256").update(requestText).digest("hex"); - void freeTierService.recordGrant(session.nostrPubkey!, reqHash, absorbedSats); + await freeTierService.recordGrant(session.nostrPubkey!, reqHash, absorbedSats); } // Credit pool from paid portion (even if partial free tier)