Task #27: Cost-routing + free-tier gate

## What was built

### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns

### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
  — all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
  — checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
  work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
  updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring

### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
  intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
  Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
  balance debit. Session balance only reduced by `chargeSats`; absorbed portion
  comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.

### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
  (if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets

### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot

## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)
This commit is contained in:
alexpaynex
2026-03-19 16:34:05 +00:00
parent b664ee9b2f
commit 4c3a0e867a
9 changed files with 502 additions and 27 deletions

View File

@@ -0,0 +1,250 @@
import { createHash } from "crypto";
import { randomUUID } from "crypto";
import { db, timmyConfig, freeTierGrants, nostrIdentities, type NostrIdentity, type TrustTier } from "@workspace/db";
import { eq } 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<number> {
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<FreeTierDecision> {
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<void> {
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<void> {
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 poolBalance = await this.getPoolBalance();
const newPoolBalance = Math.max(0, poolBalance - absorbSats);
await db.transaction(async (tx) => {
await tx
.insert(timmyConfig)
.values({ key: POOL_KEY, value: String(newPoolBalance), updatedAt: now })
.onConflictDoUpdate({
target: timmyConfig.key,
set: { value: String(newPoolBalance), updatedAt: now },
});
await tx
.update(nostrIdentities)
.set({
satsAbsorbedToday: newAbsorbed,
absorbedResetAt: isNewDay ? now : identity?.absorbedResetAt ?? now,
updatedAt: now,
})
.where(eq(nostrIdentities.pubkey, pubkey));
await tx.insert(freeTierGrants).values({
id: randomUUID(),
pubkey,
requestHash,
satsAbsorbed: absorbSats,
poolBalanceAfter: newPoolBalance,
});
});
logger.info("free-tier grant recorded", {
pubkey: pubkey.slice(0, 8),
absorbSats,
newPoolBalance,
newAbsorbed,
});
}
/**
* Pool status snapshot — for health/metrics endpoints.
*/
async poolStatus(): Promise<{
balanceSats: number;
budgets: Record<TrustTier, number>;
}> {
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();