Skip to content

Commit f8badc3

Browse files
authored
Merge pull request #27 from Silk-Nodes/claude/unruffled-ride
Harden data fetching: timeouts, fallbacks, error handling
2 parents 398e14b + 8e23731 commit f8badc3

6 files changed

Lines changed: 131 additions & 83 deletions

File tree

src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default function RootLayout({
7979
{/* <meta name="msvalidate.01" content="YOUR_BING_CODE" /> */}
8080
<meta
8181
httpEquiv="Content-Security-Policy"
82-
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://*.clarity.ms; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://restake.app; connect-src 'self' https://api.coingecko.com https://rest-coreum.ecostake.com https://rpc-coreum.ecostake.com wss://rpc-coreum.ecostake.com https://hasura.mainnet-1.coreum.dev https://api.web3forms.com https://www.google-analytics.com https://analytics.google.com https://www.googletagmanager.com https://*.clarity.ms; object-src 'none'; base-uri 'self';"
82+
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com https://*.clarity.ms; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://restake.app; connect-src 'self' https://api.coingecko.com https://rest-coreum.ecostake.com https://rpc-coreum.ecostake.com wss://rpc-coreum.ecostake.com https://full-node.mainnet-1.coreum.dev:1317 https://hasura.mainnet-1.coreum.dev https://api.web3forms.com https://www.google-analytics.com https://analytics.google.com https://www.googletagmanager.com https://*.clarity.ms; object-src 'none'; base-uri 'self';"
8383
/>
8484
<link rel="icon" href={`${basePath}/tx-icon.png`} type="image/png" />
8585
<link rel="apple-touch-icon" href={`${basePath}/tx-icon.png`} />

src/components/ValidatorList.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
const BASE_PATH = process.env.NODE_ENV === "production" ? "/tx-silknodes" : "";
44

5-
import { useState, useEffect, useMemo } from "react";
5+
import { useState, useEffect, useMemo, useRef } from "react";
66
import Tooltip from "@/components/Tooltip";
7+
import { SILK_LCD, fetchWithTimeout } from "@/lib/chain-config";
78

89
interface ValidatorEntry {
910
operatorAddress: string;
@@ -27,7 +28,7 @@ interface ChainEconomics {
2728
type SortField = "moniker" | "tokens" | "commission" | "monthlyIncome" | "delegatorApr";
2829
type SortDir = "asc" | "desc";
2930

30-
const LCD = "https://rest-coreum.ecostake.com";
31+
const LCD = SILK_LCD;
3132
const SILK_OPERATOR = "corevaloper1kepnaw38rymdvq5sstnnytdqqkpd0xxwc5eqjk";
3233

3334
export default function ValidatorList({ wallet, setActiveTab, setShowWalletModal }: { wallet?: any; setActiveTab?: (tab: string) => void; setShowWalletModal?: (show: boolean) => void }) {
@@ -52,7 +53,7 @@ export default function ValidatorList({ wallet, setActiveTab, setShowWalletModal
5253
const keyParam = paginationKey
5354
? `&pagination.key=${encodeURIComponent(paginationKey)}`
5455
: "";
55-
const resp = await fetch(
56+
const resp = await fetchWithTimeout(
5657
`${LCD}/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED&pagination.limit=100${keyParam}`
5758
);
5859
const result = await resp.json();
@@ -73,23 +74,23 @@ export default function ValidatorList({ wallet, setActiveTab, setShowWalletModal
7374

7475
setValidators(allVals);
7576

76-
const [provRes, distRes, poolRes, priceRes] = await Promise.all([
77-
fetch(`${LCD}/cosmos/mint/v1beta1/annual_provisions`),
78-
fetch(`${LCD}/cosmos/distribution/v1beta1/params`),
79-
fetch(`${LCD}/cosmos/staking/v1beta1/pool`),
80-
fetch("https://api.coingecko.com/api/v3/simple/price?ids=tx&vs_currencies=usd"),
77+
const [provRes, distRes, poolRes, priceRes] = await Promise.allSettled([
78+
fetchWithTimeout(`${LCD}/cosmos/mint/v1beta1/annual_provisions`),
79+
fetchWithTimeout(`${LCD}/cosmos/distribution/v1beta1/params`),
80+
fetchWithTimeout(`${LCD}/cosmos/staking/v1beta1/pool`),
81+
fetchWithTimeout("https://api.coingecko.com/api/v3/simple/price?ids=tx&vs_currencies=usd"),
8182
]);
8283

83-
const prov = await provRes.json();
84-
const dist = await distRes.json();
85-
const pool = await poolRes.json();
86-
const price = await priceRes.json();
84+
const prov = provRes.status === "fulfilled" ? await provRes.value.json() : {};
85+
const dist = distRes.status === "fulfilled" ? await distRes.value.json() : {};
86+
const pool = poolRes.status === "fulfilled" ? await poolRes.value.json() : {};
87+
const price = priceRes.status === "fulfilled" ? await priceRes.value.json() : {};
8788

8889
const annualProvisions = parseFloat(prov.annual_provisions || "0") / 1e6;
8990
const communityTax = parseFloat(dist.params?.community_tax || "0.05");
9091
const totalBonded = parseInt(pool.pool?.bonded_tokens || "0") / 1e6;
9192

92-
const inflRes = await fetch(`${LCD}/cosmos/mint/v1beta1/inflation`);
93+
const inflRes = await fetchWithTimeout(`${LCD}/cosmos/mint/v1beta1/inflation`);
9394
const infl = await inflRes.json();
9495

9596
setEconomics({

src/hooks/useRWATokens.ts

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useState, useEffect, useCallback, useRef } from "react";
4-
import { SILK_LCD } from "@/lib/chain-config";
4+
import { SILK_LCD, fetchWithTimeout } from "@/lib/chain-config";
55

66
export interface SmartToken {
77
denom: string;
@@ -39,6 +39,9 @@ export interface RWAData {
3939
// Features that indicate RWA compliance
4040
const RWA_FEATURES = ["whitelisting", "freezing", "clawback", "burning", "minting"];
4141

42+
// Max tokens to process (prevents unbounded fetching)
43+
const MAX_TOKENS = 500;
44+
4245
function parseSupply(amount: string, precision: number): number {
4346
return parseInt(amount) / Math.pow(10, precision);
4447
}
@@ -48,6 +51,7 @@ export function useRWATokens(): RWAData & { refresh: () => void } {
4851
const [loading, setLoading] = useState(true);
4952
const [error, setError] = useState<string | null>(null);
5053
const fetchedRef = useRef(false);
54+
const mountedRef = useRef(true);
5155

5256
const fetchTokens = useCallback(async () => {
5357
try {
@@ -61,51 +65,59 @@ export function useRWATokens(): RWAData & { refresh: () => void } {
6165
do {
6266
const paginationParam = nextKey ? `&pagination.key=${encodeURIComponent(nextKey)}` : "";
6367
const url: string = `${SILK_LCD}/cosmos/bank/v1beta1/supply?pagination.limit=500${paginationParam}`;
64-
const res = await fetch(url);
65-
if (!res.ok) throw new Error(`Supply fetch failed: ${res.status}`);
68+
const res = await fetchWithTimeout(url);
6669
const data = await res.json();
6770
allDenoms.push(...(data.supply || []));
6871
nextKey = data.pagination?.next_key || null;
6972
} while (nextKey);
7073

74+
if (!mountedRef.current) return;
75+
7176
// Step 2: Filter for Smart Token denoms (pattern: subunit-core1...)
72-
const smartDenoms = allDenoms.filter(
73-
(d) => d.denom.match(/^[a-zA-Z0-9]+-core1[a-z0-9]+$/) && d.denom !== "ucore"
74-
);
77+
const smartDenoms = allDenoms
78+
.filter((d) => d.denom.match(/^[a-zA-Z0-9]+-core1[a-z0-9]+$/) && d.denom !== "ucore")
79+
.slice(0, MAX_TOKENS); // Cap at MAX_TOKENS
7580

7681
// Step 3: Fetch token details for each (batch with concurrency limit)
7782
const BATCH_SIZE = 15;
7883
const smartTokens: SmartToken[] = [];
7984

8085
for (let i = 0; i < smartDenoms.length; i += BATCH_SIZE) {
86+
if (!mountedRef.current) return; // Abort if unmounted
87+
8188
const batch = smartDenoms.slice(i, i + BATCH_SIZE);
8289
const results = await Promise.allSettled(
8390
batch.map(async (d) => {
84-
const res = await fetch(`${SILK_LCD}/coreum/asset/ft/v1/tokens/${d.denom}`);
85-
if (!res.ok) return null;
86-
const data = await res.json();
87-
const token = data.token;
88-
return {
89-
denom: token.denom,
90-
issuer: token.issuer,
91-
symbol: token.symbol || token.subunit?.toUpperCase() || "???",
92-
subunit: token.subunit,
93-
precision: token.precision || 0,
94-
description: token.description || "",
95-
globally_frozen: token.globally_frozen || false,
96-
features: token.features || [],
97-
burn_rate: token.burn_rate || "0",
98-
send_commission_rate: token.send_commission_rate || "0",
99-
supply: parseSupply(d.amount, token.precision || 0),
100-
admin: token.admin || token.issuer,
101-
} as SmartToken;
91+
try {
92+
const res = await fetchWithTimeout(`${SILK_LCD}/coreum/asset/ft/v1/tokens/${d.denom}`);
93+
const data = await res.json();
94+
const token = data.token;
95+
return {
96+
denom: token.denom,
97+
issuer: token.issuer,
98+
symbol: token.symbol || token.subunit?.toUpperCase() || "???",
99+
subunit: token.subunit,
100+
precision: token.precision || 0,
101+
description: token.description || "",
102+
globally_frozen: token.globally_frozen || false,
103+
features: token.features || [],
104+
burn_rate: token.burn_rate || "0",
105+
send_commission_rate: token.send_commission_rate || "0",
106+
supply: parseSupply(d.amount, token.precision || 0),
107+
admin: token.admin || token.issuer,
108+
} as SmartToken;
109+
} catch {
110+
return null;
111+
}
102112
})
103113
);
104114
for (const r of results) {
105115
if (r.status === "fulfilled" && r.value) smartTokens.push(r.value);
106116
}
107117
}
108118

119+
if (!mountedRef.current) return;
120+
109121
// Sort by number of RWA features (most compliant first)
110122
smartTokens.sort((a, b) => {
111123
const aScore = a.features.filter((f) => RWA_FEATURES.includes(f)).length;
@@ -115,17 +127,19 @@ export function useRWATokens(): RWAData & { refresh: () => void } {
115127

116128
setTokens(smartTokens);
117129
} catch (err) {
118-
setError(err instanceof Error ? err.message : "Failed to fetch tokens");
130+
if (mountedRef.current) setError(err instanceof Error ? err.message : "Failed to fetch tokens");
119131
} finally {
120-
setLoading(false);
132+
if (mountedRef.current) setLoading(false);
121133
}
122134
}, []);
123135

124136
useEffect(() => {
137+
mountedRef.current = true;
125138
if (!fetchedRef.current) {
126139
fetchedRef.current = true;
127140
fetchTokens();
128141
}
142+
return () => { mountedRef.current = false; };
129143
}, [fetchTokens]);
130144

131145
// Compute stats

src/hooks/useTokenData.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect, useCallback } from "react";
3+
import { useState, useEffect, useCallback, useRef } from "react";
44
import type { TokenData, StakingData, NetworkStatus, ValidatorInfo } from "@/lib/types";
55
import { fetchTokenData, fetchStakingData, fetchNetworkStatus, fetchAllValidators } from "@/lib/api";
66

@@ -25,41 +25,47 @@ export function useTokenData(): UseTokenDataReturn {
2525
const [loading, setLoading] = useState(true);
2626
const [error, setError] = useState<string | null>(null);
2727
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
28+
const mountedRef = useRef(true);
2829

2930
const fetchData = useCallback(async () => {
3031
try {
3132
setError(null);
3233

33-
const [tokenRes, stakingRes, networkRes] = await Promise.allSettled([
34+
// Fetch ALL data in parallel (not sequentially)
35+
const [tokenRes, stakingRes, networkRes, validatorRes] = await Promise.allSettled([
3436
fetchTokenData(),
3537
fetchStakingData(),
3638
fetchNetworkStatus(),
39+
fetchAllValidators(),
3740
]);
3841

42+
// Only update state if component is still mounted
43+
if (!mountedRef.current) return;
44+
3945
const newTokenData = tokenRes.status === "fulfilled" ? tokenRes.value : null;
4046
const newStakingData = stakingRes.status === "fulfilled" ? stakingRes.value : null;
4147

4248
if (newTokenData) setTokenData(newTokenData);
4349
if (newStakingData) setStakingData(newStakingData);
4450
if (networkRes.status === "fulfilled") setNetworkStatus(networkRes.value);
45-
46-
// Fetch validators with price for income calculation
47-
const price = newTokenData?.price || 0;
48-
const validatorRes = await fetchAllValidators(price);
49-
setValidators(validatorRes);
51+
if (validatorRes.status === "fulfilled") setValidators(validatorRes.value);
5052

5153
setLastUpdated(new Date());
5254
} catch (err: any) {
53-
setError(err.message || "Failed to fetch data");
55+
if (mountedRef.current) setError(err.message || "Failed to fetch data");
5456
} finally {
55-
setLoading(false);
57+
if (mountedRef.current) setLoading(false);
5658
}
5759
}, []);
5860

5961
useEffect(() => {
62+
mountedRef.current = true;
6063
fetchData();
6164
const interval = setInterval(fetchData, REFRESH_INTERVAL);
62-
return () => clearInterval(interval);
65+
return () => {
66+
mountedRef.current = false;
67+
clearInterval(interval);
68+
};
6369
}, [fetchData]);
6470

6571
return {

0 commit comments

Comments
 (0)