From aa6c92064560c6c68cc5a6865e1c77b67ea6570d Mon Sep 17 00:00:00 2001 From: ofodumchristopher32 Date: Mon, 31 Aug 2026 06:02:39 +1000 Subject: [PATCH] fix: ComplianceBadge fallback, distribution auto-refresh, dual-path docs, engines field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(compliance): guard STYLES lookup with ?? STYLES['None'] so unknown runtime status strings (e.g. via the as-never cast in AllowlistRow) degrade to a neutral badge instead of crashing. Closes #315 - test(compliance): add test asserting ComplianceBadge renders the neutral 'Not Registered' fallback for a status value outside the known union. Closes #316 - feat(distribution): add 30-second auto-refresh and staleness indicator to ExistingDistributionsCard. Background refreshes show a spinner; a footer displays 'Updated Xs ago · auto-refreshes every 30 s' and a manual Refresh button so issuers always know whether the claimed percentage is current. Closes #318 - docs: document the indexer API fast-path vs Soroban RPC fallback strategy in README (new subsection with decision tree, per-hook table, and debugging tip) and add inline JSDoc to useAssets, useHolders, useHolderTotals. Closes #319 - chore: add engines field to package.json (node >=20.0.0, npm >=10.0.0) matching the Node 20 pin in CI so npm warns locally on unsupported toolchains --- README.md | 37 ++++++++ .../compliance/ComplianceBadge.test.tsx | 14 +++ components/compliance/ComplianceBadge.tsx | 6 +- .../issuer/panels/DistributionPanel.tsx | 92 ++++++++++++++++++- hooks/useAssets.ts | 8 ++ hooks/useHolderTotals.ts | 7 +- hooks/useHolders.ts | 8 +- package.json | 4 + 8 files changed, 170 insertions(+), 6 deletions(-) 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 && ( +
+

+ {backgroundRefreshing ? ( + <> + + Refreshing… + + ) : ( + <> + {/* Simple clock icon */} + + + {relativeTime ? `Updated ${relativeTime}` : "Up to date"} · auto-refreshes every 30 s + + + )} +

+ +
+ )}
); } 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",