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
This commit is contained in:
alexpaynex
2026-03-19 17:17:54 +00:00
parent 4866cfc950
commit eca505e47e
3 changed files with 49 additions and 6 deletions

View File

@@ -75,6 +75,41 @@ export class FreeTierService {
return POOL_INITIAL_SATS;
}
/**
* Non-mutating preview of what decide() would return, WITHOUT debiting the pool.
* Use this for estimate/pre-flight endpoints where no execution is happening.
* The result is indicative only — pool state may differ by the time actual decide() runs.
*/
async decideDryRun(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) return gate;
const poolBalance = await this.getPoolBalance();
if (poolBalance <= 0) return gate;
const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats);
if (canAbsorb <= 0) return gate;
if (canAbsorb >= estimatedSats) {
return { serve: "free", absorbSats: canAbsorb, chargeSats: 0 };
}
return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb };
}
/**
* Decide whether to serve a request for free, partially free, or at full cost.
*

View File

@@ -44,7 +44,7 @@ router.get("/estimate", async (req: Request, res: Response) => {
if (parsed) {
pubkey = parsed.pubkey;
trustTier = await trustService.getTier(pubkey);
const decision = await freeTierService.decide(pubkey, estimatedSats);
const decision = await freeTierService.decideDryRun(pubkey, estimatedSats);
freeTierDecision = {
serve: decision.serve,
absorbSats: decision.absorbSats,

View File

@@ -196,8 +196,17 @@ async function runWorkInBackground(
);
const lockedBtcPrice = btcPriceUsd ?? 100_000;
const actualTotalCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
// For free-tier jobs the user paid nothing — no refund is applicable.
const actualAmountSats = isFree ? 0 : pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
// For partial jobs: Timmy absorbed partialAbsorbSats from pool; user's actual charge is
// max(0, actualTotalCostSats - partialAbsorbSats).
// Refund = workAmountSats (what user paid) - actualUserChargeSats.
const isPartial = !isFree && partialAbsorbSats > 0;
const actualAbsorbedForRefund = isPartial ? partialAbsorbSats : 0;
const actualAmountSats = isFree
? 0
: Math.max(0, actualTotalCostSats - actualAbsorbedForRefund);
const refundAmountSats = isFree ? 0 : pricingService.calculateRefundSats(workAmountSats, actualAmountSats);
const refundState: "not_applicable" | "pending" = isFree
? "not_applicable"
@@ -243,10 +252,9 @@ async function runWorkInBackground(
// - partial: actualAbsorbed = min(actualTotalCostSats, reservedAbsorbed)
// actual cost may be less than estimated; return excess to pool
if (partialAbsorbSats > 0 && nostrPubkey) {
const lockedBtcPrice = btcPriceUsd ?? 100_000;
const actualCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
// actualAbsorbed = how many pool sats Timmy actually spent on this request
const actualAbsorbed = Math.min(actualCostSats, partialAbsorbSats);
// actualAbsorbed = how many pool sats Timmy actually spent on this request.
// Capped at reservedAbsorbed (partialAbsorbSats); over-reservation returned inside recordGrant.
const actualAbsorbed = Math.min(actualTotalCostSats, partialAbsorbSats);
const reqHash = createHash("sha256").update(request).digest("hex");
await freeTierService.recordGrant(nostrPubkey, reqHash, actualAbsorbed, partialAbsorbSats);
}