diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 25e65df..dde1590 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -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 { // 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 { 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(() => {