import { randomUUID } from "crypto"; import { db, timmyConfig, freeTierGrants, nostrIdentities, type NostrIdentity, 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"; // ── 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; } // How many sats has this identity absorbed today (resets after 24 h)? getTodayAbsorbed(identity: NostrIdentity): number { const hoursSinceReset = (Date.now() - identity.absorbedResetAt.getTime()) / (1000 * 60 * 60); if (hoursSinceReset >= 24) return 0; return identity.satsAbsorbedToday; } /** * Decide whether to serve a request for free, partially free, or at full cost. * * Returns: * serve="free" → absorbSats=estimatedSats, chargeSats=0 * serve="partial" → 0 < absorbSats < estimatedSats, chargeSats = remainder * serve="gate" → absorbSats=0, chargeSats=estimatedSats (pay full price) */ 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 = this.getTodayAbsorbed(identity); 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", { poolBalance }); return gate; } const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats); if (canAbsorb >= estimatedSats) { logger.info("free-tier: serving free", { pubkey: pubkey.slice(0, 8), tier: identity.tier, estimatedSats, poolBalance, }); return { serve: "free", absorbSats: estimatedSats, chargeSats: 0 }; } if (canAbsorb > 0) { logger.info("free-tier: partial subsidy", { pubkey: pubkey.slice(0, 8), tier: identity.tier, absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb, }); return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb }; } return gate; } /** * 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 current = await this.getPoolBalance(); const next = current + creditSats; const now = new Date(); await db .insert(timmyConfig) .values({ key: POOL_KEY, value: String(next), updatedAt: now }) .onConflictDoUpdate({ target: timmyConfig.key, set: { value: String(next), updatedAt: now }, }); logger.info("generosity pool credited", { paidSats, creditSats, poolBalance: next }); } /** * Record that Timmy absorbed `absorbSats` on behalf of `pubkey`. * Atomically: deducts from pool, increments identity's daily absorption, writes audit row. */ async recordGrant( pubkey: string, requestHash: string, absorbSats: number, ): Promise { if (absorbSats <= 0) return; const now = new Date(); const DAY_MS = 24 * 60 * 60 * 1000; // All three mutations happen atomically inside a single transaction with row-locking: // 1. Lock + read pool row (FOR UPDATE), compute actual deductible amount // 2. Pool deduction capped to available balance // 3. Daily absorption increment via SQL CASE (reset on new day), capped to actualAbsorbed // 4. Audit log insert with accurate absorbed amount // Using FOR UPDATE ensures concurrent grants cannot over-debit the pool. let actualAbsorbed = 0; 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(POOL_INITIAL_SATS) }) .onConflictDoNothing(); // Lock the pool row for this transaction; read current balance. 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; // Cap actual absorption to what the pool can cover. actualAbsorbed = Math.min(absorbSats, poolBalance); if (actualAbsorbed <= 0) { // Pool is empty; nothing to absorb — roll back silently. return; } actualNewPoolBalance = poolBalance - actualAbsorbed; await tx .update(timmyConfig) .set({ value: String(actualNewPoolBalance), updatedAt: now }) .where(eq(timmyConfig.key, POOL_KEY)); // Atomically increment daily absorption by the actual absorbed amount. // If absorbed_reset_at is older than 24 h, reset the counter (new day). await tx .update(nostrIdentities) .set({ satsAbsorbedToday: sql` CASE WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS} THEN ${actualAbsorbed} ELSE sats_absorbed_today + ${actualAbsorbed} 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)); await tx.insert(freeTierGrants).values({ id: randomUUID(), pubkey, requestHash, satsAbsorbed: actualAbsorbed, poolBalanceAfter: actualNewPoolBalance, }); }); if (actualAbsorbed > 0) { logger.info("free-tier grant recorded", { pubkey: pubkey.slice(0, 8), requestedSats: absorbSats, actualAbsorbed, newPoolBalance: actualNewPoolBalance, }); } else { logger.warn("free-tier grant skipped: pool empty at grant time", { pubkey: pubkey.slice(0, 8), requestedSats: absorbSats, }); } } /** * 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();