Files
timmy-tower/artifacts/api-server/src/routes/estimate.ts
alexpaynex eca505e47e 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

83 lines
2.7 KiB
TypeScript

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 {
const { estimatedInputTokens: inputTokens, estimatedOutputTokens: outputTokens, estimatedCostUsd: costUsd } =
pricingService.estimateRequestCost(requestText, agentService.workModel);
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);
const decision = await freeTierService.decideDryRun(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;