Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap

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.
This commit is contained in:
alexpaynex
2026-03-19 17:08:43 +00:00
parent 26e0d32f5c
commit ec5316a4dc
3 changed files with 268 additions and 128 deletions

View File

@@ -366,17 +366,52 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
const fullDebitSats = usdToSats(chargeUsd, btcPriceUsd);
// ── Reconcile free-tier decision against actual cost ──────────────────────
// decide() atomically debited pool for serve="free"; was advisory for serve="partial".
// Cap absorbedSats at the actual cost so we never over-absorb from the pool.
let debitedSats = fullDebitSats;
let freeTierServed = false;
let absorbedSats = 0;
let reservedAbsorbed = 0; // amount pool was debited before work ran
if (finalState === "complete" && ftDecision && ftDecision.serve !== "gate") {
absorbedSats = Math.min(ftDecision.absorbSats, fullDebitSats);
debitedSats = Math.max(0, fullDebitSats - absorbedSats);
freeTierServed = true;
const reqHash = createHash("sha256").update(requestText).digest("hex");
await freeTierService.recordGrant(session.nostrPubkey!, reqHash, absorbedSats);
if (ftDecision && ftDecision.serve !== "gate") {
if (finalState === "complete") {
if (ftDecision.serve === "free") {
// Pool was debited at decide() time. Actual cost may be less than estimated.
reservedAbsorbed = ftDecision.absorbSats;
absorbedSats = Math.min(reservedAbsorbed, fullDebitSats);
debitedSats = Math.max(0, fullDebitSats - absorbedSats);
freeTierServed = true;
} else {
// Partial: decide() was advisory (no pool debit). Atomically debit NOW, after
// work completed, using actual cost capped by the advisory absorbSats limit.
const wantedAbsorb = Math.min(ftDecision.absorbSats, fullDebitSats);
const actualReserved = await freeTierService.reservePartialGrant(
wantedAbsorb,
session.nostrPubkey!,
);
reservedAbsorbed = actualReserved;
absorbedSats = actualReserved;
debitedSats = Math.max(0, fullDebitSats - absorbedSats);
freeTierServed = absorbedSats > 0;
}
if (freeTierServed) {
const reqHash = createHash("sha256").update(requestText).digest("hex");
await freeTierService.recordGrant(
session.nostrPubkey!,
reqHash,
absorbedSats,
reservedAbsorbed,
);
}
} else if (ftDecision.serve === "free") {
// Work failed or was rejected — release the pool reservation from decide()
void freeTierService.releaseReservation(
ftDecision.absorbSats,
`session work ${finalState}`,
);
}
// Partial + failed: no pool debit was made (advisory only), nothing to release.
}
// Credit pool from paid portion (even if partial free tier)