@@ -325,6 +379,21 @@ export default function PlatformAdminPage() {
>
)}
+ )}
>
) : (
@@ -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;
}