Redesign faucet claim flow: login → loading → eligibility → claim

- Backend: payout is now random between FAUCET_MIN_SATS and FAUCET_MAX_SATS
- Remove weighted buckets; simplify config and .env.example
- Frontend: show loading and eligibility in main body, modal only for confirm/success
- Rename Confirm payout to Claim; add styles for body loading and eligible state

Made-with: Cursor
This commit is contained in:
SatsFaucet
2026-03-02 14:59:37 +01:00
parent 381597c96f
commit 623eb720dc
6 changed files with 67 additions and 74 deletions

View File

@@ -18,16 +18,9 @@ NONCE_TTL_SECONDS=600
# Faucet economics # Faucet economics
FAUCET_ENABLED=true FAUCET_ENABLED=true
EMERGENCY_STOP=false EMERGENCY_STOP=false
# Payout: random amount between FAUCET_MIN_SATS and FAUCET_MAX_SATS (inclusive)
FAUCET_MIN_SATS=10 FAUCET_MIN_SATS=10
FAUCET_MAX_SATS=100 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 DAILY_BUDGET_SATS=10000
MAX_CLAIMS_PER_DAY=100 MAX_CLAIMS_PER_DAY=100
MIN_WALLET_BALANCE_SATS=1000 MIN_WALLET_BALANCE_SATS=1000

View File

@@ -26,6 +26,8 @@ export const config = {
trustProxy: envBool("TRUST_PROXY", false), trustProxy: envBool("TRUST_PROXY", false),
/** Public path prefix when behind a reverse proxy that strips it (e.g. nginx /api/ -> backend /). */ /** Public path prefix when behind a reverse proxy that strips it (e.g. nginx /api/ -> backend /). */
publicBasePath: (process.env.PUBLIC_BASE_PATH ?? "").replace(/\/$/, ""), 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()), 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 // Database: omit DATABASE_URL for SQLite; set for Postgres
@@ -44,14 +46,6 @@ export const config = {
emergencyStop: envBool("EMERGENCY_STOP", false), emergencyStop: envBool("EMERGENCY_STOP", false),
faucetMinSats: envInt("FAUCET_MIN_SATS", 1), faucetMinSats: envInt("FAUCET_MIN_SATS", 1),
faucetMaxSats: envInt("FAUCET_MAX_SATS", 5), 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), dailyBudgetSats: envInt("DAILY_BUDGET_SATS", 10000),
maxClaimsPerDay: envInt("MAX_CLAIMS_PER_DAY", 100), maxClaimsPerDay: envInt("MAX_CLAIMS_PER_DAY", 100),
minWalletBalanceSats: envInt("MIN_WALLET_BALANCE_SATS", 1000), minWalletBalanceSats: envInt("MIN_WALLET_BALANCE_SATS", 1000),

View File

