## 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)
66 lines
2.7 KiB
TypeScript
66 lines
2.7 KiB
TypeScript
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";
|
|
|
|
export const JOB_STATES = [
|
|
"awaiting_eval_payment",
|
|
"evaluating",
|
|
"rejected",
|
|
"awaiting_work_payment",
|
|
"executing",
|
|
"complete",
|
|
"failed",
|
|
] as const;
|
|
|
|
export type JobState = (typeof JOB_STATES)[number];
|
|
|
|
export const jobs = pgTable("jobs", {
|
|
id: text("id").primaryKey(),
|
|
request: text("request").notNull(),
|
|
state: text("state").$type<JobState>().notNull().default("awaiting_eval_payment"),
|
|
evalInvoiceId: text("eval_invoice_id"),
|
|
workInvoiceId: text("work_invoice_id"),
|
|
evalAmountSats: integer("eval_amount_sats").notNull(),
|
|
workAmountSats: integer("work_amount_sats"),
|
|
rejectionReason: text("rejection_reason"),
|
|
result: text("result"),
|
|
errorMessage: text("error_message"),
|
|
|
|
// ── Cost-based pricing (set when work invoice is created) ───────────────
|
|
estimatedCostUsd: real("estimated_cost_usd"),
|
|
marginPct: real("margin_pct"),
|
|
btcPriceUsd: real("btc_price_usd"),
|
|
|
|
// ── Actual token usage (set after work executes) ────────────────────────
|
|
actualInputTokens: integer("actual_input_tokens"),
|
|
actualOutputTokens: integer("actual_output_tokens"),
|
|
actualCostUsd: real("actual_cost_usd"),
|
|
|
|
// 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"),
|
|
refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(),
|
|
refundPaymentHash: text("refund_payment_hash"),
|
|
|
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
});
|
|
|
|
export const insertJobSchema = createInsertSchema(jobs).omit({
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
});
|
|
|
|
export type Job = typeof jobs.$inferSelect;
|
|
export type InsertJob = z.infer<typeof insertJobSchema>;
|