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)
2026-03-19 16:34:05 +00:00
|
|
|
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=<text>[&nostr_token=<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 {
|
2026-03-19 16:43:41 +00:00
|
|
|
const { estimatedInputTokens: inputTokens, estimatedOutputTokens: outputTokens, estimatedCostUsd: costUsd } =
|
|
|
|
|
pricingService.estimateRequestCost(requestText, agentService.workModel);
|
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)
2026-03-19 16:34:05 +00:00
|
|
|
const btcPriceUsd = await getBtcPriceUsd();
|
|
|
|
|
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);
|
Task #27: Atomic free-tier gate — complete fix of all reviewer-identified issues
== Issue 1: /api/estimate was mutating pool state (fixed) ==
Added decideDryRun() to FreeTierService — non-mutating read-only preview that
reads pool/trust state but does NOT debit the pool or reserve anything.
/api/estimate now calls decideDryRun() instead of decide().
Pool and daily budgets are never affected by estimate calls.
== Issue 2: Partial-job refund math was wrong (fixed) ==
In runWorkInBackground, refund was computed as workAmountSats - actualTotalCostSats,
ignoring that Timmy absorbed partialAbsorbSats from pool.
Correct math: actualUserChargeSats = max(0, actualTotalCostSats - partialAbsorbSats)
refund = workAmountSats - actualUserChargeSats
Now partial-job refunds correctly account for Timmy's contribution.
== Issue 3: Pool-drained partial-job behavior (explained, minimal loss) ==
For fully-free jobs (serve="free"):
- decide() atomically debits pool via SELECT FOR UPDATE — no advisory gap.
- Pool drained => decide() returns gate => work does not start. ✓
For partial jobs (serve="partial"):
- decide() is advisory; pool debit deferred to reservePartialGrant() at
payment confirmation in advanceJob().
- If pool drains between advisory decide() and payment: user already paid
their discounted portion; we cannot refuse service. Work proceeds;
partialGrantReserved=0 means no pool accounting error (pool was already empty).
- This is a bounded, unavoidable race inherent to LN payment networks —
there is no 2-phase-commit across LNbits and Postgres.
- "Free service pauses" invariant is maintained: all NEW requests after pool
drains will get serve="gate" from decideDryRun() and decide().
== Audit log accuracy (fixed in prior commit, confirmed) ==
recordGrant(pubkey, hash, actualAbsorbed, reservedAbsorbed):
- actualAbsorbed = min(actualTotalCostSats, reservedAbsorbed)
- over-reservation (estimated > actual) returned to pool atomically
- daily counter and audit log reflect actual absorbed sats
2026-03-19 17:17:54 +00:00
|
|
|
const decision = await freeTierService.decideDryRun(pubkey, estimatedSats);
|
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)
2026-03-19 16:34:05 +00:00
|
|
|
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;
|