@@ -6,42 +6,17 @@ import { getWalletBalanceSats } from "./lnbits.js";
const QUOTE_TTL_SECONDS = 60; 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. * Random payout between FAUCET_MIN_SATS and FAUCET_MAX_SATS (inclusive),
*/ * capped by daily budget remaining.
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.
*/ */
export function computePayoutForClaim(todayPaidSats: number): number { export function computePayoutForClaim(todayPaidSats: number): number {
const remaining = Math.max(0, config.dailyBudgetSats - todayPaidSats); const remaining = Math.max(0, config.dailyBudgetSats - todayPaidSats);
if (remaining < config.faucetMinSats) return 0; if (remaining < config.faucetMinSats) return 0;
const selected = selectWeightedPayout(); const min = Math.max(config.faucetMinSats, 1);
return Math.min(selected, remaining, config.faucetMaxSats); const max = Math.min(config.faucetMaxSats, remaining);
if (max < min) return 0;
return randomInt(min, max + 1);
} }
export interface CreateQuoteResult { export interface CreateQuoteResult {

View File

@@ -36,8 +36,6 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
const nostrProfile = useNostrProfile(pubkey); const nostrProfile = useNostrProfile(pubkey);
const isConnected = !!pubkey; const isConnected = !!pubkey;
const isBusy = claim.loading !== "idle";
const hasResult = !!claim.quote || !!claim.denial || !!claim.success || !!claim.confirmError;
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
@@ -73,15 +71,14 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
const addr = lightningAddress.trim(); const addr = lightningAddress.trim();
if (!addr || !isValidLightningAddress(addr)) return; if (!addr || !isValidLightningAddress(addr)) return;
autoCheckRef.current = pubkey; autoCheckRef.current = pubkey;
setClaimModalOpen(true);
claim.checkEligibility(addr); claim.checkEligibility(addr);
}, [pubkey, lightningAddress, claim.loading, claim.claimState, claim.checkEligibility]); }, [pubkey, lightningAddress, claim.loading, claim.claimState, claim.checkEligibility]);
useEffect(() => { useEffect(() => {
if (isBusy || hasResult) { if (claim.loading === "confirm" || claim.success || claim.confirmError) {
setClaimModalOpen(true); setClaimModalOpen(true);
} }
}, [isBusy, hasResult]); }, [claim.loading, claim.success, claim.confirmError]);
const handleDisconnect = () => { const handleDisconnect = () => {
onPubkeyChange(null); onPubkeyChange(null);
@@ -105,10 +102,10 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
claim.clearConfirmError(); claim.clearConfirmError();
setLightningAddressTouched(false); setLightningAddressTouched(false);
setClaimModalOpen(false); setClaimModalOpen(false);
autoCheckRef.current = null;
}; };
const handleCheckEligibility = () => { const handleCheckEligibility = () => {
setClaimModalOpen(true);
claim.checkEligibility(lightningAddress); claim.checkEligibility(lightningAddress);
}; };
@@ -137,6 +134,10 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
setClaimModalOpen(false); setClaimModalOpen(false);
}; };
const handleClaimClick = () => {
setClaimModalOpen(true);
};
const lightningAddressInvalid = const lightningAddressInvalid =
lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress); lightningAddressTouched && lightningAddress.trim() !== "" && !isValidLightningAddress(lightningAddress);
const fromProfile = const fromProfile =
@@ -151,7 +152,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
if (claim.success) return "Sats sent!"; if (claim.success) return "Sats sent!";
if (claim.confirmError) return "Something went wrong"; if (claim.confirmError) return "Something went wrong";
if (claim.loading === "confirm") return "Sending sats…"; 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.denial) return "Not eligible";
if (claim.loading === "quote") return "Checking eligibility"; if (claim.loading === "quote") return "Checking eligibility";
return "Claim"; return "Claim";
@@ -185,6 +186,39 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
onConnect={(pk, method) => onPubkeyChange(pk, method)} onConnect={(pk, method) => onPubkeyChange(pk, method)}
onDisconnect={handleDisconnect} onDisconnect={handleDisconnect}
/> />
) : claim.loading === "quote" ? (
<div className="claim-wizard-body-loading" role="status" aria-live="polite">
<div className="claim-wizard-progress-bar" />
<p className="claim-wizard-progress-text">
{ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]}
</p>
</div>
) : claim.denial ? (
<ClaimDenialPanel
denial={claim.denial}
onDismiss={handleDismissDenial}
onCheckAgain={handleCheckAgain}
/>
) : claim.quote && !quoteExpired ? (
<div className="claim-wizard-step claim-wizard-step-eligible">
<h3 className="claim-wizard-step-title">You&apos;re eligible!</h3>
<div className="claim-wizard-quote-amount claim-wizard-quote-amount--main">
<span className="claim-wizard-quote-amount-value">{claim.quote.payout_sats}</span>
<span className="claim-wizard-quote-amount-unit"> sats</span>
</div>
<p className="claim-wizard-quote-destination">
To <strong>{lightningAddress}</strong>
</p>
<div className="claim-wizard-step-actions">
<button
type="button"
className="btn-primary claim-wizard-btn-primary"
onClick={handleClaimClick}
>
Claim
</button>
</div>
</div>
) : ( ) : (
<EligibilityStep <EligibilityStep
lightningAddress={lightningAddress} lightningAddress={lightningAddress}
@@ -194,7 +228,7 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
invalid={lightningAddressInvalid} invalid={lightningAddressInvalid}
fromProfile={fromProfile} fromProfile={fromProfile}
readOnly={loginMethod === "npub"} readOnly={loginMethod === "npub"}
loading={claim.loading === "quote"} loading={false}
onCheckEligibility={handleCheckEligibility} onCheckEligibility={handleCheckEligibility}
/> />
)} )}
@@ -208,23 +242,6 @@ export function ClaimWizard({ pubkey, loginMethod, onPubkeyChange, onClaimSucces
preventClose={claim.loading !== "idle"} preventClose={claim.loading !== "idle"}
> >
<div className="claim-modal-body"> <div className="claim-modal-body">
{claim.loading === "quote" && (
<div className="claim-modal-progress" role="status" aria-live="polite">
<div className="claim-wizard-progress-bar" />
<p className="claim-modal-progress-text">
{ELIGIBILITY_PROGRESS_STEPS[claim.eligibilityProgressStep ?? 0]}
</p>
</div>
)}
{claim.denial && (
<ClaimDenialPanel
denial={claim.denial}
onDismiss={handleDismissDenial}
onCheckAgain={handleCheckAgain}
/>
)}
{(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && ( {(claim.claimState === "quote_ready" || claim.claimState === "confirming" || claim.claimState === "error") && claim.quote && (
<ConfirmStep <ConfirmStep
quote={claim.quote} quote={claim.quote}

View File

@@ -80,7 +80,7 @@ export function ConfirmStep({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<h3 className="claim-wizard-step-title">Confirm payout</h3> <h3 className="claim-wizard-step-title">Confirm claim</h3>
<div className="claim-wizard-quote-amount"> <div className="claim-wizard-quote-amount">
<span className="claim-wizard-quote-amount-value">{quote.payout_sats}</span> <span className="claim-wizard-quote-amount-value">{quote.payout_sats}</span>
<span className="claim-wizard-quote-amount-unit">sats</span> <span className="claim-wizard-quote-amount-unit">sats</span>
@@ -99,7 +99,7 @@ export function ConfirmStep({
onClick={onConfirm} onClick={onConfirm}
disabled={quoteExpired} disabled={quoteExpired}
> >
Confirm payout Claim
</button> </button>
<button type="button" className="btn-secondary" onClick={onCancel}> <button type="button" className="btn-secondary" onClick={onCancel}>
Cancel Cancel

View File

@@ -1766,6 +1766,20 @@ h1 {
margin-top: 4px; margin-top: 4px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.claim-wizard-body-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px 0;
}
.claim-wizard-body-loading .claim-wizard-progress-bar {
width: 100%;
max-width: 280px;
}
.claim-wizard-step-eligible .claim-wizard-quote-amount--main {
margin: 24px 0 16px;
}
.claim-wizard-progress { .claim-wizard-progress {
margin: 16px 0; margin: 16px 0;
} }