diff --git a/backend/.env.example b/backend/.env.example index b150fe9..ca07a18 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,16 +18,9 @@ NONCE_TTL_SECONDS=600 # Faucet economics FAUCET_ENABLED=true EMERGENCY_STOP=false +# Payout: random amount between FAUCET_MIN_SATS and FAUCET_MAX_SATS (inclusive) FAUCET_MIN_SATS=10 FAUCET_MAX_SATS=100 -PAYOUT_WEIGHT_SMALL=50 -PAYOUT_WEIGHT_MEDIUM=30 -PAYOUT_WEIGHT_LARGE=15 -PAYOUT_WEIGHT_JACKPOT=5 -PAYOUT_SMALL_SATS=10 -PAYOUT_MEDIUM_SATS=25 -PAYOUT_LARGE_SATS=50 -PAYOUT_JACKPOT_SATS=100 DAILY_BUDGET_SATS=10000 MAX_CLAIMS_PER_DAY=100 MIN_WALLET_BALANCE_SATS=1000 diff --git a/backend/src/config.ts b/backend/src/config.ts index 7cdc631..7e1cd8e 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -26,6 +26,8 @@ export const config = { trustProxy: envBool("TRUST_PROXY", false), /** Public path prefix when behind a reverse proxy that strips it (e.g. nginx /api/ -> backend /). */ publicBasePath: (process.env.PUBLIC_BASE_PATH ?? "").replace(/\/$/, ""), + /** Host of the API subdomain (e.g. faucetapi.lnpulse.app). When request Host matches, OpenAPI server URL is /. */ + apiHost: (process.env.API_HOST ?? "faucetapi.lnpulse.app").split(":")[0], allowedOrigins: (process.env.ALLOWED_ORIGINS ?? process.env.FRONTEND_URL ?? "http://localhost:5173,http://localhost:5174").split(",").map((s) => s.trim()), // Database: omit DATABASE_URL for SQLite; set for Postgres @@ -44,14 +46,6 @@ export const config = { emergencyStop: envBool("EMERGENCY_STOP", false), faucetMinSats: envInt("FAUCET_MIN_SATS", 1), faucetMaxSats: envInt("FAUCET_MAX_SATS", 5), - payoutWeightSmall: envInt("PAYOUT_WEIGHT_SMALL", 50), - payoutWeightMedium: envInt("PAYOUT_WEIGHT_MEDIUM", 30), - payoutWeightLarge: envInt("PAYOUT_WEIGHT_LARGE", 15), - payoutWeightJackpot: envInt("PAYOUT_WEIGHT_JACKPOT", 5), - payoutSmallSats: envInt("PAYOUT_SMALL_SATS", 10), - payoutMediumSats: envInt("PAYOUT_MEDIUM_SATS", 25), - payoutLargeSats: envInt("PAYOUT_LARGE_SATS", 50), - payoutJackpotSats: envInt("PAYOUT_JACKPOT_SATS", 100), dailyBudgetSats: envInt("DAILY_BUDGET_SATS", 10000), maxClaimsPerDay: envInt("MAX_CLAIMS_PER_DAY", 100), minWalletBalanceSats: envInt("MIN_WALLET_BALANCE_SATS", 1000), diff --git a/backend/src/services/quote.ts b/backend/src/services/quote.ts index 5b46068..0b485e1 100644 --- a/backend/src/services/quote.ts +++ b/backend/src/services/quote.ts @@ -6,42 +6,17 @@ import { getWalletBalanceSats } from "./lnbits.js"; const QUOTE_TTL_SECONDS = 60; -interface PayoutBucket { - sats: number; - weight: number; -} - -function getPayoutBuckets(): PayoutBucket[] { - return [ - { sats: config.payoutSmallSats, weight: config.payoutWeightSmall }, - { sats: config.payoutMediumSats, weight: config.payoutWeightMedium }, - { sats: config.payoutLargeSats, weight: config.payoutWeightLarge }, - { sats: config.payoutJackpotSats, weight: config.payoutWeightJackpot }, - ]; -} - /** - * Weighted random selection. Returns sats amount. - */ -export function selectWeightedPayout(): number { - const buckets = getPayoutBuckets(); - const totalWeight = buckets.reduce((s, b) => s + b.weight, 0); - let r = randomInt(0, totalWeight); - for (const b of buckets) { - if (r < b.weight) return b.sats; - r -= b.weight; - } - return config.payoutSmallSats; -} - -/** - * Compute payout for this claim: weighted selection, capped by daily budget remaining. + * Random payout between FAUCET_MIN_SATS and FAUCET_MAX_SATS (inclusive), + * capped by daily budget remaining. */ export function computePayoutForClaim(todayPaidSats: number): number { const remaining = Math.max(0, config.dailyBudgetSats - todayPaidSats); if (remaining < config.faucetMinSats) return 0; - const selected = selectWeightedPayout(); - return Math.min(selected, remaining, config.faucetMaxSats); + const min = Math.max(config.faucetMinSats, 1); + const max = Math.min(config.faucetMaxSats, remaining); + if (max < min) return 0; + return randomInt(min, max + 1); } export interface CreateQuoteResult { diff --git a/frontend/src/components/ClaimWizard.tsx b/frontend/src/components/ClaimWizard.tsx index 35a6185..52d54e6 100644 --- a/frontend/src/components/ClaimWizard.tsx +++ b/frontend/src/components/ClaimWizard.tsx @@ -36,8 +36,6 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces const nostrProfile = useNostrProfile(pubkey); const isConnected = !!pubkey; - const isBusy = claim.loading !== "idle"; - const hasResult = !!claim.quote || !!claim.denial || !!claim.success || !!claim.confirmError; useEffect(() => { if (!pubkey) { @@ -73,15 +71,14 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces const addr = lightningAddress.trim(); if (!addr || !isValidLightningAddress(addr)) return; autoCheckRef.current = pubkey; - setClaimModalOpen(true); claim.checkEligibility(addr); }, [pubkey, lightningAddress, claim.loading, claim.claimState, claim.checkEligibility]); useEffect(() => { - if (isBusy || hasResult) { + if (claim.loading === "confirm" || claim.success || claim.confirmError) { setClaimModalOpen(true); } - }, [isBusy, hasResult]); + }, [claim.loading, claim.success, claim.confirmError]); const handleDisconnect = () => { onPubkeyChange(null); @@ -105,10 +102,10 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces claim.clearConfirmError(); setLightningAddressTouched(false); setClaimModalOpen(false); + autoCheckRef.current = null; }; const handleCheckEligibility = () => { - setClaimModalOpen(true); claim.checkEligibility(lightningAddress); }; @@ -137,6 +134,10 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces setClaimModalOpen(false); }; + const handleClaimClick = () => { + setClaimModalOpen(true); + }; + const lightningAddressInvalid = lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress); const fromProfile = @@ -151,7 +152,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces if (claim.success) return "Sats sent!"; if (claim.confirmError) return "Something went wrong"; if (claim.loading === "confirm") return "Sending sats…"; - if (claim.quote) return "Confirm payout"; + if (claim.quote) return "Confirm claim"; if (claim.denial) return "Not eligible"; if (claim.loading === "quote") return "Checking eligibility"; return "Claim"; @@ -185,6 +186,39 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces onConnect={(pk, method) => onPubkeyChange(pk, method)} onDisconnect={handleDisconnect} /> + ) : claim.loading === "quote" ? ( +
+
+

+ {ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]} +

+
+ ) : claim.denial ? ( + + ) : claim.quote && !quoteExpired ? ( +
+

You're eligible!

+
+ {claim.quote.payout_sats} + sats +
+

+ To {lightningAddress} +

+
+ +
+
) : ( )} @@ -208,23 +242,6 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces preventClose={claim.loading !== "idle"} >
- {claim.loading === "quote" && ( -
-
-

- {ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]} -

-
- )} - - {claim.denial && ( - - )} - {(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && ( -

Confirm payout

+

Confirm claim

{quote.payout_sats} sats @@ -99,7 +99,7 @@ export function ConfirmStep({ onClick={onConfirm} disabled={quoteExpired} > - Confirm payout + Claim