From a9143f6db4b564fe3feca052aaa95bca4bf4c40b Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 17:20:52 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#27:=20Atomic=20free-tier=20gate=20?= =?UTF-8?q?=E2=80=94=20complete,=20pool-drained=20enforces=20hard=20no-los?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() --- artifacts/api-server/src/routes/jobs.ts | 27 +++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 125d74c..ade713b 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -381,16 +381,31 @@ async function advanceJob(job: Job): Promise { }); 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); } }