diff --git a/apps/web/src/features/faucet/components/faucet-page.tsx b/apps/web/src/features/faucet/components/faucet-page.tsx
index 2a0a4c4..0afea43 100644
--- a/apps/web/src/features/faucet/components/faucet-page.tsx
+++ b/apps/web/src/features/faucet/components/faucet-page.tsx
@@ -1,5 +1,11 @@
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 type { TokenClaimState } from "../hooks/useClaim"
import { Navbar } from "@/ui/Navbar"
import { TokenIcon } from "@/shared/components/TokenIcon"
import { formatToken } from "@/shared/lib/format"
@@ -8,10 +14,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 ────────────────────────────────────────────────────────────────
@@ -23,6 +25,7 @@ type TokenCardProps = {
cooldownLedgers: number | undefined
isLoading: boolean
isPending: boolean
+ claimState: TokenClaimState
isDisabled: boolean
onClaim: (token: FaucetTokenConfig) => void
}
@@ -35,6 +38,7 @@ function TokenCard({
cooldownLedgers,
isLoading,
isPending,
+ claimState,
isDisabled,
onClaim,
}: TokenCardProps) {
@@ -42,20 +46,26 @@ function TokenCard({
lastClaimLedger && cooldownLedgers
? `Last claim ledger ${lastClaimLedger.toLocaleString()}`
: "No claim recorded"
+ const claimFeedback =
+ claimState.status === "success" || claimState.status === "error"
+ ? claimState.message
+ : null
return (
-
{token.symbol}
+
+ {token.symbol}
+
{token.name}
-
+
Your balance
{isLoading ? (
@@ -68,7 +78,7 @@ function TokenCard({
-
+
Claim amount
{isLoading ? (
@@ -82,7 +92,9 @@ function TokenCard({
-
{cooldownText}
+
+ {cooldownText}
+
onClaim(token)}
>
- Claim
+ {isPending ? (
+
+
+ Claiming…
+
+ ) : (
+ "Claim"
+ )}
+
+ {claimFeedback ? (
+
+ {claimFeedback}
+
+ ) : null}
)
}
@@ -104,21 +135,32 @@ 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,
+ hasPendingTokens,
+ getTokenClaimState,
+ } = useClaim()
const isTestnet = NETWORK.name === "testnet"
- const claimDisabled = !isConnected || isPending || mismatch
+ const globalClaimDisabled = !isConnected || mismatch
+ // Bulk claims include every faucet token, so disable bulk while any token claim is in flight.
+ const bulkClaimDisabled =
+ globalClaimDisabled || isBulkPending || hasPendingTokens
return (
-
+
{/* Header */}
-
Testnet Faucet
+
+ Testnet Faucet
+
Stellar Testnet
@@ -148,8 +190,9 @@ export function FaucetPage() {
lastClaimLedger={data?.lastClaimLedgers[token.symbol]}
cooldownLedgers={data?.cooldownLedgers}
isLoading={isLoading}
- isPending={isPending}
- isDisabled={claimDisabled}
+ isPending={isTokenPending(token.contractId)}
+ claimState={getTokenClaimState(token.contractId)}
+ isDisabled={globalClaimDisabled}
onClaim={(selectedToken) => claim([selectedToken.contractId])}
/>
))}
@@ -159,10 +202,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 +219,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 +244,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 +253,7 @@ export function FaucetPage() {
{/* Info panel */}
-
+
Contract addresses
@@ -216,8 +264,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..516872d 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, 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 {
@@ -20,57 +20,162 @@ 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")
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 [tokenClaimStates, setTokenClaimStates] = useState<
+ Record
+ >({})
- const claim = useCallback(async (tokenIds = FAUCET_TOKENS.map((token) => token.contractId)) => {
- if (!address || !isConnected) return
+ const setBulkPending = useCallback((value: boolean) => {
+ isBulkPendingRef.current = value
+ setIsBulkPending(value)
+ }, [])
- setIsPending(true)
- const toastId = toast.loading(
- tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…",
- )
+ const markTokensPending = useCallback((tokenIds: Array) => {
+ const next = new Set(pendingTokenIdsRef.current)
+ tokenIds.forEach((tokenId) => next.add(tokenId))
+ pendingTokenIdsRef.current = next
+ setPendingTokenIds(next)
+ }, [])
- try {
- const faucet = createFaucetClient(address)
- const tx = await faucet.claim_many({
- account: address,
- tokens: tokenIds,
- })
+ const clearTokensPending = useCallback((tokenIds: Array) => {
+ const next = new Set(pendingTokenIdsRef.current)
+ tokenIds.forEach((tokenId) => next.delete(tokenId))
+ pendingTokenIdsRef.current = next
+ setPendingTokenIds(next)
+ }, [])
- 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 →
-
- ),
+ const setTokensClaimState = useCallback(
+ (tokenIds: Array, state: TokenClaimState) => {
+ setTokenClaimStates((current) => {
+ const next = { ...current }
+ tokenIds.forEach((tokenId) => {
+ next[tokenId] = state
+ })
+ return next
})
- } 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])
-
- return { claim, isPending }
+ },
+ []
+ )
+
+ 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
+
+ const isBulkClaim = tokenIds.length !== 1
+ const hasPendingToken = tokenIds.some((tokenId) =>
+ pendingTokenIdsRef.current.has(tokenId)
+ )
+ 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…"
+ )
+
+ try {
+ const faucet = createFaucetClient(address)
+ const tx = await faucet.claim_many({
+ account: address,
+ tokens: tokenIds,
+ })
+
+ 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),
+ })
+
+ setTokensClaimState(tokenIds, {
+ status: "success",
+ message: "Claim submitted. Balance refreshes shortly.",
+ })
+
+ 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)
+
+ setTokensClaimState(tokenIds, { status: "error", message })
+ toast.error(message, { id: toastId })
+ } finally {
+ if (isBulkClaim) {
+ setBulkPending(false)
+ }
+ clearTokensPending(tokenIds)
+ }
+ },
+ [
+ address,
+ clearTokensPending,
+ isConnected,
+ markTokensPending,
+ queryClient,
+ setBulkPending,
+ setTokensClaimState,
+ ]
+ )
+
+ return {
+ claim,
+ isBulkPending,
+ isTokenPending,
+ hasPendingTokens,
+ getTokenClaimState,
+ }
}