Skip to content

Commit c19bb18

Browse files
Give each faucet token card its own independent claim state
- Serialize through shared signer via isClaimingRef (prevents concurrent wallet prompts) - Explicit claimOne(tokenId) / claimAll() instead of inferring bulk from array length - isBulkPending reflected on all cards during bulk claim - Defense-in-depth: isClaimingRef gates new claims while any in-flight - Singular/plural success toast matches single vs bulk claim - Each card owns its own pending state via pendingTokens Set - Per-card spinner shown while pending or during bulk claim - Balance refresh via invalidateQueries preserved on success
1 parent 49d189f commit c19bb18

4 files changed

Lines changed: 76 additions & 38 deletions

File tree

apps/web/src/features/faucet/components/faucet-page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function FaucetPage() {
111111
const isConnected = useWalletStore((state) => state.status === "connected")
112112
const { mismatch } = useNetwork()
113113
const { data, isLoading } = useFaucetData(address)
114-
const { claim, pendingTokens, isBulkPending } = useClaim()
114+
const { claimOne, claimAll, pendingTokens, isBulkPending } = useClaim()
115115

116116
const isTestnet = NETWORK.name === "testnet"
117117
const claimDisabled = !isConnected || mismatch
@@ -155,9 +155,9 @@ export function FaucetPage() {
155155
lastClaimLedger={data?.lastClaimLedgers[token.symbol]}
156156
cooldownLedgers={data?.cooldownLedgers}
157157
isLoading={isLoading}
158-
isPending={pendingTokens.has(token.contractId)}
158+
isPending={pendingTokens.has(token.contractId) || isBulkPending}
159159
isDisabled={claimDisabled}
160-
onClaim={(selectedToken) => claim([selectedToken.contractId])}
160+
onClaim={(selectedToken) => claimOne(selectedToken.contractId)}
161161
/>
162162
))}
163163
</div>
@@ -189,7 +189,7 @@ export function FaucetPage() {
189189
variant="default"
190190
className="w-full"
191191
disabled={claimDisabled || isBulkPending}
192-
onClick={() => claim(FAUCET_TOKENS.map((t) => t.contractId))}
192+
onClick={() => claimAll()}
193193
>
194194
{isBulkPending ? (
195195
<span className="flex items-center gap-2">

apps/web/src/features/faucet/hooks/useClaim.tsx

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState } from "react"
1+
import { useCallback, useRef, useState } from "react"
22
import { toast } from "sonner"
33
import { useQueryClient } from "@tanstack/react-query"
44
import { FAUCET_TOKENS } from "../data/tokens"
@@ -26,29 +26,77 @@ export function useClaim() {
2626
const queryClient = useQueryClient()
2727
const [pendingTokens, setPendingTokens] = useState<Set<string>>(new Set())
2828
const [isBulkPending, setIsBulkPending] = useState(false)
29+
const isClaimingRef = useRef(false)
2930

30-
const claim = useCallback(
31-
async (tokenIds: Array<string>) => {
31+
const claimOne = useCallback(
32+
async (tokenId: string) => {
3233
if (!address || !isConnected) return
33-
if (tokenIds.length === 0) return
34+
if (isClaimingRef.current) return
3435

35-
const isBulk = tokenIds.length === FAUCET_TOKENS.length
36+
isClaimingRef.current = true
37+
setPendingTokens((prev) => {
38+
const next = new Set(prev)
39+
next.add(tokenId)
40+
return next
41+
})
3642

37-
if (isBulk) {
38-
setIsBulkPending(true)
39-
} else {
43+
const toastId = toast.loading("Claiming test token…")
44+
45+
try {
46+
const faucet = createFaucetClient(address)
47+
const tx = await faucet.claim_many({
48+
account: address,
49+
tokens: [tokenId],
50+
})
51+
52+
const unsignedXdr = tx.toXDR()
53+
const { signedTxXdr } = await walletKit.signTransaction(unsignedXdr)
54+
const signedXdr = signedTxXdr
55+
const { hash } = await sendAndPoll(signedXdr)
56+
57+
// Refresh balances after a successful claim
58+
await queryClient.invalidateQueries({ queryKey: queryKeys.faucet.data(address) })
59+
60+
toast.success("Test token claimed!", {
61+
id: toastId,
62+
description: (
63+
<a
64+
href={explorerTxUrl(hash)}
65+
target="_blank"
66+
rel="noreferrer"
67+
className="text-xs text-primary hover:underline"
68+
>
69+
View transaction →
70+
</a>
71+
),
72+
})
73+
} catch (error) {
74+
const message = isClaimTooSoonError(error)
75+
? "Cooldown active — please wait before claiming again."
76+
: parseSorobanError(error)
77+
toast.error(message, { id: toastId })
78+
} finally {
4079
setPendingTokens((prev) => {
4180
const next = new Set(prev)
42-
for (const id of tokenIds) {
43-
next.add(id)
44-
}
81+
next.delete(tokenId)
4582
return next
4683
})
84+
isClaimingRef.current = false
4785
}
86+
},
87+
[address, isConnected, queryClient],
88+
)
89+
90+
const claimAll = useCallback(
91+
async () => {
92+
if (!address || !isConnected) return
93+
if (isClaimingRef.current) return
94+
95+
isClaimingRef.current = true
96+
setIsBulkPending(true)
4897

49-
const toastId = toast.loading(
50-
tokenIds.length === 1 ? "Claiming test token…" : "Claiming test tokens…",
51-
)
98+
const tokenIds = FAUCET_TOKENS.map((t) => t.contractId)
99+
const toastId = toast.loading("Claiming test tokens…")
52100

53101
try {
54102
const faucet = createFaucetClient(address)
@@ -84,21 +132,12 @@ export function useClaim() {
84132
: parseSorobanError(error)
85133
toast.error(message, { id: toastId })
86134
} finally {
87-
if (isBulk) {
88-
setIsBulkPending(false)
89-
} else {
90-
setPendingTokens((prev) => {
91-
const next = new Set(prev)
92-
for (const id of tokenIds) {
93-
next.delete(id)
94-
}
95-
return next
96-
})
97-
}
135+
setIsBulkPending(false)
136+
isClaimingRef.current = false
98137
}
99138
},
100139
[address, isConnected, queryClient],
101140
)
102141

103-
return { claim, pendingTokens, isBulkPending }
142+
return { claimOne, claimAll, pendingTokens, isBulkPending }
104143
}

apps/web/src/features/faucet/hooks/useFaucetData.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { useQuery } from "@tanstack/react-query"
2-
import { queryKeys } from "@/shared/lib/query-keys"
3-
import { FAUCET_TOKENS, type FaucetTokenSymbol } from "../data/tokens"
2+
import { FAUCET_TOKENS, type FaucetTokenSymbol } from "../data/tokens" // eslint-disable-line import/consistent-type-specifier-style
43
import {
54
createFaucetClient,
65
createTokenClient,
76
fromContractAmount,
87
} from "../lib/clients"
8+
import { queryKeys } from "@/shared/lib/query-keys"
99

1010
export type FaucetData = {
1111
balances: Record<FaucetTokenSymbol, number>
@@ -44,10 +44,10 @@ async function fetchFaucetData(address: string | null): Promise<FaucetData> {
4444
const lastClaimLedgers = {} as Record<FaucetTokenSymbol, number | null>
4545

4646
FAUCET_TOKENS.forEach((token, index) => {
47-
const balanceTx = balanceTxs[index]
48-
const lastClaimTx = lastClaimTxs[index]
49-
balances[token.symbol] = fromContractAmount((balanceTx?.result as bigint | undefined) ?? 0n)
50-
claimAmounts[token.symbol] = fromContractAmount(claimTxs[index]?.result as bigint)
47+
const balanceTx = balanceTxs.at(index)
48+
const lastClaimTx = lastClaimTxs.at(index)
49+
balances[token.symbol] = fromContractAmount(balanceTx?.result ?? 0n)
50+
claimAmounts[token.symbol] = fromContractAmount(claimTxs[index].result)
5151
lastClaimLedgers[token.symbol] = lastClaimTx ? Number(lastClaimTx.result) : null
5252
})
5353

apps/web/src/features/faucet/lib/clients.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { FaucetContractClient as FaucetClient } from "@workspace/contracts"
2-
import { TestTokenContractClient as TokenClient } from "@workspace/contracts"
1+
import { FaucetContractClient as FaucetClient, TestTokenContractClient as TokenClient } from "@workspace/contracts"
32
import { CONTRACTS } from "@/app/config/contracts"
43
import { NETWORK } from "@/app/config/network"
54

0 commit comments

Comments
 (0)