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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user