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