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)