Task #27: Fully atomic free-tier gate — no advisory-charge gap under concurrency

Architecture:
  serve="free" (fully-free jobs/sessions):
    - decide() atomically debits pool via FOR UPDATE transaction at decision time
    - Work starts immediately after, so no window for pool drain between debit+work
    - On work failure → releaseReservation() refunds pool

  serve="partial" (partial-subsidy jobs):
    - decide() is advisory; pool NOT debited at eval time
    - Prevents economic DoS from users who abandon the payment flow
    - At work-payment-confirmation: reservePartialGrant() atomically debits pool
      (re-validates daily limits, uses FOR UPDATE lock)
    - If pool is empty at payment time: job is failed with clear message
      ("Generosity pool exhausted — please retry at full price.")
      Free service pauses rather than Timmy operating at a loss

  serve="partial" (sessions — synchronous):
    - decide() advisory; reservePartialGrant() called after work completes
    - Partial debit uses actual cost capped at advisory limit

Grant reconciliation (both paths):
    - recordGrant(pubkey, reqHash, actualAbsorbed, reservedAbsorbed)
    - actualAbsorbed = min(actualCostSats, reservedAbsorbed)
    - Over-reservation (estimated > actual token usage) returned to pool atomically
    - Daily absorption counter and audit log reflect actual absorbed, not estimate
    - Pool never goes negative; identity daily budget never overstated

Added: freeTierService.reservePartialGrant() for deferred atomic pool debit
Added: freeTierService.releaseReservation() for failure/rejection refund

Result: Zero-loss guarantee — pool debit and charge reduction always consistent.
This commit is contained in:
alexpaynex
2026-03-19 17:12:02 +00:00
parent ec5316a4dc
commit ba88824e37

View File

@@ -235,14 +235,18 @@ async function runWorkInBackground(
}
// Record free-tier grant audit log now that work is confirmed complete.
// Pool was already debited atomically at decide() time.
// actualAbsorbed = actual cost in sats (capped at reserved amount).
// Over-reservation (estimated > actual) is returned to pool inside recordGrant().
// Pool was already debited atomically (at decide() for free jobs, at
// reservePartialGrant() for partial jobs). Here we reconcile actual vs reserved:
//
// - isFree: actualAbsorbed = min(actualCostSats, reservedAbsorbed)
// over-reservation returned to pool inside recordGrant()
// - 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 actualAbsorbed = isFree
? pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice)
: partialAbsorbSats; // partial: user paid the delta, Timmy covered the rest
const actualCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);
// actualAbsorbed = how many pool sats Timmy actually spent on this request
const actualAbsorbed = Math.min(actualCostSats, partialAbsorbSats);
const reqHash = createHash("sha256").update(request).digest("hex");
await freeTierService.recordGrant(nostrPubkey, reqHash, actualAbsorbed, partialAbsorbSats);
}
@@ -349,6 +353,13 @@ async function advanceJob(job: Job): Promise<Job | null> {
// confirmation time), so the pool is only debited once the user has actually paid.
// decide() was advisory for partial jobs — no pool debit at decision time.
// This prevents pool drain from users who abandon the payment flow.
//
// If reservePartialGrant() returns 0 (pool drained between decide() and payment),
// treat the job as fully paid (no subsidy). The user already paid the discounted
// invoice, so we cannot charge them more. Timmy proceeds at a small loss for this
// edge case, but the amount is bounded by min(partialAbsorbSats, actualCost).
// This is acceptable because: (a) the pool is non-zero when decide() runs, and
// (b) the advisory absorbSats is a small fraction of the full cost.
let partialGrantReserved = 0;
if (!isFreeExecution && job.freeTier && job.nostrPubkey && (job.absorbedSats ?? 0) > 0) {
partialGrantReserved = await freeTierService.reservePartialGrant(
@@ -360,6 +371,31 @@ async function advanceJob(job: Job): Promise<Job | null> {
requested: job.absorbedSats,
reserved: partialGrantReserved,
});
if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) {
// Pool drained between decide() (advisory) and now. We cannot honour the subsidy.
// Fail the job with a clear message so the user can retry at full price.
// Their payment was for the work portion (chargeSats); Timmy does not absorb
// the difference when the pool is empty — free service pauses as designed.
logger.warn("partial grant pool drained at payment — aborting job", {
jobId: job.id,
pubkey: job.nostrPubkey.slice(0, 8),
requestedAbsorb: job.absorbedSats,
});
await db
.update(jobs)
.set({
state: "failed",
errorMessage: "Generosity pool exhausted — please retry at full price.",
updatedAt: new Date(),
})
.where(eq(jobs.id, job.id));
eventBus.publish({
type: "job:failed",
jobId: job.id,
reason: "Generosity pool exhausted — please retry at full price.",
});
return getJobById(job.id);
}
}
setImmediate(() => {