-
{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,
}
}