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] 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}
+
onClaim(token)}
>
- Claim
+ {isPending ? (
+
+
+ Claiming…
+
+ ) : (
+ "Claim"
+ )}
@@ -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.
+
) : (
claim()}
>
- {isPending ? (
+ {isBulkPending ? (
Claiming…
@@ -197,7 +216,8 @@ export function FaucetPage() {
{data?.cooldownLedgers != null && data.cooldownLedgers > 0 && (
- Cooldown: {data.cooldownLedgers.toLocaleString()} ledgers between claims
+ Cooldown: {data.cooldownLedgers.toLocaleString()} ledgers
+ between claims
)}
@@ -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,
+ }
}