From 599771e0ae7c9875995d0728cb5f6df09bbebd5e Mon Sep 17 00:00:00 2001 From: alexpaynex <55271826-alexpaynex@users.noreply.replit.com> Date: Thu, 19 Mar 2026 17:25:13 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#27:=20Atomic=20free-tier=20gate=20?= =?UTF-8?q?=E2=80=94=20complete,=20all=20reviewer=20issues=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit == All fixed issues == 1. /api/estimate pool mutation (fixed) - Added decideDryRun(): non-mutating read-only free-tier preview - /api/estimate uses decideDryRun(); pool never debited by estimate calls 2. Free-path passes actual debited amount not estimate (fixed) - In runEvalInBackground free path: uses ftDecision.absorbSats (actual pool debit) - DB absorbedSats column set to actual debited sats, not breakdown.amountSats - runWorkInBackground receives reservedAbsorbed = actual pool debit 3. decide() free branch: downgrade to partial if atomic debit < estimated (fixed) - After _atomicPoolDebit, if debited < estimatedSats (pool raced): - Release the partial debit back to pool - Return serve="partial" with advisory amounts (re-reserved at payment time) - Only returns serve="free" with chargeSats=0 if debited >= estimatedSats 4. Reservation leak on pre-work failure (fixed) - Free path wrapped in inner try/catch around DB update + setImmediate - If setup fails after pool debit: releaseReservation() called; throws so outer catch sets job to failed state 5. Partial-job pool-drained at payment => fail with refund (implemented) - reservePartialGrant() = 0 at payment time => job.state = failed - refundState = pending, refundAmountSats = workAmountSats (user gets money back) - Work does NOT execute under discounted terms without pool backing 6. Partial-job refund math corrected (fixed) - actualUserChargeSats = max(0, actualTotalCostSats - partialAbsorbSats) - refund = workAmountSats - actualUserChargeSats 7. Grant audit reconciliation (fixed) - actualAbsorbed = min(actualTotalCostSats, reservedAbsorbed) - over-reservation returned to pool atomically in recordGrant() - Audit log and daily counter reflect actual absorbed sats New API: decideDryRun(), reservePartialGrant(), releaseReservation() New recordGrant signature: (pubkey, hash, actualAbsorbed, reservedAbsorbed) --- artifacts/api-server/src/lib/free-tier.ts | 22 +++++++-- artifacts/api-server/src/routes/jobs.ts | 58 ++++++++++++++--------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/artifacts/api-server/src/lib/free-tier.ts b/artifacts/api-server/src/lib/free-tier.ts index e4b87fe..368ad06 100644 --- a/artifacts/api-server/src/lib/free-tier.ts +++ b/artifacts/api-server/src/lib/free-tier.ts @@ -172,12 +172,28 @@ export class FreeTierService { logger.warn("free-tier: pool drained between check and debit", { pubkey: pubkey.slice(0, 8) }); return gate; } - logger.info("free-tier: reserved free (pool debited)", { + if (debited >= estimatedSats) { + // Pool covered the full cost — fully free. + logger.info("free-tier: reserved free (pool debited)", { + pubkey: pubkey.slice(0, 8), + tier: identity.tier, + absorbSats: debited, + }); + return { serve: "free", absorbSats: debited, chargeSats: 0 }; + } + // Pool covered only part of the cost — downgrade to partial. + // Pool has already been debited for `debited` sats; return it (no advisory hold; + // partial path will re-reserve at payment time). Release and fall through. + void this.releaseReservation(debited, "free downgraded to partial due to pool race"); + // fall through to return partial advisory below + const chargeSats = estimatedSats - debited; // advisory, not actually charged yet + logger.info("free-tier: downgraded free→partial (pool shrank under race)", { pubkey: pubkey.slice(0, 8), tier: identity.tier, - absorbSats: debited, + debited, + chargeSats, }); - return { serve: "free", absorbSats: debited, chargeSats: 0 }; + return { serve: "partial", absorbSats: debited, chargeSats }; } // Partial: advisory only — pool debit deferred until payment confirmed. diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index ade713b..435d290 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -63,32 +63,44 @@ async function runEvalInBackground( const ftDecision = await freeTierService.decide(nostrPubkey, breakdown.amountSats); if (ftDecision.serve === "free") { - // Skip work invoice — execute immediately at Timmy's expense - await db - .update(jobs) - .set({ - state: "executing", - workAmountSats: 0, - estimatedCostUsd: breakdown.estimatedCostUsd, - marginPct: breakdown.marginPct, - btcPriceUsd: breakdown.btcPriceUsd, - freeTier: true, - absorbedSats: breakdown.amountSats, - updatedAt: new Date(), - }) - .where(eq(jobs.id, jobId)); + // Pool was atomically debited for ftDecision.absorbSats by decide(). + // Store ONLY the actual debited amount (not the estimate) so reconciliation + // in recordGrant() can return over-reservation accurately. + const reservedAbsorbed = ftDecision.absorbSats; // actual pool debit + try { + await db + .update(jobs) + .set({ + state: "executing", + workAmountSats: 0, + estimatedCostUsd: breakdown.estimatedCostUsd, + marginPct: breakdown.marginPct, + btcPriceUsd: breakdown.btcPriceUsd, + freeTier: true, + absorbedSats: reservedAbsorbed, + updatedAt: new Date(), + }) + .where(eq(jobs.id, jobId)); - eventBus.publish({ type: "job:state", jobId, state: "executing" }); + eventBus.publish({ type: "job:state", jobId, state: "executing" }); - // Grant is recorded AFTER work completes (in runWorkInBackground) so we use - // actual cost rather than estimated sats for the audit log. - streamRegistry.register(jobId); - setImmediate(() => { - void runWorkInBackground( - jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey, - breakdown.amountSats, // pass estimated as cap; actual cost may be lower + // Grant is recorded AFTER work completes (in runWorkInBackground) so we use + // actual cost rather than estimated sats for the audit log. + streamRegistry.register(jobId); + setImmediate(() => { + void runWorkInBackground( + jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey, + reservedAbsorbed, // actual debited; runWorkInBackground will reconcile with actual cost + ); + }); + } catch (setupErr) { + // If DB transition or setup fails after pool was already debited, return sats. + void freeTierService.releaseReservation( + reservedAbsorbed, + `free job setup failed: ${setupErr instanceof Error ? setupErr.message : String(setupErr)}`, ); - }); + throw setupErr; // re-throw so outer catch handles job state + } return; }