diff --git a/src/app/(main)/dashboard/page.test.tsx b/src/app/(main)/dashboard/page.test.tsx index 1ed8f22..770c069 100644 --- a/src/app/(main)/dashboard/page.test.tsx +++ b/src/app/(main)/dashboard/page.test.tsx @@ -33,6 +33,11 @@ vi.mock('@/components/toast/ToastProvider', () => ({ useToast: () => ({ showToast: vi.fn() }), })); +vi.mock('@/lib/stellar', () => ({ + getNativeAssetAddress: () => 'CNATIVE', + getUsdcAssetAddress: () => 'CUSDC', +})); + vi.mock('@/components/wallet/WalletProvider', () => ({ useWallet: vi.fn(), @@ -437,6 +442,52 @@ describe('DashboardPage', () => { }); }); + describe('multi-token totals', () => { + it('shows each token committed separately and never sums them into one number', async () => { + vi.spyOn(WalletProviderMod, 'useWallet').mockReturnValue({ + address: 'G123', + connecting: false, + connect: vi.fn(), + disconnect: vi.fn(), + signTransaction: vi.fn(), + signMessage: vi.fn(), + }); + + // One XLM stream: balance 3, withdrawn 1 → 4 XLM committed. + // One USDC stream: balance 2, withdrawn 8 → 10 USDC committed. + // The two totals must appear separately, never added together. + vi.spyOn(ApiMod, 'getStreams').mockResolvedValue([ + makeStream({ + id: 's1', + tokenAddress: 'CNATIVE', + balance: '30000000', + withdrawn: '10000000', + status: 'ACTIVE', + ngo: { id: 'ngo-1', name: 'XLM NGO', ownerAddress: 'G1' }, + }), + makeStream({ + id: 's2', + tokenAddress: 'CUSDC', + balance: '20000000', + withdrawn: '80000000', + status: 'ACTIVE', + ngo: { id: 'ngo-2', name: 'USDC NGO', ownerAddress: 'G2' }, + }), + ]); + + render(); + + await waitFor(() => expect(screen.getByText('Total committed')).toBeInTheDocument()); + + const committedCell = screen.getByText('Total committed').closest('div')!; + // Each token labelled separately. + expect(committedCell).toHaveTextContent('4 XLM'); + expect(committedCell).toHaveTextContent('10 USDC'); + // Must NOT show a bare cross-token sum like "14". + expect(committedCell).not.toHaveTextContent('14'); + }); + }); + describe('background polling', () => { it('re-fetches streams every 30 seconds while the tab is visible', async () => { vi.useFakeTimers(); diff --git a/src/app/(main)/ngo-admin/page.test.tsx b/src/app/(main)/ngo-admin/page.test.tsx index 7ca66d7..ea100b2 100644 --- a/src/app/(main)/ngo-admin/page.test.tsx +++ b/src/app/(main)/ngo-admin/page.test.tsx @@ -148,5 +148,40 @@ describe('NgoAdminPage states', () => { expect(streamList).toBeInTheDocument(); expect(streamList).toHaveTextContent('Streams count: 2'); }); + + it('groups totals by token when streams use two different tokens', async () => { + const streams = [ + makeStream({ + id: 's1', + tokenAddress: 'CNATIVE', + balance: '30000000', // 3 XLM + withdrawn: '10000000', // 1 XLM + status: 'ACTIVE', + }), + makeStream({ + id: 's2', + tokenAddress: 'CUSDC', + balance: '20000000', // 2 USDC + withdrawn: '50000000', // 5 USDC + status: 'ACTIVE', + }), + ]; + getStreams.mockResolvedValue(streams); + + render(); + + await screen.findByText('Managing streams for Test NGO.'); + + // Total withdrawn must show both tokens separately, never summed together. + // 1 XLM withdrawn + 5 USDC withdrawn → two labelled figures. + const withdrawnCell = screen.getByText('Total withdrawn').closest('div')!; + expect(withdrawnCell).toHaveTextContent('1 XLM'); + expect(withdrawnCell).toHaveTextContent('5 USDC'); + + // Remaining balance (both ACTIVE): 3 XLM + 2 USDC. + const balanceCell = screen.getByText('Remaining balance').closest('div')!; + expect(balanceCell).toHaveTextContent('3 XLM'); + expect(balanceCell).toHaveTextContent('2 USDC'); + }); }); diff --git a/src/app/(main)/platform-admin/page.test.tsx b/src/app/(main)/platform-admin/page.test.tsx index c25eee3..3272680 100644 --- a/src/app/(main)/platform-admin/page.test.tsx +++ b/src/app/(main)/platform-admin/page.test.tsx @@ -64,6 +64,8 @@ vi.mock('@/lib/ngoRegistryClient', () => ({ revoke_ngo: revokeNgoMock, }, ready: true, + initError: null, + retryInit: vi.fn(), }), })); @@ -74,12 +76,12 @@ describe('PlatformAdminPage', () => { beforeEach(() => { vi.clearAllMocks(); getNgos.mockResolvedValue([]); - listNgoApplications.mockResolvedValue([]); + listNgoApplications.mockResolvedValue({ applications: [], total: 0, limit: 100, offset: 0 }); }); describe('approve flow', () => { it('approves an application on-chain and off-chain, shows success toast, and refreshes', async () => { - listNgoApplications.mockResolvedValueOnce([APPLICATION]).mockResolvedValueOnce([]); + listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }).mockResolvedValueOnce({ applications: [], total: 0, limit: 100, offset: 0 }); const signAndSend = vi.fn().mockResolvedValue({}); approveNgoMock.mockResolvedValue({ signAndSend, built: { fee: '100' } }); reviewNgoApplication.mockResolvedValue({}); @@ -104,7 +106,7 @@ describe('PlatformAdminPage', () => { }); it('shows error toast when on-chain approval fails and skips off-chain review', async () => { - listNgoApplications.mockResolvedValueOnce([APPLICATION]); + listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }); approveNgoMock.mockRejectedValue(new Error('On-chain approval rejected')); const user = userEvent.setup(); @@ -120,7 +122,7 @@ describe('PlatformAdminPage', () => { describe('reject flow', () => { it('asks for confirmation, then calls reviewNgoApplication with reject, shows toast and refreshes', async () => { - listNgoApplications.mockResolvedValueOnce([APPLICATION]).mockResolvedValueOnce([]); + listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }).mockResolvedValueOnce({ applications: [], total: 0, limit: 100, offset: 0 }); const user = userEvent.setup(); render(); @@ -143,7 +145,7 @@ describe('PlatformAdminPage', () => { }); it('cancels rejection when clicking Never mind', async () => { - listNgoApplications.mockResolvedValueOnce([APPLICATION]); + listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }); const user = userEvent.setup(); render(); @@ -158,7 +160,7 @@ describe('PlatformAdminPage', () => { }); it('shows error toast when reject review fails', async () => { - listNgoApplications.mockResolvedValueOnce([APPLICATION]); + listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }); reviewNgoApplication.mockRejectedValue(new Error('API reject failed')); const user = userEvent.setup(); diff --git a/src/app/(main)/platform-admin/page.tsx b/src/app/(main)/platform-admin/page.tsx index ac4baa4..7847e7a 100644 --- a/src/app/(main)/platform-admin/page.tsx +++ b/src/app/(main)/platform-admin/page.tsx @@ -16,13 +16,15 @@ import { useLatestRequest } from '@/lib/useLatestRequest'; export default function PlatformAdminPage() { const { address, signMessage } = useWallet(); - const { client, ready } = useNgoRegistryClient(); + const { client, ready, initError, retryInit } = useNgoRegistryClient(); const { showToast } = useToast(); const [applications, setApplications] = useState([]); + const [totalApplications, setTotalApplications] = useState(0); const [verifiedNgos, setVerifiedNgos] = useState([]); // Starts true so the "no applications" state isn't painted before the // first fetch begins. const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); const [confirmingRejectId, setConfirmingRejectId] = useState(null); @@ -52,12 +54,13 @@ export default function PlatformAdminPage() { setLoading(true); setError(null); try { - const [apps, ngos] = await Promise.all([ + const [page, ngos] = await Promise.all([ listNgoApplications(address, signMessage, statusFilter), getNgos(request.signal), ]); if (!request.isCurrent()) return; - setApplications(apps); + setApplications(page.applications); + setTotalApplications(page.total); setVerifiedNgos(ngos.filter(n => n.verified)); } catch (err) { if (!request.isCurrent()) return; @@ -81,6 +84,23 @@ export default function PlatformAdminPage() { void refresh(); }, [refresh]); + async function handleLoadMore(): Promise { + if (!address) return; + setLoadingMore(true); + try { + const page = await listNgoApplications(address, signMessage, statusFilter, applications.length); + // Append — don't replace — so rows already visible stay put. + setApplications((prev) => [...prev, ...page.applications]); + setTotalApplications(page.total); + } catch (err) { + showToast('error', err instanceof Error ? err.message : 'Failed to load more applications.'); + } finally { + setLoadingMore(false); + } + } + + const [retryApprovingId, setRetryApprovingId] = useState(null); + // Builds the (unsigned) approve tx and shows its fee estimate so the // admin can confirm before anything is signed. Building just simulates // the call — it doesn't touch the wallet or submit anything. @@ -106,21 +126,38 @@ export default function PlatformAdminPage() { } async function handleConfirmApprove(app: NgoApplication): Promise { - if (!address || !pendingApproveTx) return; + const isRetry = retryApprovingId === app.id; + if (!address || (!isRetry && !pendingApproveTx)) return; setBusyId(app.id); + let onChainDone = isRetry; try { // On-chain first: this is what actually flips Ngo.verified once the // indexer picks up the resulting event. If the wallet rejects or the // tx fails, we deliberately haven't touched the off-chain review // status yet — better an application stuck "pending" than one // marked "approved" while the NGO is still unverified on-chain. - await pendingApproveTx.signAndSend(); + if (!isRetry) { + await pendingApproveTx!.signAndSend(); + onChainDone = true; + } + // Now mark off-chain. If this fails, the NGO is already verified on-chain + // but the app is still PENDING — surface a targeted retry action. await reviewNgoApplication(address, signMessage, app.id, 'approve', reviewNotes[app.id]); showToast('success', `${app.name} approved.`); + setRetryApprovingId(null); await refresh(); } catch (err) { - showToast('error', err instanceof Error ? err.message : 'Something went wrong.'); + const message = err instanceof Error ? err.message : 'Something went wrong.'; + // Once the on-chain approval has gone through, any failure is in the + // off-chain marking — show the retry-marking-approved action. + if (onChainDone) { + showToast('error', `Couldn't mark as approved: ${message}`); + setRetryApprovingId(app.id); + } else { + showToast('error', message); + setRetryApprovingId(null); + } } finally { setBusyId(null); setConfirmingApproveId(null); @@ -129,6 +166,10 @@ export default function PlatformAdminPage() { } } + function handleRetryMarkApproved(app: NgoApplication): void { + void handleConfirmApprove(app); + } + async function handleReject(app: NgoApplication): Promise { if (!address) return; setBusyId(app.id); @@ -173,6 +214,19 @@ export default function PlatformAdminPage() { )} + {address && initError && ( +
+

{initError}

+ +
+ )} + {address && (
@@ -325,6 +379,21 @@ export default function PlatformAdminPage() { )}
+ {retryApprovingId === app.id && ( +
+ + NGO verified on-chain but couldn't mark approved in the system. + + +
+ )} ) : (
@@ -343,6 +412,21 @@ export default function PlatformAdminPage() { )} + {address && !loading && applications.length > 0 && applications.length < totalApplications && ( +
+ +
+ )} + {address && !loading && !error && (

Verified NGOs

diff --git a/src/lib/adminApi.test.ts b/src/lib/adminApi.test.ts index b8aedcc..d600797 100644 --- a/src/lib/adminApi.test.ts +++ b/src/lib/adminApi.test.ts @@ -148,7 +148,50 @@ describe('listNgoApplications', () => { ); const result = await listNgoApplications(ADDRESS, signMessage); - expect(result).toEqual([application]); + expect(result.applications).toEqual([application]); + expect(result.total).toBe(1); + expect(result.limit).toBe(100); + expect(result.offset).toBe(0); + }); + + it('passes offset as a query param when provided', async () => { + const { listNgoApplications } = await freshAdminApi(); + const signMessage = vi.fn().mockResolvedValue('sig'); + const fetchMock = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ applications: [], total: 50, limit: 20, offset: 20 }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await listNgoApplications(ADDRESS, signMessage, 'PENDING', 20); + + const calledUrl = (fetchMock.mock.calls[0][0] as string); + expect(calledUrl).toContain('offset=20'); + expect(result.total).toBe(50); + expect(result.offset).toBe(20); + }); + + it('omits offset query param when offset is 0', async () => { + const { listNgoApplications } = await freshAdminApi(); + const signMessage = vi.fn().mockResolvedValue('sig'); + const fetchMock = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ applications: [], total: 0, limit: 100, offset: 0 }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listNgoApplications(ADDRESS, signMessage, 'PENDING', 0); + + const calledUrl = (fetchMock.mock.calls[0][0] as string); + expect(calledUrl).not.toContain('offset='); }); it('includes the status query param when provided', async () => { diff --git a/src/lib/adminApi.ts b/src/lib/adminApi.ts index a227840..1b39eb6 100644 --- a/src/lib/adminApi.ts +++ b/src/lib/adminApi.ts @@ -3,6 +3,10 @@ import type { WalletSignMessage } from '@/components/wallet/WalletProvider'; import type { NgoApplication } from './api'; import { API_URL } from './config'; +/** Same budget as the public API layer — long enough for a slow node, short + * enough that the admin page doesn't hang forever on a dead endpoint. */ +const ADMIN_TIMEOUT_MS = 10_000; + /** The backend accepts a signature within MAX_CLOCK_SKEW_MS (5 minutes) of * its timestamp, so one we just made stays valid for that long. Reused up * to a minute short of the limit, to leave room for clock drift between @@ -62,6 +66,7 @@ async function adminFetch( 'x-admin-timestamp': cached.timestamp, }, body: body !== undefined ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(ADMIN_TIMEOUT_MS), }); const { cached, wasReused } = await getSignature(cacheKey, method, path, signMessage); @@ -83,7 +88,7 @@ async function adminFetch( /** The paginated envelope GET /ngo-applications responds with. The other * list endpoints return bare arrays; this one does not. */ -type NgoApplicationPage = { +export type NgoApplicationPage = { applications: NgoApplication[]; total: number; limit: number; @@ -94,8 +99,14 @@ export async function listNgoApplications( address: string, signMessage: WalletSignMessage, status?: 'PENDING' | 'APPROVED' | 'REJECTED', -): Promise { - const path = status ? `/ngo-applications?status=${status}` : '/ngo-applications'; + offset = 0, +): Promise { + const params = new URLSearchParams(); + if (status) params.set('status', status); + if (offset > 0) params.set('offset', String(offset)); + const query = params.toString(); + const path = `/ngo-applications${query ? `?${query}` : ''}`; + const res = await adminFetch('GET', path, address, signMessage); if (!res.ok) { throw new Error(`Failed to fetch applications: ${res.status}`); @@ -114,7 +125,7 @@ export async function listNgoApplications( ); } - return body.applications; + return body; } export async function reviewNgoApplication( diff --git a/src/lib/donationVaultClient.ts b/src/lib/donationVaultClient.ts index 3eb4bc6..ab92f61 100644 --- a/src/lib/donationVaultClient.ts +++ b/src/lib/donationVaultClient.ts @@ -1,7 +1,7 @@ 'use client'; import { Client } from '@stellar/stellar-sdk/contract'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useToast } from '@/components/toast/ToastProvider'; import { useWallet, type WalletSignTransaction } from '@/components/wallet/WalletProvider'; @@ -11,6 +11,10 @@ import { DONATION_VAULT_CONTRACT_ID, NETWORK_PASSPHRASE, SOROBAN_RPC_URL } from type DonationVaultClient = Client & DonationVaultMethods; +/** How long to wait for the Soroban RPC WASM spec fetch inside Client.from() + * before giving up and surfacing a retry state to the UI. */ +const CLIENT_INIT_TIMEOUT_MS = 15_000; + /** * Builds a fresh client, with no caching at this layer — getSharedDonationVaultClient * below is what caches the resulting promise, for the UI's benefit. @@ -23,13 +27,24 @@ export async function getDonationVaultClient( throw new Error('NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID is not set'); } - const client = await Client.from({ - contractId: DONATION_VAULT_CONTRACT_ID, - networkPassphrase: NETWORK_PASSPHRASE, - rpcUrl: SOROBAN_RPC_URL, - publicKey, - signTransaction, - }); + // Client.from() fetches the contract's WASM spec from the RPC node and has + // no built-in timeout. Race it so a slow or unresponsive node doesn't leave + // the UI stuck on "Preparing contract…" forever. + const timeoutSignal = AbortSignal.timeout(CLIENT_INIT_TIMEOUT_MS); + const client = await Promise.race([ + Client.from({ + contractId: DONATION_VAULT_CONTRACT_ID, + networkPassphrase: NETWORK_PASSPHRASE, + rpcUrl: SOROBAN_RPC_URL, + publicKey, + signTransaction, + }), + new Promise((_, reject) => { + timeoutSignal.addEventListener('abort', () => + reject(new DOMException('Timed out reaching the donation contract — check your RPC endpoint.', 'TimeoutError')), + ); + }), + ]); return client as DonationVaultClient; } @@ -74,17 +89,37 @@ function getSharedDonationVaultClient( * * `ready` is what callers should disable their contract-calling buttons on; * `client` is null until then and while the wallet is disconnected. + * `initError` is set when Client.from() fails or times out — callers can + * show a retry button that calls `retryInit` to attempt again. */ -export function useDonationVaultClient(): { client: DonationVaultClient | null; ready: boolean } { +export function useDonationVaultClient(): { + client: DonationVaultClient | null; + ready: boolean; + initError: string | null; + retryInit: () => void; +} { const { address, signTransaction } = useWallet(); const { showToast } = useToast(); const [client, setClient] = useState(null); + const [initError, setInitError] = useState(null); + // Bumping this counter triggers a retry attempt from the effect below. + const [retryCount, setRetryCount] = useState(0); + + const retryInit = useCallback(() => { + // Clear the rejected promise from the cache so the next attempt gets a + // fresh Client.from() rather than replaying the same failure. + cached = null; + hasToastedInitError = false; + setInitError(null); + setRetryCount((c) => c + 1); + }, []); useEffect(() => { if (!address) { // A disconnected wallet must not retain a usable signing client. // eslint-disable-next-line react-hooks/set-state-in-effect setClient(null); + setInitError(null); return; } @@ -92,28 +127,37 @@ export function useDonationVaultClient(): { client: DonationVaultClient | null; // Invalidate the previous signer before fetching the new wallet's client. // eslint-disable-next-line react-hooks/set-state-in-effect setClient(null); + setInitError(null); getSharedDonationVaultClient(address, signTransaction) .then((c) => { if (!cancelled) { setClient(c); + setInitError(null); } }) .catch((err) => { - if (!cancelled && !hasToastedInitError) { - hasToastedInitError = true; + if (!cancelled) { // The error can be a developer-facing message (e.g. a missing // NEXT_PUBLIC_*_CONTRACT_ID env var) that a donor can't act on — // logged for whoever's debugging the deployment, not shown. console.error('Failed to initialize the donation vault client:', err); - showToast('error', "Couldn't reach the donation contract. Please try again shortly."); + const message = "Couldn't reach the donation contract. Please try again shortly."; + setInitError(message); + if (!hasToastedInitError) { + hasToastedInitError = true; + showToast('error', message); + } } }); return () => { cancelled = true; }; - }, [address, signTransaction, showToast]); + // retryCount is intentionally included: incrementing it re-runs the effect + // to retry the Client.from() call after a failure or timeout. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, signTransaction, showToast, retryCount]); - return { client, ready: client !== null }; + return { client, ready: client !== null, initError, retryInit }; } diff --git a/src/lib/ngoRegistryClient.ts b/src/lib/ngoRegistryClient.ts index 4518931..d0a5fec 100644 --- a/src/lib/ngoRegistryClient.ts +++ b/src/lib/ngoRegistryClient.ts @@ -1,7 +1,7 @@ 'use client'; import { Client } from '@stellar/stellar-sdk/contract'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useToast } from '@/components/toast/ToastProvider'; import { useWallet, type WalletSignTransaction } from '@/components/wallet/WalletProvider'; @@ -11,6 +11,10 @@ import { NETWORK_PASSPHRASE, NGO_REGISTRY_CONTRACT_ID, SOROBAN_RPC_URL } from '. type NgoRegistryClient = Client & NgoRegistryMethods; +/** How long to wait for the Soroban RPC WASM spec fetch inside Client.from() + * before giving up and surfacing a retry state to the UI. */ +const CLIENT_INIT_TIMEOUT_MS = 15_000; + export async function getNgoRegistryClient( publicKey: string, signTransaction: WalletSignTransaction, @@ -19,29 +23,55 @@ export async function getNgoRegistryClient( throw new Error('NEXT_PUBLIC_NGO_REGISTRY_CONTRACT_ID is not set'); } - const client = await Client.from({ - contractId: NGO_REGISTRY_CONTRACT_ID, - networkPassphrase: NETWORK_PASSPHRASE, - rpcUrl: SOROBAN_RPC_URL, - publicKey, - signTransaction, - }); + // Client.from() fetches the contract's WASM spec from the RPC node and has + // no built-in timeout. Race it so a slow or unresponsive node doesn't leave + // the UI stuck on "Preparing contract…" forever. + const timeoutSignal = AbortSignal.timeout(CLIENT_INIT_TIMEOUT_MS); + const client = await Promise.race([ + Client.from({ + contractId: NGO_REGISTRY_CONTRACT_ID, + networkPassphrase: NETWORK_PASSPHRASE, + rpcUrl: SOROBAN_RPC_URL, + publicKey, + signTransaction, + }), + new Promise((_, reject) => { + timeoutSignal.addEventListener('abort', () => + reject(new DOMException('Timed out reaching the NGO registry contract — check your RPC endpoint.', 'TimeoutError')), + ); + }), + ]); return client as NgoRegistryClient; } /** See useDonationVaultClient's comment — same reasoning, for the NGO - * registry contract. */ -export function useNgoRegistryClient(): { client: NgoRegistryClient | null; ready: boolean } { + * registry contract. Exposes `initError` and `retryInit` so callers can + * show a retry button instead of being stuck when Client.from() times out. */ +export function useNgoRegistryClient(): { + client: NgoRegistryClient | null; + ready: boolean; + initError: string | null; + retryInit: () => void; +} { const { address, signTransaction } = useWallet(); const { showToast } = useToast(); const [client, setClient] = useState(null); + const [initError, setInitError] = useState(null); + // Bumping this counter triggers a retry attempt from the effect below. + const [retryCount, setRetryCount] = useState(0); + + const retryInit = useCallback(() => { + setInitError(null); + setRetryCount((c) => c + 1); + }, []); useEffect(() => { if (!address) { // A disconnected wallet must not retain a usable signing client. // eslint-disable-next-line react-hooks/set-state-in-effect setClient(null); + setInitError(null); return; } @@ -49,11 +79,13 @@ export function useNgoRegistryClient(): { client: NgoRegistryClient | null; read // Invalidate the previous signer before fetching the new wallet's client. // eslint-disable-next-line react-hooks/set-state-in-effect setClient(null); + setInitError(null); getNgoRegistryClient(address, signTransaction) .then((c) => { if (!cancelled) { setClient(c); + setInitError(null); } }) .catch((err) => { @@ -62,14 +94,19 @@ export function useNgoRegistryClient(): { client: NgoRegistryClient | null; read // NEXT_PUBLIC_*_CONTRACT_ID env var) that a visitor can't act on — // logged for whoever's debugging the deployment, not shown. console.error('Failed to initialize the NGO registry client:', err); - showToast('error', "Couldn't reach the NGO registry contract. Please try again shortly."); + const message = "Couldn't reach the NGO registry contract. Please try again shortly."; + setInitError(message); + showToast('error', message); } }); return () => { cancelled = true; }; - }, [address, signTransaction, showToast]); + // retryCount is intentionally included: incrementing it re-runs the effect + // to retry the Client.from() call after a failure or timeout. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, signTransaction, showToast, retryCount]); - return { client, ready: client !== null }; + return { client, ready: client !== null, initError, retryInit }; } diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index a8ee8d8..3a48703 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -85,6 +85,10 @@ export const NATIVE_FEE_RESERVE = NATIVE_ACCOUNT_RESERVE + NATIVE_FEE_ESTIMATE; const SIMULATION_FEE = '100'; const SIMULATION_SOURCE_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +/** How long to wait for a Soroban RPC `simulateTransaction` before giving up. + * The SDK doesn't expose a signal parameter, so we race the promise ourselves. */ +const RPC_TIMEOUT_MS = 10_000; + /** * Reads a SEP-41 token contract's `balance(id)` for `walletAddress`, via a * simulated (never submitted, never signed) contract call. @@ -123,7 +127,18 @@ export async function getTokenBalance( .setTimeout(30) .build(); - const simulated = await server.simulateTransaction(tx); + // Race the RPC call against a timeout — the SDK doesn't accept an + // AbortSignal directly, so we wire one up ourselves. The promise still + // settles in the background, but we stop waiting for it. + const timeoutSignal = AbortSignal.timeout(RPC_TIMEOUT_MS); + const simulated = await Promise.race([ + server.simulateTransaction(tx), + new Promise((_, reject) => { + timeoutSignal.addEventListener('abort', () => + reject(new DOMException('RPC balance check timed out', 'TimeoutError')), + ); + }), + ]); if (rpc.Api.isSimulationError(simulated)) { throw new Error(`Failed to simulate balance() for ${tokenAddress}: ${simulated.error}`); } @@ -161,7 +176,17 @@ export async function getTokenDecimals(tokenAddress: string): Promise((_, reject) => { + timeoutSignal.addEventListener('abort', () => + reject(new DOMException('RPC decimals check timed out', 'TimeoutError')), + ); + }), + ]); + if (rpc.Api.isSimulationError(simulated) || !simulated.result) { return null; }