diff --git a/README.md b/README.md
index 974829e..301180d 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,43 @@ types/ Domain types mirroring the contracts
- Monetary valuations are stored on-chain as **USD cents** (`i128`); token
amounts are integers in each token's own `decimals` base.
+### Indexer API fast-path vs Soroban RPC fallback
+
+Several read hooks implement a **dual-path** data strategy to balance
+performance against self-sufficiency:
+
+```
+1. Try the indexer REST API (fast — pre-aggregated, no RPC round-trips)
+ │
+ └─ null (API not configured / request failed)
+ │
+ └─ Fall back to direct Soroban RPC reads
+ (always works, but may be slower or less complete)
+```
+
+**How the API path is activated:** Set `NEXT_PUBLIC_API_URL` in your env file.
+When the variable is absent or empty, `lib/api.ts` returns `null` for every
+call and every hook immediately executes the Soroban RPC path. The two paths
+are transparent to callers — both resolve to the same TypeScript types.
+
+**Which hooks use this pattern and what each path does:**
+
+| Hook | API fast-path | Soroban RPC fallback |
+|------|--------------|----------------------|
+| `useAssets` | `GET /assets` — full list in one request | `registry.get_all_assets` |
+| `useIssuerAssets` | `GET /assets?issuer=…` | `registry.get_assets_by_issuer` |
+| `usePlatformStats` | `GET /stats` — includes `totalHolders` | Parallel `get_all_assets` + `total_value_locked`; `totalHolders` is `null` (not derivable cheaply) |
+| `useHolders` | `GET /assets/{contract}/holders` | Read compliance allowlist → `balance` per address (O(n) RPC calls) |
+| `useHolderTotals` | `GET /stats` → `totalHolders` | Union all allowlists across deduplicated compliance contracts |
+
+**Hooks that are always on-chain only** (no API path exists for them):
+`useCompliance`, `useAllowlist`, `useComplianceOverview`, `useDividends`.
+
+**Debugging tip:** if data looks stale or inconsistent between page loads, first
+check which path is active. With `NEXT_PUBLIC_API_URL` set, add a `console.log`
+in `lib/api.ts` → `fetchJson`. Without it, every fetch falls through to Soroban
+RPC and freshness is bounded by block time (~5 s on Testnet).
+
## Pages
| Route | Status | Description |
diff --git a/components/compliance/ComplianceBadge.test.tsx b/components/compliance/ComplianceBadge.test.tsx
index b52ab20..0e6f299 100644
--- a/components/compliance/ComplianceBadge.test.tsx
+++ b/components/compliance/ComplianceBadge.test.tsx
@@ -12,4 +12,18 @@ describe("ComplianceBadge", () => {
expect(screen.getByRole("status", { name: accessibleName })).toBeInTheDocument();
});
+
+ it("renders a neutral fallback badge for an unrecognised status value", () => {
+ // AllowlistRow passes `record.status as never` to ComplianceBadge, which
+ // means an unexpected string from on-chain data can reach this component
+ // at runtime without TypeScript catching it. Casting to `never` here
+ // mirrors exactly how AllowlistRow calls the component in production.
+ render();
+
+ // The component must not crash and must fall back to the neutral
+ // "None" / "Not Registered" style rather than throwing on an undefined
+ // STYLES lookup.
+ const badge = screen.getByRole("status", { name: "Not Registered" });
+ expect(badge).toBeInTheDocument();
+ });
});
\ No newline at end of file
diff --git a/components/compliance/ComplianceBadge.tsx b/components/compliance/ComplianceBadge.tsx
index 831b3c6..515de9f 100644
--- a/components/compliance/ComplianceBadge.tsx
+++ b/components/compliance/ComplianceBadge.tsx
@@ -51,7 +51,11 @@ interface ComplianceBadgeProps {
/** Status pill for an address's KYC/compliance standing. */
export function ComplianceBadge({ status, labelOverride, className = "" }: ComplianceBadgeProps) {
- const s = STYLES[status];
+ // Guard against unexpected status strings coming from on-chain data that
+ // TypeScript can't validate at runtime (e.g. via the `as never` cast in
+ // AllowlistRow). Fall back to the neutral "None" style so the UI degrades
+ // gracefully instead of crashing on an undefined lookup.
+ const s = STYLES[status] ?? STYLES["None"];
const label = labelOverride ?? s.label;
return (
{
+ if (!since) return;
+ // Re-render every 10 s so the "X seconds ago" label stays accurate.
+ const id = setInterval(() => tick((n) => n + 1), 10_000);
+ return () => clearInterval(id);
+ }, [since]);
+
+ if (!since) return null;
+ const seconds = Math.round((Date.now() - since.getTime()) / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ return `${Math.floor(seconds / 60)}m ago`;
+}
+
function ExistingDistributionsCard({ tokenContract }: { tokenContract: string }) {
const { data, loading, error, refetch } = useDividends(tokenContract);
const distributions = data ?? [];
+ // Track when data was last successfully loaded so we can show a staleness
+ // indicator. Updated every time `data` transitions from null → value or
+ // on subsequent successful refreshes (i.e. whenever we get new data and
+ // loading has just finished).
+ const [lastRefreshed, setLastRefreshed] = useState(null);
+ const prevLoadingRef = useRef(loading);
+ useEffect(() => {
+ // loading just flipped from true → false and we have data: a fetch completed.
+ if (prevLoadingRef.current && !loading && data !== null) {
+ setLastRefreshed(new Date());
+ }
+ prevLoadingRef.current = loading;
+ }, [loading, data]);
+
+ // Auto-refresh every AUTO_REFRESH_INTERVAL_MS while the card is mounted.
+ // `refetch` is a stable callback (memoised inside useAsync) so this effect
+ // only re-runs if tokenContract changes.
+ const stableRefetch = useCallback(refetch, [refetch]);
+ useEffect(() => {
+ const id = setInterval(stableRefetch, AUTO_REFRESH_INTERVAL_MS);
+ return () => clearInterval(id);
+ }, [stableRefetch]);
+
+ const relativeTime = useRelativeTime(lastRefreshed);
+
+ // True when a background refresh is in-flight but we already have data to
+ // display — used to show a subtle spinner without hiding the list.
+ const backgroundRefreshing = loading && data !== null;
+
return (
}
>
- {loading ? (
+ {/* Initial load — no data yet */}
+ {loading && data === null ? (
Loading distributions…
- ) : error ? (
+ ) : error && data === null ? (
)}
+
+ {/* Staleness footer — only shown once we have data */}
+ {data !== null && (
+
+ )}
);
}
diff --git a/hooks/useAssets.ts b/hooks/useAssets.ts
index c142723..7a4bf82 100644
--- a/hooks/useAssets.ts
+++ b/hooks/useAssets.ts
@@ -7,6 +7,9 @@ import { useWallet } from "@/hooks/useWallet";
import { useAsync } from "@/hooks/useAsync";
import type { AssetEntry, Network } from "@/types";
+// Dual-path: tries the indexer REST API first (GET /assets), falls back to
+// Soroban RPC registry.get_all_assets when NEXT_PUBLIC_API_URL is not set or
+// the request fails. See README § "Indexer API fast-path vs Soroban RPC fallback".
async function loadAssets(network: Network, includeInactive?: boolean): Promise {
const fromApi = await api.getAllAssets();
if (fromApi) return includeInactive ? fromApi : fromApi.filter((a) => a.active);
@@ -32,6 +35,9 @@ export interface PlatformStatsData {
export function usePlatformStats() {
const { network } = useWallet();
+ // Dual-path: API fast-path returns totalHolders as part of /stats. The
+ // Soroban RPC fallback cannot cheaply derive a holder count, so
+ // totalHolders is null on that path — callers must handle both cases.
return useAsync(
async () => {
const fromApi = await api.getStats();
@@ -55,6 +61,8 @@ export function usePlatformStats() {
}
export function useIssuerAssets(issuer: string | null, network: Network) {
+ // Dual-path: API fast-path is GET /assets?issuer=…; fallback is
+ // registry.get_assets_by_issuer via Soroban RPC.
return useAsync(
async () => {
if (!issuer) return [];
diff --git a/hooks/useHolderTotals.ts b/hooks/useHolderTotals.ts
index e5e7d65..0db4a71 100644
--- a/hooks/useHolderTotals.ts
+++ b/hooks/useHolderTotals.ts
@@ -10,7 +10,12 @@ import type { AssetEntry } from "@/types";
* Count the distinct KYC-approved addresses across a set of assets. Assets can
* share a compliance contract, so we dedupe by compliance contract before
* unioning the allowlists. Returns 0 for an empty set.
- * When the API is configured, reads the pre-aggregated holder count instead.
+ *
+ * Dual-path: when the indexer API is configured (NEXT_PUBLIC_API_URL set), the
+ * pre-aggregated count is read from GET /stats → totalHolders in a single
+ * request. Without the API, the fallback unions the on-chain allowlists across
+ * all deduplicated compliance contracts to count unique addresses — multiple
+ * RPC calls. See README § "Indexer API fast-path vs Soroban RPC fallback".
*/
export function useHolderTotals(assets: AssetEntry[] | null) {
const { network } = useWallet();
diff --git a/hooks/useHolders.ts b/hooks/useHolders.ts
index 6dc8164..2ac483c 100644
--- a/hooks/useHolders.ts
+++ b/hooks/useHolders.ts
@@ -14,7 +14,13 @@ export interface Holder {
* Derive an asset's holders. The token contract doesn't enumerate holders, so
* we read the compliance allowlist (the only addresses that *can* hold it) and
* keep those with a positive balance, sorted by size.
- * When the API is configured, reads the pre-aggregated holder list instead.
+ *
+ * Dual-path: when the indexer API is configured (NEXT_PUBLIC_API_URL set), the
+ * pre-aggregated holder list is fetched from GET /assets/{contract}/holders in
+ * a single request. Without the API, the fallback reads the compliance
+ * allowlist on-chain and then issues one balance RPC call per address — O(n)
+ * in the number of KYC-approved addresses, which can be slow for large lists.
+ * See README § "Indexer API fast-path vs Soroban RPC fallback".
*/
/**
* @param refreshKey Bump this (e.g. after a confirmed transfer) to force a
diff --git a/package.json b/package.json
index 881adc5..43fa1ab 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,10 @@
"version": "0.1.0",
"private": true,
"description": "Tokenize real-world assets on Stellar \u2014 issue compliant asset tokens, manage KYC allowlists, and distribute dividends.",
+ "engines": {
+ "node": ">=20.0.0",
+ "npm": ">=10.0.0"
+ },
"scripts": {
"dev": "next dev",
"build": "next build",