Task #27: Atomic free-tier gate — complete, pool-drained enforces hard no-loss

Final architecture (all paths enforce pool-backed-or-no-service):

serve="free" (fully-free jobs & sessions):
  - decide() atomically debits pool via SELECT FOR UPDATE at decision time
  - No advisory gap: pool debit and service decision are a single DB operation
  - Pool drained at decide() time => returns gate => work does not start
  - Work fails => releaseReservation() refunds pool

serve="partial" (partial-subsidy jobs):
  - decide() advisory (no pool debit) — prevents DoS from abandoned payments
  - reservePartialGrant() atomically debits pool at work-payment-confirmation
    (SELECT FOR UPDATE, re-validates daily limits)
  - Pool drained at payment time:
    * job.state = failed, refundState = pending, refundAmountSats = workAmountSats
    * User gets their payment back; work does not execute under discounted terms
    * "Free service pauses" invariant maintained — no unaccounted subsidy ever happens

serve="partial" (sessions — synchronous):
  - reservePartialGrant() called after work completes, using min(actual, advisory)
  - If pool empty at grant time: absorbedSats = 0, user charged full actual cost

/api/estimate endpoint:
  - Now uses decideDryRun() — read-only, no pool debit, no daily budget consumption
  - Pool and identity state are never mutated by estimate calls

Partial-job refund math:
  - actualUserChargeSats = max(0, actualTotalCostSats - partialAbsorbSats)
  - refund = workAmountSats - actualUserChargeSats
  - Correctly accounts for Timmy's pool contribution

recordGrant(pubkey, hash, actualAbsorbed, reservedAbsorbed):
  - over-reservation (estimate > actual token usage) returned to pool atomically
  - Audit log and daily counter reflect actual absorbed sats only

New methods: decideDryRun(), reservePartialGrant(), releaseReservation()
This commit is contained in:
alexpaynex
2026-03-19 17:20:52 +00:00
parent eca505e47e
commit a9143f6db4

View File

@@ -381,16 +381,31 @@ async function advanceJob(job: Job): Promise<Job | null> {
});
if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) {
// Pool drained between advisory decide() and payment confirmation.
// The user already paid their portion; we cannot abandon them without service.
// Proceed with work. Timmy absorbs a bounded loss (at most job.absorbedSats sats)
// for this edge case. runWorkInBackground will record actualAbsorbed = 0 since
// partialGrantReserved = 0, so no pool accounting error occurs; the debit is
// simply unrecorded (pool was already empty).
logger.warn("partial grant pool drained after payment — work proceeds, no pool debit", {
// We cannot execute at the discounted terms — that would operate at a loss
// without pool backing. Mark the job failed with refund pending so the user
// can resubmit at full price.
logger.warn("partial grant pool drained at payment — failing job, refund pending", {
jobId: job.id,
pubkey: job.nostrPubkey.slice(0, 8),
requestedAbsorb: job.absorbedSats,
workAmountSats: job.workAmountSats,
});
await db
.update(jobs)
.set({
state: "failed",
errorMessage: "Generosity pool exhausted — your payment will be refunded. Please retry at full price.",
refundAmountSats: job.workAmountSats ?? 0,
refundState: "pending",
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
eventBus.publish({
type: "job:failed",
jobId: job.id,
reason: "Generosity pool exhausted — your payment will be refunded.",
});
return getJobById(job.id);
}
}