diff --git a/apps/scan/app/[locale]/HomeContent.tsx b/apps/scan/app/[locale]/HomeContent.tsx
index fb955ec..a0f6cd4 100644
--- a/apps/scan/app/[locale]/HomeContent.tsx
+++ b/apps/scan/app/[locale]/HomeContent.tsx
@@ -8,6 +8,7 @@ import { Link } from "@/i18n/navigation";
import { Blocks, ArrowUpDown, Search, Clock, Loader2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { RailBadge, classifyRail, type Rail } from "@/components/common/RailBadge";
+import { FetchError } from "@/components/common/FetchError";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatCardSkeleton } from "@/components/common/skeletons";
import { Skeleton } from "@/components/ui/skeleton";
@@ -92,7 +93,7 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
const [railFilter, setRailFilter] = useState<"all" | Rail>("all");
const { data: stats, loading: statsLoading, error: statsError, refetch: refetchStats, retry: retryStats } = useStats(network, initial.stats);
const { data: blocks, loading: blocksLoading, refetch: refetchBlocks } = useBlocks(network, 10, initial.blocks);
- const { data: txs, loading: txsLoading, refetch: refetchTxs } = useTransactions(network, 10, initial.txs);
+ const { data: txs, loading: txsLoading, error: txsError, refetch: refetchTxs, retry: retryTxs } = useTransactions(network, 10, initial.txs);
// Live block height via WebSocket. newHeads (proposed) + sentrix_finalized
// (BFT-supermajority sealed) — both fed in so the UI can show the proposer
// tip + the canonical finality cursor as separate values. Each new head
@@ -526,6 +527,8 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
{Array.from({ length: 8 }).map((_, i) => )}
+ ) : txsError ? (
+
) : txs && txs.length > 0 ? (
(() => {
// Apply rail filter. classifyRail wants {to_address, data}, our
diff --git a/apps/scan/app/[locale]/address/[addr]/page.tsx b/apps/scan/app/[locale]/address/[addr]/page.tsx
index d1f231f..33e7d9a 100644
--- a/apps/scan/app/[locale]/address/[addr]/page.tsx
+++ b/apps/scan/app/[locale]/address/[addr]/page.tsx
@@ -13,6 +13,7 @@ import { Copyable } from "@/components/common/Copyable";
import { Pagination } from "@/components/common/Pagination";
import { PageHeader } from "@/components/common/PageHeader";
import { EmptyState } from "@/components/common/EmptyState";
+import { FetchError } from "@/components/common/FetchError";
import { StatusBadge } from "@/components/common/StatusBadge";
import { useNetwork, useNetworkFromQuery } from "@/lib/network-context";
import { useAddress, useAddressHistory, useAccountTokens, useEventLogs } from "@/lib/hooks";
@@ -92,8 +93,8 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr:
const [railFilter, setRailFilter] = useState("all");
const [activeTab, setActiveTab] = useState("history");
const { data: account, loading: accountLoading } = useAddress(network, addr);
- const { data: history, loading: historyLoading } = useAddressHistory(network, addr, page, HISTORY_PAGE_SIZE);
- const { data: tokens, loading: tokensLoading } = useAccountTokens(network, addr);
+ const { data: history, loading: historyLoading, error: historyError, retry: retryHistory } = useAddressHistory(network, addr, page, HISTORY_PAGE_SIZE);
+ const { data: tokens, loading: tokensLoading, error: tokensError, retry: retryTokens } = useAccountTokens(network, addr);
const { data: eventLogs, loading: eventLogsLoading } = useEventLogs(network, addr);
const label = useAddressLabel(addr);
@@ -293,6 +294,8 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr:
{Array.from({ length: 8 }).map((_, i) => )}
+ ) : historyError ? (
+
) : filtered.length > 0 ? (
<>
{/* Desktop table — single column hidden md+ on narrow viewports.
@@ -445,6 +448,8 @@ export default function AddressDetailPage({ params }: { params: Promise<{ addr:
{Array.from({ length: 4 }).map((_, i) => )}
+ ) : tokensError ? (
+
) : tokens && tokens.length > 0 ? (
diff --git a/apps/scan/app/[locale]/tokens/page.tsx b/apps/scan/app/[locale]/tokens/page.tsx
index b88dc68..6756372 100644
--- a/apps/scan/app/[locale]/tokens/page.tsx
+++ b/apps/scan/app/[locale]/tokens/page.tsx
@@ -11,6 +11,7 @@ import { Copyable } from "@/components/common/Copyable";
import { Pagination } from "@/components/common/Pagination";
import { PageHeader } from "@/components/common/PageHeader";
import { EmptyState } from "@/components/common/EmptyState";
+import { FetchError } from "@/components/common/FetchError";
import { useNetwork, useNetworkFromQuery } from "@/lib/network-context";
import { useTokens } from "@/lib/hooks";
import { formatNumber, shortenAddress } from "@/lib/format";
@@ -25,7 +26,7 @@ export default function TokensPage() {
// Deeplink network switch.
useNetworkFromQuery();
const searchParams = useSearchParams();
- const { data: tokens, loading } = useTokens(network);
+ const { data: tokens, loading, error, retry } = useTokens(network);
const [sortKey, setSortKey] = useState("none");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
// ?standard=tokenop|evm pre-selects the filter so the Native rail's
@@ -204,6 +205,8 @@ export default function TokensPage() {
{Array.from({ length: 8 }).map((_, i) => )}
+ ) : error ? (
+
) : paged.length > 0 ? (
<>
{/* Desktop table */}
diff --git a/apps/scan/components/common/FetchError.tsx b/apps/scan/components/common/FetchError.tsx
new file mode 100644
index 0000000..ee5a485
--- /dev/null
+++ b/apps/scan/components/common/FetchError.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { AlertTriangle, RotateCw } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { EmptyState } from "./EmptyState";
+
+// Shown when a list/table fetch fails with no data. Kept distinct from the
+// empty state so an outage doesn't read as "nothing here". Pass the hook's
+// retry to offer a one-click refetch.
+export function FetchError({ onRetry }: { onRetry?: () => void }) {
+ const tc = useTranslations("common");
+ return (
+
+
+ {tc("retry")}
+
+ ) : undefined
+ }
+ />
+ );
+}
diff --git a/apps/scan/lib/api.ts b/apps/scan/lib/api.ts
index b919e58..74ef171 100644
--- a/apps/scan/lib/api.ts
+++ b/apps/scan/lib/api.ts
@@ -480,7 +480,7 @@ export async function fetchLatestTransactions(network: NetworkId, count = 10) {
network,
`/transactions?limit=${count}`,
);
- if (!res) return [];
+ if (res === null) return null;
const rows = Array.isArray(res) ? res : (res.transactions ?? []);
return rows.map((t): TransactionData => ({
id: t.txid && !t.txid.startsWith("0x") ? `0x${t.txid}` : t.txid,
@@ -537,13 +537,14 @@ export async function fetchAccountHistory(
address: string,
page = 1,
limit = 20,
-): Promise {
+): Promise {
const offset = (page - 1) * limit;
const res = await apiFetch<{ transactions: RawHistoryItem[] }>(
network,
`/address/${normalizeAddress(address)}/history?limit=${limit}&offset=${offset}`,
);
- if (!res?.transactions) return [];
+ if (res === null) return null;
+ if (!res.transactions) return [];
return res.transactions.map((t) => ({
id: t.txid,
from: t.from,
@@ -679,6 +680,9 @@ export async function fetchTokens(network: NetworkId) {
fetchNativeTokens(network),
fetchEvmTokensFromFactory(network),
]);
+ // Native is the primary token source; if it failed, surface the error
+ // rather than showing an EVM-only (or empty) list as if it were complete.
+ if (native === null) return null;
// Dedupe by contract_address (lowercase). Native takes precedence
// for any collision because it's the chain's own ledger; EVM events
// could theoretically be replayed on a fork, so we trust native more.
@@ -697,9 +701,9 @@ export async function fetchTokens(network: NetworkId) {
// pages, which is where users actually need them as labels.
export const fetchTokensForLabels = fetchNativeTokens;
-async function fetchNativeTokens(network: NetworkId): Promise {
+async function fetchNativeTokens(network: NetworkId): Promise {
const res = await apiFetch<{ tokens: TokenData[] } | TokenData[]>(network, "/tokens");
- if (!res) return [];
+ if (res === null) return null;
const list = Array.isArray(res) ? res : (res.tokens ?? []);
return list.map((t) => ({ ...t, standard: "tokenop" as const }));
}
@@ -1096,9 +1100,10 @@ interface RawAccountTokenHolding {
balance_raw?: number;
}
-export async function fetchAccountTokens(network: NetworkId, address: string): Promise {
+export async function fetchAccountTokens(network: NetworkId, address: string): Promise {
const res = await apiFetch<{ tokens: RawAccountTokenHolding[] }>(network, `/accounts/${normalizeAddress(address)}/tokens`);
- if (!res?.tokens) return [];
+ if (res === null) return null;
+ if (!res.tokens) return [];
return res.tokens.map((t) => ({
contract_address: t.contract_address ?? t.contract ?? "",
symbol: t.symbol ?? "",