Root cause: decide() was advisory but user charges were reduced from its output;
recordGrant() later might absorb less, so Timmy could absorb the gap silently.
Fix architecture (serve="free" path — fully-free jobs + sessions):
- decide() now runs _atomicPoolDebit() inside a FOR UPDATE transaction
- Pool is debited at decision time for serve="free" decisions
- Work starts immediately after, so no window for pool drain between debit and use
- If work fails → releaseReservation() returns sats to pool
Fix architecture (serve="partial" path — partial-subsidy jobs):
- decide() remains advisory for "partial" (no pool debit at decision time)
- This prevents pool drain from users who get a partial offer but never pay
- For jobs: reservePartialGrant() atomically debits pool at work-payment-confirmation
time (inside advanceJob), before work begins
- For sessions: reservePartialGrant() called after synchronous work completes,
using actual cost capped by advisory absorbSats
recordGrant() now takes (pubkey, requestHash, actualAbsorbed, reservedAbsorbed):
- Over-reservation (estimated > actual) returned to pool atomically
- Audit log and daily counter reflect actual absorbed amount
- Pool balance was already decremented by decide() or reservePartialGrant()
Result: In ALL paths, pool debit happens atomically before charges are reduced.
User charge reduction and pool debit are always consistent — Timmy never operates
at a loss due to concurrent pool depletion.
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
import { randomUUID } from "crypto";
|
|
import { db, timmyConfig, freeTierGrants, nostrIdentities, type TrustTier } from "@workspace/db";
|
|
import { eq, sql } 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";
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
/**
|
|
* Decide whether to serve a request for free, partially free, or at full cost.
|
|
*
|
|
* For serve="free": the pool is atomically debited for absorbSats immediately.
|
|
* This is correct because work starts right away — no window where the reservation
|
|
* can be abandoned. If work fails, call releaseReservation() to refund the pool.
|
|
*
|
|
* For serve="partial": the decision is advisory — pool is NOT debited yet.
|
|
* The user still needs to pay their portion before work starts. Pool debit happens
|
|
* later via reservePartialGrant() once payment is confirmed, so the pool is never
|
|
* depleted by a user who never pays.
|
|
*
|
|
* For serve="gate": no action taken (no pool debit).
|
|
*
|
|
* Returns:
|
|
* serve="free" → absorbSats=estimatedSats (already debited), chargeSats=0
|
|
* serve="partial" → 0 < absorbSats < estimatedSats (advisory), chargeSats = remainder
|
|
* serve="gate" → absorbSats=0, chargeSats=estimatedSats (full charge)
|
|
*/
|
|
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 =
|
|
(Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS
|
|
? 0
|
|
: identity.satsAbsorbedToday;
|
|
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", { pubkey: pubkey.slice(0, 8) });
|
|
return gate;
|
|
}
|
|
|
|
const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats);
|
|
if (canAbsorb <= 0) return gate;
|
|
|
|
if (canAbsorb >= estimatedSats) {
|
|
// Fully free — atomically debit the pool now, work starts immediately after this.
|
|
const debited = await this._atomicPoolDebit(canAbsorb);
|
|
if (debited <= 0) {
|
|
// Pool drained between balance read and debit (concurrent request)
|
|
logger.warn("free-tier: pool drained between check and debit", { pubkey: pubkey.slice(0, 8) });
|
|
return gate;
|
|
}
|
|
logger.info("free-tier: reserved free (pool debited)", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
tier: identity.tier,
|
|
absorbSats: debited,
|
|
});
|
|
return { serve: "free", absorbSats: debited, chargeSats: 0 };
|
|
}
|
|
|
|
// Partial: advisory only — pool debit deferred until payment confirmed.
|
|
logger.info("free-tier: partial subsidy (advisory, pool debit deferred)", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
tier: identity.tier,
|
|
absorbSats: canAbsorb,
|
|
chargeSats: estimatedSats - canAbsorb,
|
|
});
|
|
return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb };
|
|
}
|
|
|
|
/**
|
|
* Atomically debit the generosity pool inside a transaction with row-level lock.
|
|
* Returns the actual amount debited (may be less than requested if pool is low).
|
|
* Returns 0 if pool has insufficient funds.
|
|
*/
|
|
private async _atomicPoolDebit(requestedSats: number): Promise<number> {
|
|
if (requestedSats <= 0) return 0;
|
|
|
|
let actualDebited = 0;
|
|
|
|
await db.transaction(async (tx) => {
|
|
// Ensure pool row exists
|
|
await tx
|
|
.insert(timmyConfig)
|
|
.values({ key: POOL_KEY, value: String(POOL_INITIAL_SATS) })
|
|
.onConflictDoNothing();
|
|
|
|
// Lock pool row for the duration of this transaction
|
|
const locked = await tx.execute(
|
|
sql`SELECT value::int AS balance FROM timmy_config WHERE key = ${POOL_KEY} FOR UPDATE`,
|
|
);
|
|
const poolBalance = (locked.rows[0] as { balance: number } | undefined)?.balance ?? 0;
|
|
if (poolBalance <= 0) return; // pool empty, actualDebited stays 0
|
|
|
|
actualDebited = Math.min(requestedSats, poolBalance);
|
|
const newPoolBalance = poolBalance - actualDebited;
|
|
const now = new Date();
|
|
|
|
await tx
|
|
.update(timmyConfig)
|
|
.set({ value: String(newPoolBalance), updatedAt: now })
|
|
.where(eq(timmyConfig.key, POOL_KEY));
|
|
});
|
|
|
|
return actualDebited;
|
|
}
|
|
|
|
/**
|
|
* Atomically reserve and debit the pool for a partial grant at payment confirmation time.
|
|
* Called in advanceJob() when work payment is confirmed, BEFORE starting work.
|
|
*
|
|
* Returns the actual sats debited (may be less than requested if pool drained since decide()).
|
|
* If returns 0, pool is now empty — caller should recalculate charge or gate the request.
|
|
*/
|
|
async reservePartialGrant(requestedAbsorbSats: number, pubkey: string): Promise<number> {
|
|
if (requestedAbsorbSats <= 0) return 0;
|
|
|
|
// Re-check daily limits before debiting (advisory decide() could be stale)
|
|
const identity = await trustService.getIdentityWithDecay(pubkey);
|
|
if (!identity) return 0;
|
|
|
|
const dailyBudget = this.dailyBudgetForTier(identity.tier);
|
|
const todayAbsorbed =
|
|
(Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS
|
|
? 0
|
|
: identity.satsAbsorbedToday;
|
|
const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed);
|
|
const canAbsorb = Math.min(requestedAbsorbSats, dailyRemaining);
|
|
if (canAbsorb <= 0) return 0;
|
|
|
|
const debited = await this._atomicPoolDebit(canAbsorb);
|
|
logger.info("free-tier: partial grant reserved at payment time", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
requestedAbsorbSats,
|
|
canAbsorb,
|
|
debited,
|
|
});
|
|
return debited;
|
|
}
|
|
|
|
/**
|
|
* Release a pool reservation when a request is rejected, fails, or is not executed.
|
|
* Returns `absorbSats` to the generosity pool.
|
|
* Only call if decide() debited the pool (serve="free" path).
|
|
*/
|
|
async releaseReservation(absorbSats: number, reason: string): Promise<void> {
|
|
if (absorbSats <= 0) return;
|
|
const now = new Date();
|
|
await db
|
|
.update(timmyConfig)
|
|
.set({
|
|
value: sql`(value::int + ${absorbSats})::text`,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(timmyConfig.key, POOL_KEY));
|
|
logger.info("free-tier: reservation released", { absorbSats, reason });
|
|
}
|
|
|
|
/**
|
|
* 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 now = new Date();
|
|
await db
|
|
.insert(timmyConfig)
|
|
.values({ key: POOL_KEY, value: String(creditSats), updatedAt: now })
|
|
.onConflictDoUpdate({
|
|
target: timmyConfig.key,
|
|
set: {
|
|
value: sql`(timmy_config.value::int + ${creditSats})::text`,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
|
|
logger.info("generosity pool credited", { paidSats, creditSats });
|
|
}
|
|
|
|
/**
|
|
* Record the audit log entry and update daily absorption counters AFTER work completes.
|
|
*
|
|
* The pool was already debited (by decide() for free jobs, by reservePartialGrant()
|
|
* for partial jobs). This call only writes the audit row and updates daily absorption.
|
|
*
|
|
* `actualAbsorbed` = actual cost in sats (may be less than reservedAbsorbed due to
|
|
* actual token usage being lower than estimate).
|
|
* `reservedAbsorbed` = amount debited from pool at reservation time.
|
|
* Any over-reservation (reservedAbsorbed - actualAbsorbed) is released back to pool.
|
|
*/
|
|
async recordGrant(
|
|
pubkey: string,
|
|
requestHash: string,
|
|
actualAbsorbed: number,
|
|
reservedAbsorbed: number,
|
|
): Promise<void> {
|
|
const toRecord = Math.max(0, actualAbsorbed);
|
|
const overReserved = Math.max(0, reservedAbsorbed - toRecord);
|
|
if (toRecord <= 0 && overReserved <= 0) return;
|
|
|
|
const now = new Date();
|
|
|
|
await db.transaction(async (tx) => {
|
|
// Return over-reservation to pool (estimated > actual cost)
|
|
if (overReserved > 0) {
|
|
await tx
|
|
.update(timmyConfig)
|
|
.set({
|
|
value: sql`(value::int + ${overReserved})::text`,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(timmyConfig.key, POOL_KEY));
|
|
}
|
|
|
|
if (toRecord > 0) {
|
|
// Update identity daily absorption (CASE handles new-day reset atomically)
|
|
await tx
|
|
.update(nostrIdentities)
|
|
.set({
|
|
satsAbsorbedToday: sql`
|
|
CASE
|
|
WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS}
|
|
THEN ${toRecord}
|
|
ELSE sats_absorbed_today + ${toRecord}
|
|
END
|
|
`,
|
|
absorbedResetAt: sql`
|
|
CASE
|
|
WHEN EXTRACT(EPOCH FROM (${now}::timestamptz - absorbed_reset_at)) * 1000 >= ${DAY_MS}
|
|
THEN ${now}::timestamptz
|
|
ELSE absorbed_reset_at
|
|
END
|
|
`,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(nostrIdentities.pubkey, pubkey));
|
|
|
|
const poolRows = await tx.select().from(timmyConfig).where(eq(timmyConfig.key, POOL_KEY)).limit(1);
|
|
const poolBalanceAfter = poolRows[0] ? parseInt(poolRows[0].value, 10) : 0;
|
|
|
|
await tx.insert(freeTierGrants).values({
|
|
id: randomUUID(),
|
|
pubkey,
|
|
requestHash,
|
|
satsAbsorbed: toRecord,
|
|
poolBalanceAfter,
|
|
});
|
|
}
|
|
});
|
|
|
|
logger.info("free-tier grant recorded", {
|
|
pubkey: pubkey.slice(0, 8),
|
|
actualAbsorbed: toRecord,
|
|
reservedAbsorbed,
|
|
overReserved,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|