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,13 @@
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core";
import { nostrIdentities } from "./nostr-identities";
export const freeTierGrants = pgTable("free_tier_grants", {
id: text("id").primaryKey(),
pubkey: text("pubkey").notNull().references(() => nostrIdentities.pubkey),
requestHash: text("request_hash").notNull(),
satsAbsorbed: integer("sats_absorbed").notNull(),
poolBalanceAfter: integer("pool_balance_after").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export type FreeTierGrant = typeof freeTierGrants.$inferSelect;

View File

@@ -6,3 +6,5 @@ export * from "./bootstrap-jobs";
export * from "./world-events";
export * from "./sessions";
export * from "./nostr-identities";
export * from "./timmy-config";
export * from "./free-tier-grants";

View File

@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, integer, real } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, integer, real, boolean } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { nostrIdentities } from "./nostr-identities";
@@ -40,6 +40,12 @@ export const jobs = pgTable("jobs", {
// Optional Nostr identity bound at job creation (FK → nostr_identities.pubkey)
nostrPubkey: text("nostr_pubkey").references(() => nostrIdentities.pubkey),
// ── Free-tier routing (Task #27) ─────────────────────────────────────────
// freeTier=true: Timmy absorbed the work cost from the generosity pool.
// absorbedSats: how many sats Timmy absorbed (0 for fully-paid jobs).
freeTier: boolean("free_tier").notNull().default(false),
absorbedSats: integer("absorbed_sats"),
// ── Post-work honest accounting & refund ─────────────────────────────────
actualAmountSats: integer("actual_amount_sats"),
refundAmountSats: integer("refund_amount_sats"),

View File

@@ -0,0 +1,9 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const timmyConfig = pgTable("timmy_config", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export type TimmyConfigRow = typeof timmyConfig.$inferSelect;