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:
13
lib/db/src/schema/free-tier-grants.ts
Normal file
13
lib/db/src/schema/free-tier-grants.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
9
lib/db/src/schema/timmy-config.ts
Normal file
9
lib/db/src/schema/timmy-config.ts
Normal 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;
|
||||
Reference in New Issue
Block a user