Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 14 additions & 0 deletions components/compliance/ComplianceBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ComplianceBadge status={"UnknownStatus" as never} />);

// 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();
});
});
6 changes: 5 additions & 1 deletion components/compliance/ComplianceBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span
Expand Down
92 changes: 89 additions & 3 deletions components/issuer/panels/DistributionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { StrKey } from "@stellar/stellar-sdk";
import type { AssetDetail } from "@/types";
import { dividend } from "@/lib/contracts";
Expand Down Expand Up @@ -149,10 +149,62 @@ function CreateDistributionCard({

// ---- Existing distributions ----

/** How often (ms) to automatically re-fetch distribution data. */
const AUTO_REFRESH_INTERVAL_MS = 30_000;

/**
* Returns a human-readable "X seconds ago / X minutes ago" string relative to
* `since`, or null when `since` is null.
*/
function useRelativeTime(since: Date | null): string | null {
const [, tick] = useState(0);

useEffect(() => {
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<Date | null>(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 (
<ActionCard
title="Distribution history"
Expand All @@ -165,11 +217,12 @@ function ExistingDistributionsCard({ tokenContract }: { tokenContract: string })
</svg>
}
>
{loading ? (
{/* Initial load — no data yet */}
{loading && data === null ? (
<div className="flex items-center gap-2 py-4 text-sm text-base-100/40">
<Spinner size={14} /> Loading distributions…
</div>
) : error ? (
) : error && data === null ? (
<ErrorState
title="Couldn't load distributions"
message={error}
Expand Down Expand Up @@ -222,6 +275,39 @@ function ExistingDistributionsCard({ tokenContract }: { tokenContract: string })
})}
</ul>
)}

{/* Staleness footer — only shown once we have data */}
{data !== null && (
<div className="mt-3 flex items-center justify-between border-t border-white/5 pt-2">
<p className="flex items-center gap-1.5 text-[11px] text-base-100/35">
{backgroundRefreshing ? (
<>
<Spinner size={10} />
<span>Refreshing…</span>
</>
) : (
<>
{/* Simple clock icon */}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>
{relativeTime ? `Updated ${relativeTime}` : "Up to date"} · auto-refreshes every 30 s
</span>
</>
)}
</p>
<button
onClick={refetch}
disabled={loading}
aria-label="Refresh distribution data"
className="btn-ghost py-0.5 px-1.5 text-[11px] text-base-100/40 hover:text-base-100/70 disabled:opacity-40"
>
Refresh
</button>
</div>
)}
</ActionCard>
);
}
8 changes: 8 additions & 0 deletions hooks/useAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssetEntry[]> {
const fromApi = await api.getAllAssets();
if (fromApi) return includeInactive ? fromApi : fromApi.filter((a) => a.active);
Expand All @@ -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<PlatformStatsData>(
async () => {
const fromApi = await api.getStats();
Expand All @@ -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<AssetEntry[]>(
async () => {
if (!issuer) return [];
Expand Down
7 changes: 6 additions & 1 deletion hooks/useHolderTotals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion hooks/useHolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down