Task #27: Complete cost-routing + free-tier gate — all critical fixes applied
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
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user