import { randomUUID } from "crypto"; import { db, timmyConfig, freeTierGrants, nostrIdentities, type TrustTier } from "@workspace/db"; import { eq, sql } from "drizzle-orm"; import { makeLogger } from "./logger.js"; import { trustService } from "./trust.js"; const logger = makeLogger("free-tier"); // ── Env-var helpers ──────────────────────────────────────────────────────────── function envInt(name: string, fallback: number): number { const raw = parseInt(process.env[name] ?? "", 10); return Number.isFinite(raw) && raw >= 0 ? raw : fallback; } function envFloat(name: string, fallback: number): number { const raw = parseFloat(process.env[name] ?? ""); return Number.isFinite(raw) && raw >= 0 ? raw : fallback; } // ── Daily free budget per trust tier (sats) ─────────────────────────────────── // Override via env vars. "new" tier gets 0 — must earn trust first. const DAILY_BUDGET_NEW = envInt("FREE_TIER_BUDGET_NEW", 0); const DAILY_BUDGET_ESTABLISHED = envInt("FREE_TIER_BUDGET_ESTABLISHED", 50); const DAILY_BUDGET_TRUSTED = envInt("FREE_TIER_BUDGET_TRUSTED", 200); const DAILY_BUDGET_ELITE = envInt("FREE_TIER_BUDGET_ELITE", 1_000); // ── Generosity pool ─────────────────────────────────────────────────────────── // Pool starts at POOL_INITIAL_SATS on first boot. Grows by POOL_CREDIT_PCT of // every paid work invoice. Never drops below 0. const POOL_INITIAL_SATS = envInt("FREE_TIER_POOL_INITIAL_SATS", 10_000); const POOL_CREDIT_PCT = envFloat("FREE_TIER_POOL_CREDIT_PCT", 10); const POOL_KEY = "generosity_pool_sats"; const DAY_MS = 24 * 60 * 60 * 1000; // ── Types ───────────────────────────────────────────────────────────────────── export interface FreeTierDecision { serve: "free" | "partial" | "gate"; absorbSats: number; chargeSats: number; } // ── FreeTierService ─────────────────────────────────────────────────────────── export class FreeTierService { // Daily sat budget for each trust tier. dailyBudgetForTier(tier: TrustTier): number { switch (tier) { case "elite": return DAILY_BUDGET_ELITE; case "trusted": return DAILY_BUDGET_TRUSTED; case "established": return DAILY_BUDGET_ESTABLISHED; case "new": default: return DAILY_BUDGET_NEW; } } // Read the current pool balance; initialise to POOL_INITIAL_SATS on first call. async getPoolBalance(): Promise { const rows = await db .select() .from(timmyConfig) .where(eq(timmyConfig.key, POOL_KEY)) .limit(1); if (rows[0]) return parseInt(rows[0].value, 10) || 0; // First boot — seed the pool await db .insert(timmyConfig) .values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) }) .onConflictDoNothing(); logger.info("generosity pool initialised", { initialSats: POOL_INITIAL_SATS }); return POOL_INITIAL_SATS; } /** * Decide whether to serve a request for free, partially free, or at full cost. * * For serve="free": the pool is atomically debited for absorbSats immediately. * This is correct because work starts right away — no window where the reservation * can be abandoned. If work fails, call releaseReservation() to refund the pool. * * For serve="partial": the decision is advisory — pool is NOT debited yet. * The user still needs to pay their portion before work starts. Pool debit happens * later via reservePartialGrant() once payment is confirmed, so the pool is never * depleted by a user who never pays. * * For serve="gate": no action taken (no pool debit). * * Returns: * serve="free" → absorbSats=estimatedSats (already debited), chargeSats=0 * serve="partial" → 0 < absorbSats < estimatedSats (advisory), chargeSats = remainder * serve="gate" → absorbSats=0, chargeSats=estimatedSats (full charge) */ async decide(pubkey: string | null, estimatedSats: number): Promise { const gate: FreeTierDecision = { serve: "gate", absorbSats: 0, chargeSats: estimatedSats }; if (!pubkey || estimatedSats <= 0) return gate; const identity = await trustService.getIdentityWithDecay(pubkey); if (!identity) return gate; const dailyBudget = this.dailyBudgetForTier(identity.tier); if (dailyBudget === 0) return gate; const todayAbsorbed = (Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS ? 0 : identity.satsAbsorbedToday; const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed); if (dailyRemaining === 0) { logger.info("free-tier: daily budget exhausted", { pubkey: pubkey.slice(0, 8), tier: identity.tier, todayAbsorbed, dailyBudget, }); return gate; } const poolBalance = await this.getPoolBalance(); if (poolBalance <= 0) { logger.warn("free-tier: pool empty", { pubkey: pubkey.slice(0, 8) }); return gate; } const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats); if (canAbsorb <= 0) return gate; if (canAbsorb >= estimatedSats) { // Fully free — atomically debit the pool now, work starts immediately after this. const debited = await this._atomicPoolDebit(canAbsorb); if (debited <= 0) { // Pool drained between balance read and debit (concurrent request) logger.warn("free-tier: pool drained between check and debit", { pubkey: pubkey.slice(0, 8) }); return gate; } logger.info("free-tier: reserved free (pool debited)", { pubkey: pubkey.slice(0, 8), tier: identity.tier, absorbSats: debited, }); return { serve: "free", absorbSats: debited, chargeSats: 0 }; } // Partial: advisory only — pool debit deferred until payment confirmed. logger.info("free-tier: partial subsidy (advisory, pool debit deferred)", { pubkey: pubkey.slice(0, 8), tier: identity.tier, absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb, }); return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb }; } /** * Atomically debit the generosity pool inside a transaction with row-level lock. * Returns the actual amount debited (may be less than requested if pool is low). * Returns 0 if pool has insufficient funds. */ private async _atomicPoolDebit(requestedSats: number): Promise { if (requestedSats <= 0) return 0; let actualDebited = 0; await db.transaction(async (tx) => { // Ensure pool row exists await tx .insert(timmyConfig) .values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) }) .onConflictDoNothing(); // Lock pool row for the duration of this transaction const locked = await tx.execute( sql`SELECT value::int AS balance FROM timmy_config WHERE key = ${POOL_KEY} FOR UPDATE`, ); const poolBalance = (locked.rows[0] as { balance: number } | undefined)?.balance ?? 0; if (poolBalance <= 0) return; // pool empty, actualDebited stays 0 actualDebited = Math.min(requestedSats, poolBalance); const newPoolBalance = poolBalance - actualDebited; const now = new Date(); await tx .update(timmyConfig) .set({ value: String(newPoolBalance), updatedAt: now }) .where(eq(timmyConfig.key, POOL_KEY)); }); return actualDebited; } /** * Atomically reserve and debit the pool for a partial grant at payment confirmation time. * Called in advanceJob() when work payment is confirmed, BEFORE starting work. * * Returns the actual sats debited (may be less than requested if pool drained since decide()). * If returns 0, pool is now empty — caller should recalculate charge or gate the request. */ async reservePartialGrant(requestedAbsorbSats: number, pubkey: string): Promise { if (requestedAbsorbSats <= 0) return 0; // Re-check daily limits before debiting (advisory decide() could be stale) const identity = await trustService.getIdentityWithDecay(pubkey); if (!identity) return 0; const dailyBudget = this.dailyBudgetForTier(identity.tier); const todayAbsorbed = (Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS ? 0 : identity.satsAbsorbedToday; const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed); const canAbsorb = Math.min(requestedAbsorbSats, dailyRemaining); if (canAbsorb <= 0) return 0; const debited = await this._atomicPoolDebit(canAbsorb); logger.info("free-tier: partial grant reserved at payment time", { pubkey: pubkey.slice(0, 8), requestedAbsorbSats, canAbsorb, debited, }); return debited; } /** * Release a pool reservation when a request is rejected, fails, or is not executed. * Returns `absorbSats` to the generosity pool. * Only call if decide() debited the pool (serve="free" path). */ async releaseReservation(absorbSats: number, reason: string): Promise { if (absorbSats <= 0) return; const now = new Date(); await db .update(timmyConfig) .set({ value: sql`(value::int + ${absorbSats})::text`, updatedAt: now, }) .where(eq(timmyConfig.key, POOL_KEY)); logger.info("free-tier: reservation released", { absorbSats, reason }); } /** * Credit the generosity pool from a paid interaction. * Called after a work invoice is paid and work completes successfully. * creditSats = paidSats * POOL_CREDIT_PCT / 100 */ async credit(paidSats: number): Promise { if (paidSats <= 0) return; const creditSats = Math.floor(paidSats * POOL_CREDIT_PCT / 100); if (creditSats <= 0) return; const now = new Date(); await db .insert(timmyConfig) .values({ key: POOL_KEY, value: String(creditSats), updatedAt: now }) .onConflictDoUpdate({ target: timmyConfig.key, set: { value: sql`(timmy_config.value::int + ${creditSats})::text`, updatedAt: now, }, }); logger.info("generosity pool credited", { paidSats, creditSats }); } /** * Record the audit log entry and update daily absorption counters AFTER work completes. * * The pool was already debited (by decide() for free jobs, by reservePartialGrant() * for partial jobs). This call only writes the audit row and updates daily absorption. * * `actualAbsorbed` = actual cost in sats (may be less than reservedAbsorbed due to * actual token usage being lower than estimate). * `reservedAbsorbed` = amount debited from pool at reservation time. * Any over-reservation (reservedAbsorbed - actualAbsorbed) is released back to pool. */ async recordGrant( pubkey: string, requestHash: string, actualAbsorbed: number, reservedAbsorbed: number, ): Promise { const toRecord = Math.max(0, actualAbsorbed); const overReserved = Math.max(0, reservedAbsorbed - toRecord); if (toRecord <= 0 && overReserved <= 0) return; const now = new Date(); await db.transaction(async (tx) => { // Return over-reservation to pool (estimated > actual cost) if (overReserved > 0) { await tx .update(timmyConfig) .set({ value: sql`(value::int + ${overReserved})::text`, updatedAt: now, }) .where(eq(timmyConfig.key, POOL_KEY)); } if (toRecord > 0) { // Update identity daily absorption (CASE handles new-day reset atomically) await tx .update(nostrIdentities) .set({ satsAbsorbedToday: sql` CASE WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS} THEN ${toRecord} ELSE sats_absorbed_today + ${toRecord} 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)); const poolRows = await tx.select().from(timmyConfig).where(eq(timmyConfig.key, POOL_KEY)).limit(1); const poolBalanceAfter = poolRows[0] ? parseInt(poolRows[0].value, 10) : 0; await tx.insert(freeTierGrants).values({ id: randomUUID(), pubkey, requestHash, satsAbsorbed: toRecord, poolBalanceAfter, }); } }); logger.info("free-tier grant recorded", { pubkey: pubkey.slice(0, 8), actualAbsorbed: toRecord, reservedAbsorbed, overReserved, }); } /** * Pool status snapshot — for health/metrics endpoints. */ async poolStatus(): Promise<{ balanceSats: number; budgets: Record; }> { const balanceSats = await this.getPoolBalance(); return { balanceSats, budgets: { new: DAILY_BUDGET_NEW, established: DAILY_BUDGET_ESTABLISHED, trusted: DAILY_BUDGET_TRUSTED, elite: DAILY_BUDGET_ELITE, }, }; } } export const freeTierService = new FreeTierService();