diff --git a/artifacts/api-server/src/lib/free-tier.ts b/artifacts/api-server/src/lib/free-tier.ts index 754cd09..e4b87fe 100644 --- a/artifacts/api-server/src/lib/free-tier.ts +++ b/artifacts/api-server/src/lib/free-tier.ts @@ -75,6 +75,41 @@ export class FreeTierService { return POOL_INITIAL_SATS; } + /** + * Non-mutating preview of what decide() would return, WITHOUT debiting the pool. + * Use this for estimate/pre-flight endpoints where no execution is happening. + * The result is indicative only — pool state may differ by the time actual decide() runs. + */ + async decideDryRun(pubkey: string | null, estimatedSats: number): Promise { + const gate: FreeTierDecision = { serve: "gate", absorbSats: 0, chargeSats: estimatedSats }; + + if (!pubkey || estimatedSats <= 0) return gate; + + const identity = await trustService.getIdentityWithDecay(pubkey); + if (!identity) return gate; + + const dailyBudget = this.dailyBudgetForTier(identity.tier); + if (dailyBudget === 0) return gate; + + const todayAbsorbed = + (Date.now() - identity.absorbedResetAt.getTime()) >= DAY_MS + ? 0 + : identity.satsAbsorbedToday; + const dailyRemaining = Math.max(0, dailyBudget - todayAbsorbed); + if (dailyRemaining === 0) return gate; + + const poolBalance = await this.getPoolBalance(); + if (poolBalance <= 0) return gate; + + const canAbsorb = Math.min(dailyRemaining, poolBalance, estimatedSats); + if (canAbsorb <= 0) return gate; + + if (canAbsorb >= estimatedSats) { + return { serve: "free", absorbSats: canAbsorb, chargeSats: 0 }; + } + return { serve: "partial", absorbSats: canAbsorb, chargeSats: estimatedSats - canAbsorb }; + } + /** * Decide whether to serve a request for free, partially free, or at full cost. * diff --git a/artifacts/api-server/src/routes/estimate.ts b/artifacts/api-server/src/routes/estimate.ts index ba319cc..97b0c17 100644 --- a/artifacts/api-server/src/routes/estimate.ts +++ b/artifacts/api-server/src/routes/estimate.ts @@ -44,7 +44,7 @@ router.get("/estimate", async (req: Request, res: Response) => { if (parsed) { pubkey = parsed.pubkey; trustTier = await trustService.getTier(pubkey); - const decision = await freeTierService.decide(pubkey, estimatedSats); + const decision = await freeTierService.decideDryRun(pubkey, estimatedSats); freeTierDecision = { serve: decision.serve, absorbSats: decision.absorbSats, diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index e3622e9..125d74c 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -196,8 +196,17 @@ async function runWorkInBackground( ); const lockedBtcPrice = btcPriceUsd ?? 100_000; + const actualTotalCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice); + // For free-tier jobs the user paid nothing — no refund is applicable. - const actualAmountSats = isFree ? 0 : pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice); + // For partial jobs: Timmy absorbed partialAbsorbSats from pool; user's actual charge is + // max(0, actualTotalCostSats - partialAbsorbSats). + // Refund = workAmountSats (what user paid) - actualUserChargeSats. + const isPartial = !isFree && partialAbsorbSats > 0; + const actualAbsorbedForRefund = isPartial ? partialAbsorbSats : 0; + const actualAmountSats = isFree + ? 0 + : Math.max(0, actualTotalCostSats - actualAbsorbedForRefund); const refundAmountSats = isFree ? 0 : pricingService.calculateRefundSats(workAmountSats, actualAmountSats); const refundState: "not_applicable" | "pending" = isFree ? "not_applicable" @@ -243,10 +252,9 @@ async function runWorkInBackground( // - 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 actualCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice); - // actualAbsorbed = how many pool sats Timmy actually spent on this request - const actualAbsorbed = Math.min(actualCostSats, partialAbsorbSats); + // actualAbsorbed = how many pool sats Timmy actually spent on this request. + // Capped at reservedAbsorbed (partialAbsorbSats); over-reservation returned inside recordGrant. + const actualAbsorbed = Math.min(actualTotalCostSats, partialAbsorbSats); const reqHash = createHash("sha256").update(request).digest("hex"); await freeTierService.recordGrant(nostrPubkey, reqHash, actualAbsorbed, partialAbsorbSats); }