From 55a777309748276f55507ca341680c5188af675c Mon Sep 17 00:00:00 2001 From: 9904099 <9904099@gmail.com> Date: Sat, 20 Jun 2026 15:54:12 +0800 Subject: [PATCH 1/2] fix: scope faucet pending state per token --- .../faucet/components/faucet-page.tsx | 75 ++++++--- .../src/features/faucet/hooks/useClaim.tsx | 158 +++++++++++++----- 2 files changed, 163 insertions(+), 70 deletions(-) diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx index 2a0a4c4..ab05eb8 100644 --- a/apps/web/src/features/faucet/components/faucet-page.tsx +++ b/apps/web/src/features/faucet/components/faucet-page.tsx @@ -1,5 +1,10 @@ import { Skeleton } from "@workspace/ui/components/skeleton" import { Button } from "@workspace/ui/components/button" +import { FAUCET_TOKENS } from "../data/tokens" +import { FAUCET_CONTRACT_ID } from "../lib/clients" +import { useFaucetData } from "../hooks/useFaucetData" +import { useClaim } from "../hooks/useClaim" +import type { FaucetTokenConfig } from "../data/tokens" import { Navbar } from "@/ui/Navbar" import { TokenIcon } from "@/shared/components/TokenIcon" import { formatToken } from "@/shared/lib/format" @@ -8,10 +13,6 @@ import { useWalletStore } from "@/features/wallet/store/wallet-store" import { useNetwork } from "@/features/wallet/hooks/useNetwork" import { NetworkMismatchBanner } from "@/features/wallet/components/NetworkMismatchBanner" import { ConnectButton } from "@/features/wallet/components/ConnectButton" -import { FAUCET_TOKENS, type FaucetTokenConfig } from "../data/tokens" -import { FAUCET_CONTRACT_ID } from "../lib/clients" -import { useFaucetData } from "../hooks/useFaucetData" -import { useClaim } from "../hooks/useClaim" // ── Token card ──────────────────────────────────────────────────────────────── @@ -48,14 +49,16 @@ function TokenCard({
-

{token.symbol}

+

+ {token.symbol} +

{token.name}

-

+

Your balance

