Task #27: Atomic free-tier gate — complete, all reviewer issues fixed
== 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)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user