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:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user