{isLoading ? ( @@ -68,7 +71,7 @@ function TokenCard({
-

+

Claim amount

{isLoading ? ( @@ -82,7 +85,9 @@ function TokenCard({
-

{cooldownText}

+

+ {cooldownText} +

@@ -104,21 +116,24 @@ export function FaucetPage() { const isConnected = useWalletStore((state) => state.status === "connected") const { mismatch } = useNetwork() const { data, isLoading } = useFaucetData(address) - const { claim, isPending } = useClaim() + const { claim, isBulkPending, isTokenPending } = useClaim() const isTestnet = NETWORK.name === "testnet" - const claimDisabled = !isConnected || isPending || mismatch + const globalClaimDisabled = !isConnected || mismatch + const bulkClaimDisabled = globalClaimDisabled || isBulkPending return (
-
+
{/* Header */}
-

Testnet Faucet

+

+ Testnet Faucet +

Stellar Testnet @@ -148,8 +163,8 @@ export function FaucetPage() { lastClaimLedger={data?.lastClaimLedgers[token.symbol]} cooldownLedgers={data?.cooldownLedgers} isLoading={isLoading} - isPending={isPending} - isDisabled={claimDisabled} + isPending={isTokenPending(token.contractId)} + isDisabled={globalClaimDisabled} onClaim={(selectedToken) => claim([selectedToken.contractId])} /> ))} @@ -159,10 +174,12 @@ export function FaucetPage() {
-

Claim test tokens

+

+ Claim test tokens +

- Receive TUSDC, TWBTC, TETH, and TXLM in a single transaction. A cooldown - applies between claims. + Receive TUSDC, TWBTC, TETH, and TXLM in a single + transaction. A cooldown applies between claims.

@@ -174,17 +191,19 @@ export function FaucetPage() { {!isConnected ? (
-

Connect your wallet to claim test tokens.

+

+ Connect your wallet to claim test tokens. +

) : (
@@ -205,7 +225,7 @@ export function FaucetPage() { {/* Info panel */}
-

+

Contract addresses

@@ -216,8 +236,13 @@ export function FaucetPage() { id: token.contractId, })), ].map(({ label, id }) => ( -
-
{label}
+
+
+ {label} +
{id}
diff --git a/apps/web/src/features/faucet/hooks/useClaim.tsx b/apps/web/src/features/faucet/hooks/useClaim.tsx index ff46593..7d8e79f 100644 --- a/apps/web/src/features/faucet/hooks/useClaim.tsx +++ b/apps/web/src/features/faucet/hooks/useClaim.tsx @@ -1,13 +1,13 @@ -import { useCallback, useState } from "react" +import { useCallback, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { useQueryClient } from "@tanstack/react-query" +import { FAUCET_TOKENS } from "../data/tokens" +import { createFaucetClient } from "../lib/clients" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { walletKit } from "@/features/wallet/lib/wallet-kit" import { sendAndPoll } from "@/lib/tx-builder" import { explorerTxUrl } from "@/app/config/network" import { queryKeys } from "@/shared/lib/query-keys" -import { FAUCET_TOKENS } from "../data/tokens" -import { createFaucetClient } from "../lib/clients" import { parseSorobanError } from "@/lib/contracts" function isClaimTooSoonError(error: unknown): boolean { @@ -24,53 +24,121 @@ export function useClaim() { const address = useWalletStore((state) => state.address) const isConnected = useWalletStore((state) => state.status === "connected") const queryClient = useQueryClient() - const [isPending, setIsPending] = useState(false) + const [pendingTokenIds, setPendingTokenIds] = useState>( + () => new Set() + ) + const pendingTokenIdsRef = useRef>(new Set()) + const isBulkPendingRef = useRef(false) + const [isBulkPending, setIsBulkPending] = useState(false) + + const setBulkPending = useCallback((value: boolean) => { + isBulkPendingRef.current = value + setIsBulkPending(value) + }, []) + + const markTokensPending = useCallback((tokenIds: Array) => { + setPendingTokenIds((current) => { + const next = new Set(current) + tokenIds.forEach((tokenId) => next.add(tokenId)) + pendingTokenIdsRef.current = next + return next + }) + }, []) - const claim = useCallback(async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { - if (!address || !isConnected) return + const clearTokensPending = useCallback((tokenIds: Array) => { + setPendingTokenIds((current) => { + const next = new Set(current) + tokenIds.forEach((tokenId) => next.delete(tokenId)) + pendingTokenIdsRef.current = next + return next + }) + }, []) - setIsPending(true) - const toastId = toast.loading( - tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…", - ) + const claim = useCallback( + async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { + if (!address || !isConnected) return - try { - const faucet = createFaucetClient(address) - const tx = await faucet.claim_many({ - account: address, - tokens: tokenIds, - }) + const isBulkClaim = tokenIds.length !== 1 + const hasPendingToken = tokenIds.some((tokenId) => + pendingTokenIdsRef.current.has(tokenId) + ) + if (isBulkClaim ? isBulkPendingRef.current : hasPendingToken) return - const unsignedXdr = tx.toXDR() - const { signedTxXdr } = await walletKit.signTransaction(unsignedXdr) - const signedXdr = signedTxXdr - const { hash } = await sendAndPoll(signedXdr) + if (isBulkClaim) { + setBulkPending(true) + } + markTokensPending(tokenIds) + const toastId = toast.loading( + tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…" + ) - // Refresh balances after a successful claim - await queryClient.invalidateQueries({ queryKey: queryKeys.faucet.data(address) }) + try { + const faucet = createFaucetClient(address) + const tx = await faucet.claim_many({ + account: address, + tokens: tokenIds, + }) - toast.success("Test tokens claimed!", { - id: toastId, - description: ( - - View transaction → - - ), - }) - } catch (error) { - const message = isClaimTooSoonError(error) - ? "Cooldown active — please wait before claiming again." - : parseSorobanError(error) - toast.error(message, { id: toastId }) - } finally { - setIsPending(false) - } - }, [address, isConnected, queryClient]) + const unsignedXdr = tx.toXDR() + const { signedTxXdr } = await walletKit.signTransaction(unsignedXdr) + const signedXdr = signedTxXdr + const { hash } = await sendAndPoll(signedXdr) + + // Refresh balances after a successful claim + await queryClient.invalidateQueries({ + queryKey: queryKeys.faucet.data(address), + }) + + toast.success("Test tokens claimed!", { + id: toastId, + description: ( + + View transaction → + + ), + }) + } catch (error) { + const message = isClaimTooSoonError(error) + ? "Cooldown active — please wait before claiming again." + : parseSorobanError(error) + toast.error(message, { id: toastId }) + } finally { + if (isBulkClaim) { + setBulkPending(false) + } + clearTokensPending(tokenIds) + } + }, + [ + address, + clearTokensPending, + isConnected, + markTokensPending, + queryClient, + setBulkPending, + ] + ) + + const isPending = isBulkPending || pendingTokenIds.size > 0 + const isTokenPending = useCallback( + (tokenId: string) => pendingTokenIds.has(tokenId), + [pendingTokenIds] + ) + const pendingTokenIdList = useMemo( + () => Array.from(pendingTokenIds), + [pendingTokenIds] + ) - return { claim, isPending } + return { + claim, + isPending, + isBulkPending, + isTokenPending, + pendingTokenIds: pendingTokenIdList, + } } From 803201396c7946ec3d4b037fb680e1a0628c8589 Mon Sep 17 00:00:00 2001 From: 9904099 <9904099@gmail.com> Date: Sat, 20 Jun 2026 17:17:01 +0800 Subject: [PATCH 2/2] fix: track faucet claim states per token --- .../faucet/components/faucet-page.tsx | 32 ++++++- .../src/features/faucet/hooks/useClaim.tsx | 89 +++++++++++++------ 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx index ab05eb8..0afea43 100644 --- a/apps/web/src/features/faucet/components/faucet-page.tsx +++ b/apps/web/src/features/faucet/components/faucet-page.tsx @@ -5,6 +5,7 @@ import { FAUCET_CONTRACT_ID } from "../lib/clients" import { useFaucetData } from "../hooks/useFaucetData" import { useClaim } from "../hooks/useClaim" import type { FaucetTokenConfig } from "../data/tokens" +import type { TokenClaimState } from "../hooks/useClaim" import { Navbar } from "@/ui/Navbar" import { TokenIcon } from "@/shared/components/TokenIcon" import { formatToken } from "@/shared/lib/format" @@ -24,6 +25,7 @@ type TokenCardProps = { cooldownLedgers: number | undefined isLoading: boolean isPending: boolean + claimState: TokenClaimState isDisabled: boolean onClaim: (token: FaucetTokenConfig) => void } @@ -36,6 +38,7 @@ function TokenCard({ cooldownLedgers, isLoading, isPending, + claimState, isDisabled, onClaim, }: TokenCardProps) { @@ -43,6 +46,10 @@ function TokenCard({ lastClaimLedger && cooldownLedgers ? `Last claim ledger ${lastClaimLedger.toLocaleString()}` : "No claim recorded" + const claimFeedback = + claimState.status === "success" || claimState.status === "error" + ? claimState.message + : null return (
@@ -105,6 +112,18 @@ function TokenCard({ )}
+ + {claimFeedback ? ( +

+ {claimFeedback} +

+ ) : null}
) } @@ -116,11 +135,19 @@ export function FaucetPage() { const isConnected = useWalletStore((state) => state.status === "connected") const { mismatch } = useNetwork() const { data, isLoading } = useFaucetData(address) - const { claim, isBulkPending, isTokenPending } = useClaim() + const { + claim, + isBulkPending, + isTokenPending, + hasPendingTokens, + getTokenClaimState, + } = useClaim() const isTestnet = NETWORK.name === "testnet" const globalClaimDisabled = !isConnected || mismatch - const bulkClaimDisabled = globalClaimDisabled || isBulkPending + // Bulk claims include every faucet token, so disable bulk while any token claim is in flight. + const bulkClaimDisabled = + globalClaimDisabled || isBulkPending || hasPendingTokens return (
@@ -164,6 +191,7 @@ export function FaucetPage() { cooldownLedgers={data?.cooldownLedgers} isLoading={isLoading} isPending={isTokenPending(token.contractId)} + claimState={getTokenClaimState(token.contractId)} isDisabled={globalClaimDisabled} onClaim={(selectedToken) => claim([selectedToken.contractId])} /> diff --git a/apps/web/src/features/faucet/hooks/useClaim.tsx b/apps/web/src/features/faucet/hooks/useClaim.tsx index 7d8e79f..516872d 100644 --- a/apps/web/src/features/faucet/hooks/useClaim.tsx +++ b/apps/web/src/features/faucet/hooks/useClaim.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react" +import { useCallback, useRef, useState } from "react" import { toast } from "sonner" import { useQueryClient } from "@tanstack/react-query" import { FAUCET_TOKENS } from "../data/tokens" @@ -20,6 +20,15 @@ function isClaimTooSoonError(error: unknown): boolean { ) } +type ClaimStatus = "idle" | "pending" | "success" | "error" + +export type TokenClaimState = { + status: ClaimStatus + message?: string +} + +const IDLE_CLAIM_STATE: TokenClaimState = { status: "idle" } + export function useClaim() { const address = useWalletStore((state) => state.address) const isConnected = useWalletStore((state) => state.status === "connected") @@ -30,6 +39,9 @@ export function useClaim() { const pendingTokenIdsRef = useRef>(new Set()) const isBulkPendingRef = useRef(false) const [isBulkPending, setIsBulkPending] = useState(false) + const [tokenClaimStates, setTokenClaimStates] = useState< + Record + >({}) const setBulkPending = useCallback((value: boolean) => { isBulkPendingRef.current = value @@ -37,23 +49,44 @@ export function useClaim() { }, []) const markTokensPending = useCallback((tokenIds: Array) => { - setPendingTokenIds((current) => { - const next = new Set(current) - tokenIds.forEach((tokenId) => next.add(tokenId)) - pendingTokenIdsRef.current = next - return next - }) + const next = new Set(pendingTokenIdsRef.current) + tokenIds.forEach((tokenId) => next.add(tokenId)) + pendingTokenIdsRef.current = next + setPendingTokenIds(next) }, []) const clearTokensPending = useCallback((tokenIds: Array) => { - setPendingTokenIds((current) => { - const next = new Set(current) - tokenIds.forEach((tokenId) => next.delete(tokenId)) - pendingTokenIdsRef.current = next - return next - }) + const next = new Set(pendingTokenIdsRef.current) + tokenIds.forEach((tokenId) => next.delete(tokenId)) + pendingTokenIdsRef.current = next + setPendingTokenIds(next) }, []) + const setTokensClaimState = useCallback( + (tokenIds: Array, state: TokenClaimState) => { + setTokenClaimStates((current) => { + const next = { ...current } + tokenIds.forEach((tokenId) => { + next[tokenId] = state + }) + return next + }) + }, + [] + ) + + const getTokenClaimState = useCallback( + (tokenId: string) => tokenClaimStates[tokenId] ?? IDLE_CLAIM_STATE, + [tokenClaimStates] + ) + + const isTokenPending = useCallback( + (tokenId: string) => pendingTokenIds.has(tokenId), + [pendingTokenIds] + ) + + const hasPendingTokens = pendingTokenIds.size > 0 + const claim = useCallback( async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => { if (!address || !isConnected) return @@ -62,12 +95,18 @@ export function useClaim() { const hasPendingToken = tokenIds.some((tokenId) => pendingTokenIdsRef.current.has(tokenId) ) - if (isBulkClaim ? isBulkPendingRef.current : hasPendingToken) return + if ( + isBulkClaim + ? isBulkPendingRef.current || hasPendingToken + : hasPendingToken + ) + return if (isBulkClaim) { setBulkPending(true) } markTokensPending(tokenIds) + setTokensClaimState(tokenIds, { status: "pending" }) const toastId = toast.loading( tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…" ) @@ -89,6 +128,11 @@ export function useClaim() { queryKey: queryKeys.faucet.data(address), }) + setTokensClaimState(tokenIds, { + status: "success", + message: "Claim submitted. Balance refreshes shortly.", + }) + toast.success("Test tokens claimed!", { id: toastId, description: ( @@ -106,6 +150,8 @@ export function useClaim() { const message = isClaimTooSoonError(error) ? "Cooldown active — please wait before claiming again." : parseSorobanError(error) + + setTokensClaimState(tokenIds, { status: "error", message }) toast.error(message, { id: toastId }) } finally { if (isBulkClaim) { @@ -121,24 +167,15 @@ export function useClaim() { markTokensPending, queryClient, setBulkPending, + setTokensClaimState, ] ) - const isPending = isBulkPending || pendingTokenIds.size > 0 - const isTokenPending = useCallback( - (tokenId: string) => pendingTokenIds.has(tokenId), - [pendingTokenIds] - ) - const pendingTokenIdList = useMemo( - () => Array.from(pendingTokenIds), - [pendingTokenIds] - ) - return { claim, - isPending, isBulkPending, isTokenPending, - pendingTokenIds: pendingTokenIdList, + hasPendingTokens, + getTokenClaimState, } }