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:
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user