diff --git a/artifacts/api-server/src/lib/free-tier.ts b/artifacts/api-server/src/lib/free-tier.ts new file mode 100644 index 0000000..80557f4 --- /dev/null +++ b/artifacts/api-server/src/lib/free-tier.ts @@ -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 { + 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 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; + }> { + 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(); diff --git a/artifacts/api-server/src/routes/estimate.ts b/artifacts/api-server/src/routes/estimate.ts new file mode 100644 index 0000000..a777a01 --- /dev/null +++ b/artifacts/api-server/src/routes/estimate.ts @@ -0,0 +1,83 @@ +import { Router, type Request, type Response } from "express"; +import { pricingService } from "../lib/pricing.js"; +import { agentService } from "../lib/agent.js"; +import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js"; +import { freeTierService } from "../lib/free-tier.js"; +import { trustService } from "../lib/trust.js"; + +const router = Router(); + +/** + * GET /api/estimate?request=[&nostr_token=] + * + * Returns a pre-flight cost estimate for a request, including free-tier + * status for the authenticated identity (if a valid nostr_token is supplied). + * + * No payment required. Does not create a job. + */ +router.get("/estimate", async (req: Request, res: Response) => { + const requestText = + typeof req.query.request === "string" ? req.query.request.trim() : ""; + + if (!requestText) { + res.status(400).json({ error: "Query param 'request' is required" }); + return; + } + + try { + const inputTokens = pricingService.estimateInputTokens(requestText); + const outputTokens = pricingService.estimateOutputTokens(requestText); + const btcPriceUsd = await getBtcPriceUsd(); + const costUsd = pricingService.calculateWorkFeeUsd(inputTokens, outputTokens, agentService.workModel); + const estimatedSats = usdToSats(costUsd, btcPriceUsd); + + // Optionally resolve Nostr identity from query param or header for free-tier preview + const rawToken = + (req.headers["x-nostr-token"] as string | undefined) ?? + (typeof req.query.nostr_token === "string" ? req.query.nostr_token : undefined); + + let pubkey: string | null = null; + let trustTier = "anonymous"; + let freeTierDecision: { serve: string; absorbSats: number; chargeSats: number } | null = null; + + if (rawToken) { + const parsed = trustService.verifyToken(rawToken.trim()); + if (parsed) { + pubkey = parsed.pubkey; + trustTier = await trustService.getTier(pubkey); + const decision = await freeTierService.decide(pubkey, estimatedSats); + freeTierDecision = { + serve: decision.serve, + absorbSats: decision.absorbSats, + chargeSats: decision.chargeSats, + }; + } + } + + const poolStatus = await freeTierService.poolStatus(); + + res.json({ + estimatedSats, + estimatedCostUsd: costUsd, + btcPriceUsd, + tokenEstimate: { + inputTokens, + outputTokens, + model: agentService.workModel, + }, + identity: { + trust_tier: trustTier, + ...(pubkey ? { pubkey } : {}), + ...(freeTierDecision ? { free_tier: freeTierDecision } : {}), + }, + pool: { + balanceSats: poolStatus.balanceSats, + dailyBudgets: poolStatus.budgets, + }, + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : "Estimate failed" }); + } +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 4a5f0c3..7703ba2 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -11,12 +11,14 @@ import nodeDiagnosticsRouter from "./node-diagnostics.js"; import metricsRouter from "./metrics.js"; import worldRouter from "./world.js"; import identityRouter from "./identity.js"; +import estimateRouter from "./estimate.js"; const router: IRouter = Router(); router.use(healthRouter); router.use(metricsRouter); router.use(jobsRouter); +router.use(estimateRouter); router.use(bootstrapRouter); router.use(sessionsRouter); router.use(identityRouter); diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 408d39f..c21b899 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -1,5 +1,5 @@ import { Router, type Request, type Response } from "express"; -import { randomUUID } from "crypto"; +import { randomUUID, createHash } from "crypto"; import { db, jobs, invoices, type Job } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { CreateJobBody, GetJobParams } from "@workspace/api-zod"; @@ -12,6 +12,7 @@ import { streamRegistry } from "../lib/stream-registry.js"; import { makeLogger } from "../lib/logger.js"; import { latencyHistogram } from "../lib/histogram.js"; import { trustService } from "../lib/trust.js"; +import { freeTierService } from "../lib/free-tier.js"; const logger = makeLogger("jobs"); @@ -30,8 +31,13 @@ async function getInvoiceById(id: string) { /** * Runs the AI eval in a background task (fire-and-forget) so HTTP polls * return immediately with "evaluating" state instead of blocking 5-8 seconds. + * nostrPubkey is used for free-tier routing and trust scoring. */ -async function runEvalInBackground(jobId: string, request: string): Promise { +async function runEvalInBackground( + jobId: string, + request: string, + nostrPubkey: string | null, +): Promise { const evalStart = Date.now(); try { const evalResult = await agentService.evaluateRequest(request); @@ -54,8 +60,49 @@ async function runEvalInBackground(jobId: string, request: string): Promise { + void runWorkInBackground( + jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey, + ); + }); + return; + } + + // Partial subsidy or full gate: invoice amount = chargeSats + const invoiceSats = ftDecision.serve === "partial" + ? ftDecision.chargeSats + : breakdown.amountSats; + const workInvoiceData = await lnbitsService.createInvoice( - breakdown.amountSats, + invoiceSats, `Work fee for job ${jobId}`, ); const workInvoiceId = randomUUID(); @@ -66,7 +113,7 @@ async function runEvalInBackground(jobId: string, request: string): Promise { +async function runWorkInBackground( + jobId: string, + request: string, + workAmountSats: number, + btcPriceUsd: number | null, + isFree = false, + nostrPubkey: string | null = null, +): Promise { const workStart = Date.now(); try { eventBus.publish({ type: "job:state", jobId, state: "executing" }); @@ -136,9 +202,12 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat ); const lockedBtcPrice = btcPriceUsd ?? 100_000; - const actualAmountSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice); - const refundAmountSats = pricingService.calculateRefundSats(workAmountSats, actualAmountSats); - const refundState = refundAmountSats > 0 ? "pending" : "not_applicable"; + // For free-tier jobs the user paid nothing — no refund is applicable. + const actualAmountSats = isFree ? 0 : pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice); + const refundAmountSats = isFree ? 0 : pricingService.calculateRefundSats(workAmountSats, actualAmountSats); + const refundState: "not_applicable" | "pending" = isFree + ? "not_applicable" + : (refundAmountSats > 0 ? "pending" : "not_applicable"); await db .update(jobs) @@ -157,6 +226,7 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat logger.info("work completed", { jobId, + isFree, inputTokens: workResult.inputTokens, outputTokens: workResult.outputTokens, actualAmountSats, @@ -165,10 +235,15 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat }); eventBus.publish({ type: "job:completed", jobId, result: workResult.result }); + // Credit the generosity pool from paid interactions + if (!isFree && workAmountSats > 0) { + void freeTierService.credit(workAmountSats); + } + // Trust scoring — fire and forget - const completedJob = await getJobById(jobId); - if (completedJob?.nostrPubkey) { - void trustService.recordSuccess(completedJob.nostrPubkey, actualAmountSats); + const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null; + if (pubkeyForTrust) { + void trustService.recordSuccess(pubkeyForTrust, actualAmountSats); } } catch (err) { const message = err instanceof Error ? err.message : "Execution error"; @@ -180,9 +255,9 @@ async function runWorkInBackground(jobId: string, request: string, workAmountSat eventBus.publish({ type: "job:failed", jobId, reason: message }); // Trust scoring — penalise on work failure - const failedJob = await getJobById(jobId); - if (failedJob?.nostrPubkey) { - void trustService.recordFailure(failedJob.nostrPubkey, message); + const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null; + if (pubkeyForTrust) { + void trustService.recordFailure(pubkeyForTrust, message); } } } @@ -220,7 +295,7 @@ async function advanceJob(job: Job): Promise { eventBus.publish({ type: "job:state", jobId: job.id, state: "evaluating" }); // Fire AI eval in background — poll returns immediately with "evaluating" - setImmediate(() => { void runEvalInBackground(job.id, job.request); }); + setImmediate(() => { void runEvalInBackground(job.id, job.request, job.nostrPubkey ?? null); }); return getJobById(job.id); } @@ -254,7 +329,16 @@ async function advanceJob(job: Job): Promise { streamRegistry.register(job.id); // Fire AI work in background — poll returns immediately with "executing" - setImmediate(() => { void runWorkInBackground(job.id, job.request, job.workAmountSats ?? 0, job.btcPriceUsd); }); + setImmediate(() => { + void runWorkInBackground( + job.id, + job.request, + job.workAmountSats ?? 0, + job.btcPriceUsd, + job.freeTier ?? false, + job.nostrPubkey ?? null, + ); + }); return getJobById(job.id); } @@ -386,6 +470,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { completedAt: job.state === "complete" ? job.updatedAt.toISOString() : null, ...(job.nostrPubkey ? { nostrPubkey: job.nostrPubkey } : {}), trust_tier: trustTier, + ...(job.freeTier ? { free_tier: true, absorbed_sats: job.absorbedSats ?? 0 } : {}), }; switch (job.state) { diff --git a/artifacts/api-server/src/routes/sessions.ts b/artifacts/api-server/src/routes/sessions.ts index 4467ba3..b092f46 100644 --- a/artifacts/api-server/src/routes/sessions.ts +++ b/artifacts/api-server/src/routes/sessions.ts @@ -1,5 +1,5 @@ import { Router, type Request, type Response } from "express"; -import { randomBytes, randomUUID } from "crypto"; +import { randomBytes, randomUUID, createHash } from "crypto"; import { db, sessions, sessionRequests, type Session } from "@workspace/db"; import { eq, and } from "drizzle-orm"; import { lnbitsService } from "../lib/lnbits.js"; @@ -9,6 +9,7 @@ import { agentService } from "../lib/agent.js"; import { pricingService } from "../lib/pricing.js"; import { getBtcPriceUsd, usdToSats } from "../lib/btc-oracle.js"; import { trustService } from "../lib/trust.js"; +import { freeTierService } from "../lib/free-tier.js"; const router = Router(); @@ -350,7 +351,28 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { // ── Honest accounting ──────────────────────────────────────────────────── const totalTokenCostUsd = evalCostUsd + workCostUsd; const chargeUsd = pricingService.calculateActualChargeUsd(totalTokenCostUsd); - const debitedSats = usdToSats(chargeUsd, btcPriceUsd); + const fullDebitSats = usdToSats(chargeUsd, btcPriceUsd); + + // ── Free-tier gate (only on successful requests) ───────────────────────── + let debitedSats = fullDebitSats; + let freeTierServed = false; + let absorbedSats = 0; + + if (finalState === "complete" && session.nostrPubkey) { + const ftDecision = await freeTierService.decide(session.nostrPubkey, fullDebitSats); + if (ftDecision.serve !== "gate") { + absorbedSats = ftDecision.absorbSats; + debitedSats = ftDecision.chargeSats; + freeTierServed = true; + const reqHash = createHash("sha256").update(requestText).digest("hex"); + void freeTierService.recordGrant(session.nostrPubkey, reqHash, absorbedSats); + } + } + + // Credit pool from paid portion (even if partial free tier) + if (finalState === "complete" && debitedSats > 0) { + void freeTierService.credit(debitedSats); + } const newBalance = session.balanceSats - debitedSats; const newSessionState = newBalance < MIN_BALANCE_SATS ? "paused" : "active"; @@ -403,6 +425,7 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { ...(errorMessage ? { errorMessage } : {}), debitedSats, balanceRemaining: newBalance, + ...(freeTierServed ? { free_tier: true, absorbed_sats: absorbedSats } : {}), cost: { evalSats: usdToSats( pricingService.calculateActualChargeUsd(evalCostUsd), @@ -411,7 +434,9 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => { workSats: workCostUsd > 0 ? usdToSats(pricingService.calculateActualChargeUsd(workCostUsd), btcPriceUsd) : 0, - totalSats: debitedSats, + totalSats: fullDebitSats, + chargedSats: debitedSats, + absorbedSats, btcPriceUsd, }, }); diff --git a/lib/db/src/schema/free-tier-grants.ts b/lib/db/src/schema/free-tier-grants.ts new file mode 100644 index 0000000..62ed1e6 --- /dev/null +++ b/lib/db/src/schema/free-tier-grants.ts @@ -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; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index c47de51..e893452 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -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"; diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index 5c67172..b5538c0 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -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"), diff --git a/lib/db/src/schema/timmy-config.ts b/lib/db/src/schema/timmy-config.ts new file mode 100644 index 0000000..61f137a --- /dev/null +++ b/lib/db/src/schema/timmy-config.ts @@ -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;