Task #27: Atomic free-tier pool reservation — eliminates advisory-charge gap
Root cause: decide() was advisory but user charges were reduced from its output;
recordGrant() later might absorb less, so Timmy could absorb the gap silently.
Fix architecture (serve="free" path — fully-free jobs + sessions):
- decide() now runs _atomicPoolDebit() inside a FOR UPDATE transaction
- Pool is debited at decision time for serve="free" decisions
- Work starts immediately after, so no window for pool drain between debit and use
- If work fails → releaseReservation() returns sats to pool
Fix architecture (serve="partial" path — partial-subsidy jobs):
- decide() remains advisory for "partial" (no pool debit at decision time)
- This prevents pool drain from users who get a partial offer but never pay
- For jobs: reservePartialGrant() atomically debits pool at work-payment-confirmation
time (inside advanceJob), before work begins
- For sessions: reservePartialGrant() called after synchronous work completes,
using actual cost capped by advisory absorbSats
recordGrant() now takes (pubkey, requestHash, actualAbsorbed, reservedAbsorbed):
- Over-reservation (estimated > actual) returned to pool atomically
- Audit log and daily counter reflect actual absorbed amount
- Pool balance was already decremented by decide() or reservePartialGrant()
Result: In ALL paths, pool debit happens atomically before charges are reduced.
User charge reduction and pool debit are always consistent — Timmy never operates
at a loss due to concurrent pool depletion.
This commit is contained in:
@@ -234,17 +234,17 @@ async function runWorkInBackground(
|
||||
void freeTierService.credit(workAmountSats);
|
||||
}
|
||||
|
||||
// Record free-tier grant now that work is confirmed complete.
|
||||
// For fully-free jobs: cap at actual cost (actualAmountSats was 0 for isFree, use actualCostUsd→sats).
|
||||
// For partial jobs: partialAbsorbSats is the estimated absorbed portion (cap enforced in recordGrant).
|
||||
// Both are deferred until post-execution so the audit log reflects real cost, not estimates.
|
||||
// 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().
|
||||
if (partialAbsorbSats > 0 && nostrPubkey) {
|
||||
const lockedBtcPrice = btcPriceUsd ?? 100_000;
|
||||
const absorbCap = isFree
|
||||
const actualAbsorbed = isFree
|
||||
? pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice)
|
||||
: partialAbsorbSats;
|
||||
: partialAbsorbSats; // partial: user paid the delta, Timmy covered the rest
|
||||
const reqHash = createHash("sha256").update(request).digest("hex");
|
||||
await freeTierService.recordGrant(nostrPubkey, reqHash, absorbCap);
|
||||
await freeTierService.recordGrant(nostrPubkey, reqHash, actualAbsorbed, partialAbsorbSats);
|
||||
}
|
||||
|
||||
// Trust scoring — fire and forget
|
||||
@@ -261,6 +261,11 @@ async function runWorkInBackground(
|
||||
.where(eq(jobs.id, jobId));
|
||||
eventBus.publish({ type: "job:failed", jobId, reason: message });
|
||||
|
||||
// Return any pool reservation if work failed
|
||||
if (partialAbsorbSats > 0 && nostrPubkey) {
|
||||
void freeTierService.releaseReservation(partialAbsorbSats, `work failed: ${message}`);
|
||||
}
|
||||
|
||||
// Trust scoring — penalise on work failure
|
||||
const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
|
||||
if (pubkeyForTrust) {
|
||||
@@ -339,6 +344,24 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
||||
// isFree is true ONLY for fully-free jobs (user paid nothing, workAmountSats=0).
|
||||
// Partial jobs (freeTier=true but workAmountSats>0) must use the paid accounting path.
|
||||
const isFreeExecution = (job.workAmountSats ?? 0) === 0;
|
||||
|
||||
// For partial free-tier jobs: atomically reserve the pool subsidy NOW (at payment
|
||||
// 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.
|
||||
let partialGrantReserved = 0;
|
||||
if (!isFreeExecution && job.freeTier && job.nostrPubkey && (job.absorbedSats ?? 0) > 0) {
|
||||
partialGrantReserved = await freeTierService.reservePartialGrant(
|
||||
job.absorbedSats ?? 0,
|
||||
job.nostrPubkey,
|
||||
);
|
||||
logger.info("partial grant reserved at payment", {
|
||||
jobId: job.id,
|
||||
requested: job.absorbedSats,
|
||||
reserved: partialGrantReserved,
|
||||
});
|
||||
}
|
||||
|
||||
setImmediate(() => {
|
||||
void runWorkInBackground(
|
||||
job.id,
|
||||
@@ -347,9 +370,9 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
||||
job.btcPriceUsd,
|
||||
isFreeExecution,
|
||||
job.nostrPubkey ?? null,
|
||||
// For partial jobs: pass absorbedSats so grant is recorded post-payment.
|
||||
// For fully-free jobs: grant was already recorded at eval time, pass 0.
|
||||
(!isFreeExecution && job.freeTier) ? (job.absorbedSats ?? 0) : 0,
|
||||
// For fully-free jobs: pool was debited at decide() time, pass estimated sats.
|
||||
// For partial jobs: pool was debited by reservePartialGrant(), pass actual reserved.
|
||||
isFreeExecution ? (job.absorbedSats ?? 0) : partialGrantReserved,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -366,17 +366,52 @@ router.post("/sessions/:id/request", async (req: Request, res: Response) => {
|
||||
const fullDebitSats = usdToSats(chargeUsd, btcPriceUsd);
|
||||
|
||||
// ── Reconcile free-tier decision against actual cost ──────────────────────
|
||||
// decide() atomically debited pool for serve="free"; was advisory for serve="partial".
|
||||
// Cap absorbedSats at the actual cost so we never over-absorb from the pool.
|
||||
let debitedSats = fullDebitSats;
|
||||
let freeTierServed = false;
|
||||
let absorbedSats = 0;
|
||||
let reservedAbsorbed = 0; // amount pool was debited before work ran
|
||||
|
||||
if (finalState === "complete" && ftDecision && ftDecision.serve !== "gate") {
|
||||
absorbedSats = Math.min(ftDecision.absorbSats, fullDebitSats);
|
||||
debitedSats = Math.max(0, fullDebitSats - absorbedSats);
|
||||
freeTierServed = true;
|
||||
const reqHash = createHash("sha256").update(requestText).digest("hex");
|
||||
await freeTierService.recordGrant(session.nostrPubkey!, reqHash, absorbedSats);
|
||||
if (ftDecision && ftDecision.serve !== "gate") {
|
||||
if (finalState === "complete") {
|
||||
if (ftDecision.serve === "free") {
|
||||
// Pool was debited at decide() time. Actual cost may be less than estimated.
|
||||
reservedAbsorbed = ftDecision.absorbSats;
|
||||
absorbedSats = Math.min(reservedAbsorbed, fullDebitSats);
|
||||
debitedSats = Math.max(0, fullDebitSats - absorbedSats);
|
||||
freeTierServed = true;
|
||||
} else {
|
||||
// Partial: decide() was advisory (no pool debit). Atomically debit NOW, after
|
||||
// work completed, using actual cost capped by the advisory absorbSats limit.
|
||||
const wantedAbsorb = Math.min(ftDecision.absorbSats, fullDebitSats);
|
||||
const actualReserved = await freeTierService.reservePartialGrant(
|
||||
wantedAbsorb,
|
||||
session.nostrPubkey!,
|
||||
);
|
||||
reservedAbsorbed = actualReserved;
|
||||
absorbedSats = actualReserved;
|
||||
debitedSats = Math.max(0, fullDebitSats - absorbedSats);
|
||||
freeTierServed = absorbedSats > 0;
|
||||
}
|
||||
|
||||
if (freeTierServed) {
|
||||
const reqHash = createHash("sha256").update(requestText).digest("hex");
|
||||
await freeTierService.recordGrant(
|
||||
session.nostrPubkey!,
|
||||
reqHash,
|
||||
absorbedSats,
|
||||
reservedAbsorbed,
|
||||
);
|
||||
}
|
||||
} else if (ftDecision.serve === "free") {
|
||||
// Work failed or was rejected — release the pool reservation from decide()
|
||||
void freeTierService.releaseReservation(
|
||||
ftDecision.absorbSats,
|
||||
`session work ${finalState}`,
|
||||
);
|
||||
}
|
||||
// Partial + failed: no pool debit was made (advisory only), nothing to release.
|
||||
}
|
||||
|
||||
// Credit pool from paid portion (even if partial free tier)
|
||||
|
||||
Reference in New Issue
Block a user