diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f66ef0..5f3a982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,9 +70,9 @@ jobs: if: matrix.node == '22' run: npx playwright install --with-deps chromium - - name: Check recovery-alert browser flow and small windows + - name: Check recovery and dashboard browser flows and small windows if: matrix.node == '22' - run: npm run test:recovery-browser + run: npx playwright test --config playwright.config.cjs tests/browser/recovery-alerts.spec.cjs tests/browser/network-availability.spec.cjs - name: Preserve recovery-alert screenshots and failure traces if: always() && matrix.node == '22' diff --git a/src/App.css b/src/App.css index 38a66d3..c49613e 100644 --- a/src/App.css +++ b/src/App.css @@ -736,6 +736,13 @@ .chart-canvas { height: 360px; + min-width: 0; +} + +.panel--chart, +.breakdown-panel { + /* Canvas has an intrinsic 300px width; let its grid track shrink on phones. */ + min-width: 0; } .range-switcher, @@ -1436,6 +1443,21 @@ color: #855b15; } +.network-data-status { + display: grid; + gap: 10px; + overflow-wrap: anywhere; +} + +.network-data-status .notice { + margin: 0; +} + +.metric-card strong { + white-space: normal; + overflow-wrap: anywhere; +} + .state-block { text-align: center; background: linear-gradient(180deg, rgba(249, 252, 255, 0.98) 0%, rgba(244, 248, 252, 0.98) 100%); diff --git a/src/components/NetworkDataStatus.js b/src/components/NetworkDataStatus.js new file mode 100644 index 0000000..d62f4b5 --- /dev/null +++ b/src/components/NetworkDataStatus.js @@ -0,0 +1,28 @@ +import React from 'react'; + +const LABELS = { chain: 'Chain', node: 'Sentry node', governance: 'Governance', market: 'Market' }; + +export default function NetworkDataStatus({ groups, historyError }) { + const observations = Object.entries(groups || {}); + // The page already has one clear outage message when every live group is + // unavailable. Do not repeat it in four more warning panels. + const allUnavailable = observations.length > 0 && observations.every(([, group]) => group.status === 'unavailable'); + const notices = new Map(); + for (const [name, group] of observations) { + if (allUnavailable || group.status === 'ready') continue; + const key = `${group.status}:${group.updatedAt || ''}`; + const notice = notices.get(key) || { ...group, names: [] }; + notice.names.push(LABELS[name]); + notices.set(key, notice); + } + return
+ {[...notices.entries()].map(([key, group]) => ( +

+ {group.names.join(', ')} statistics {group.status === 'stale' ? 'are stale' : + group.status === 'unverified' ? 'have no source freshness timestamp' : 'are unavailable'}. + {group.updatedAt ? ` Last successful update: ${new Date(group.updatedAt).toLocaleString()}.` : ''} +

+ ))} + {historyError ?

{historyError}

: null} +
; +} diff --git a/src/hooks/useGovernanceData.js b/src/hooks/useGovernanceData.js index 7e8153b..391de74 100644 --- a/src/hooks/useGovernanceData.js +++ b/src/hooks/useGovernanceData.js @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchGovernanceFeed, fetchNetworkStats } from '../lib/api'; import { isSupportedGovernanceProposal } from '../lib/governanceEligibility'; +import { normalizeNetworkStats } from '../lib/networkStats'; +import { useBackgroundPoll } from './useBackgroundPoll'; // Governance feed + network stats, loaded once on mount and refetchable // on demand via the returned `refresh` callback. @@ -40,12 +42,15 @@ export default function useGovernanceData() { loadedAt: null, loading: true, proposals: [], + proposalsLoaded: false, stats: null, + groups: {}, }); const mountedRef = useRef(true); const latestRequestIdRef = useRef(0); const load = useCallback(async function load() { + if (!mountedRef.current) return; const requestId = latestRequestIdRef.current + 1; latestRequestIdRef.current = requestId; // Keep the previous proposals/stats visible while the refetch @@ -53,16 +58,20 @@ export default function useGovernanceData() { // cause jarring layout shifts. Mark `loading` so UI surfaces // can show a spinner overlay. setState(function markLoading(prev) { + if (!mountedRef.current || latestRequestIdRef.current !== requestId) return prev; return { ...prev, loading: true, error: '' }; }); try { - const response = await Promise.all([ + const response = await Promise.allSettled([ fetchNetworkStats(), fetchGovernanceFeed(), ]); if (!mountedRef.current) return; if (latestRequestIdRef.current !== requestId) return; - const rawProposals = Array.isArray(response[1]) ? response[1] : []; + const feedSucceeded = response[1].status === 'fulfilled' && Array.isArray(response[1].value); + const normalized = response[0].status === 'fulfilled' + ? normalizeNetworkStats(response[0].value) : null; + const rawProposals = feedSucceeded ? response[1].value : []; const nowMs = Date.now(); const proposals = rawProposals .filter(function keepProposal(proposal) { @@ -73,23 +82,29 @@ export default function useGovernanceData() { Number(b.AbsoluteYesCount || 0) - Number(a.AbsoluteYesCount || 0) ); }); - setState({ - error: '', - loadedAt: new Date(), + setState((previous) => { + if (!mountedRef.current || latestRequestIdRef.current !== requestId) return previous; + return { + error: [!normalized?.stats ? 'Live network statistics are unavailable right now.' : '', + !feedSucceeded ? 'Unable to refresh the governance proposal feed.' : ''].filter(Boolean).join(' '), + loadedAt: normalized?.stats ? normalized.loadedAt : previous.loadedAt, loading: false, - proposals, - stats: response[0], + proposals: feedSucceeded ? proposals : previous.proposals, + proposalsLoaded: feedSucceeded || previous.proposalsLoaded, + stats: normalized?.stats || previous.stats, + groups: normalized?.stats ? normalized.groups : Object.fromEntries( + Object.entries(previous.groups).map(([name, group]) => [name, + group.status === 'unavailable' ? group : { ...group, status: 'stale' }])) + }; }); } catch (err) { if (!mountedRef.current) return; if (latestRequestIdRef.current !== requestId) return; - setState({ - error: 'Unable to load governance data right now.', - loadedAt: new Date(), - loading: false, - proposals: [], - stats: null, - }); + setState((previous) => !mountedRef.current || latestRequestIdRef.current !== requestId ? previous : ({ + ...previous, + error: 'Unable to load governance data right now.', + loading: false, + })); } }, []); @@ -104,5 +119,7 @@ export default function useGovernanceData() { [load] ); + useBackgroundPoll(load, { enabled: true, intervalMs: 60000 }); + return { ...state, refresh: load }; } diff --git a/src/hooks/useGovernanceData.test.js b/src/hooks/useGovernanceData.test.js index c51880d..e0603d9 100644 --- a/src/hooks/useGovernanceData.test.js +++ b/src/hooks/useGovernanceData.test.js @@ -31,6 +31,11 @@ function Probe() { return {state.loading ? 'loading' : JSON.stringify(state.proposals.map((p) => p.Key))}; } +function SnapshotProbe() { + const state = useGovernanceData(); + return {JSON.stringify(state)}; +} + describe('governance feed eligibility', () => { beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW); @@ -74,4 +79,17 @@ describe('governance feed eligibility', () => { render(); await waitFor(() => expect(screen.getByTestId('proposals').textContent).toBe(JSON.stringify(['a'.repeat(64), 'c'.repeat(64)]))); }); + + test('a statistics outage does not discard a valid independent proposal feed', async () => { + fetchNetworkStats.mockRejectedValue(new Error('503')); + fetchGovernanceFeed.mockResolvedValue([proposal()]); + render(); + await waitFor(() => expect(JSON.parse(screen.getByTestId('snapshot').textContent).loading).toBe(false)); + const state = JSON.parse(screen.getByTestId('snapshot').textContent); + expect(state.proposals).toHaveLength(1); + expect(state.proposalsLoaded).toBe(true); + expect(state.stats).toBeNull(); + expect(state.loadedAt).toBeNull(); + expect(state.error).toMatch(/statistics are unavailable/); + }); }); diff --git a/src/hooks/useNetworkData.js b/src/hooks/useNetworkData.js index a7e86bd..adb434b 100644 --- a/src/hooks/useNetworkData.js +++ b/src/hooks/useNetworkData.js @@ -1,55 +1,70 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchNetworkStats, fetchNodeHistory } from '../lib/api'; +import { normalizeNetworkStats, normalizeNodeHistory } from '../lib/networkStats'; +import { useBackgroundPoll } from './useBackgroundPoll'; export default function useNetworkData() { const [state, setState] = useState({ loading: true, + statsLoading: true, + historyLoading: true, error: '', + historyError: '', + groups: {}, history: [], loadedAt: null, stats: null, }); + const mounted = useRef(true); + const generation = useRef(0); - useEffect(function loadNetworkData() { - let cancelled = false; - - async function load() { - try { - const response = await Promise.all([fetchNetworkStats(), fetchNodeHistory()]); - - if (cancelled) { - return; - } - - setState({ - loading: false, - error: '', - history: Array.isArray(response[1]) ? response[1] : [], - loadedAt: new Date(), - stats: response[0], - }); - } catch (error) { - if (cancelled) { - return; - } - - setState({ - loading: false, - error: 'Unable to load the latest network metrics right now.', - history: [], - loadedAt: new Date(), - stats: null, - }); - } + const load = useCallback(() => { + const request = ++generation.current; + function update(fields) { + if (!mounted.current || request !== generation.current) return; + setState((previous) => { + if (!mounted.current || request !== generation.current) return previous; + const next = { ...previous, ...(typeof fields === 'function' ? fields(previous) : fields) }; + return { ...next, loading: next.statsLoading || next.historyLoading }; + }); + } + update({ statsLoading: true, historyLoading: true }); + function statsFailure(previous) { + return { + statsLoading: false, + // Keep a known observation visible, explicitly stale, without changing + // its source timestamp. An outage is never a successful zero snapshot. + groups: Object.fromEntries(Object.entries(previous.groups).map(([key, group]) => + [key, group.status === 'unavailable' ? group : { ...group, status: 'stale' }])), + error: previous.stats + ? 'Unable to refresh live statistics. Showing the last known observations.' + : 'Live network statistics are unavailable right now.', + }; } + const statsRequest = fetchNetworkStats().then((payload) => { + const normalized = normalizeNetworkStats(payload); + if (!normalized.stats) update(statsFailure); + else update({ ...normalized, statsLoading: false, error: '' }); + }).catch(() => update(statsFailure)); + const historyRequest = fetchNodeHistory().then((history) => update({ historyLoading: false, + history: normalizeNodeHistory(history), + historyError: Array.isArray(history) ? '' : 'Node history is unavailable right now.' + })).catch(() => update({ historyLoading: false, + historyError: 'Node history is unavailable right now.' })); + return Promise.all([statsRequest, historyRequest]); + }, []); + useEffect(function loadNetworkData() { + mounted.current = true; load(); - return function cleanup() { - cancelled = true; + mounted.current = false; + generation.current += 1; }; - }, []); + }, [load]); + + useBackgroundPoll(load, { enabled: true, intervalMs: 60000 }); return state; } diff --git a/src/hooks/useNetworkData.test.js b/src/hooks/useNetworkData.test.js new file mode 100644 index 0000000..d81bac0 --- /dev/null +++ b/src/hooks/useNetworkData.test.js @@ -0,0 +1,127 @@ +import React from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import useNetworkData from './useNetworkData'; +import { fetchNetworkStats, fetchNodeHistory } from '../lib/api'; +import { coldNetworkStats, validNetworkStats } from '../testFixtures/networkStats'; +jest.mock('../lib/api', () => ({ fetchNetworkStats: jest.fn(), fetchNodeHistory: jest.fn() })); +function Probe() { + const data = useNetworkData(); + return
{JSON.stringify(data)}
; +} +const state = () => JSON.parse(screen.getByTestId('state').textContent); +beforeEach(() => jest.clearAllMocks()); +afterEach(() => { + cleanup(); + jest.useRealTimers(); + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); +}); +async function settle() { + await act(async () => { for (let i = 0; i < 6; i += 1) await Promise.resolve(); }); +} +function hidden(value) { + Object.defineProperty(document, 'hidden', { configurable: true, value }); + document.dispatchEvent(new Event('visibilitychange')); +} + +test('history succeeds independently while current statistics fail', async () => { + fetchNetworkStats.mockRejectedValue(new Error('503')); + fetchNodeHistory.mockResolvedValue([{ date: '2026-10-21', users: 3123 }]); + render(); + await waitFor(() => expect(state().loading).toBe(false)); + expect(state().history).toHaveLength(1); + expect(state().stats).toBeNull(); + expect(state().error).toMatch(/unavailable/); + expect(state().loadedAt).toBeNull(); +}); + +test('history displays before a slow statistics response', async () => { + let finish; + fetchNetworkStats.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + fetchNodeHistory.mockResolvedValue([{ date: '2026-10-21', users: 3123 }]); + render(); + await waitFor(() => expect(state().history).toHaveLength(1)); + expect(state().statsLoading).toBe(true); + await act(async () => finish(validNetworkStats())); + expect(state().loading).toBe(false); +}); + +test('a successful HTTP response with cold defaults is unavailable, not fresh', async () => { + fetchNetworkStats.mockResolvedValue(coldNetworkStats); + fetchNodeHistory.mockResolvedValue([]); + render(); + await waitFor(() => expect(state().loading).toBe(false)); + expect(state().stats).toBeNull(); + expect(state().loadedAt).toBeNull(); + expect(state().error).toMatch(/unavailable/); +}); + +test('history failure does not discard a valid current snapshot', async () => { + fetchNetworkStats.mockResolvedValue(validNetworkStats()); + fetchNodeHistory.mockRejectedValue(new Error('503')); + render(); + await waitFor(() => expect(state().loading).toBe(false)); + expect(state().stats.stats.mn_stats.enabled).toBe('3,000'); + expect(state().error).toBe(''); + expect(state().historyError).toMatch(/unavailable/); +}); + +test('a later refresh failure preserves known values as stale without advancing their timestamp', async () => { + jest.useFakeTimers(); + const now = Date.now(); + fetchNetworkStats.mockResolvedValueOnce(validNetworkStats(now)).mockRejectedValue(new Error('503')); + fetchNodeHistory.mockResolvedValueOnce([{ date: '2026-10-21', users: 3123 }]).mockRejectedValue(new Error('503')); + render(); + await settle(); + expect(state().loadedAt).toBe(new Date(now).toISOString()); + await act(async () => jest.advanceTimersByTime(60000)); + await settle(); + expect(state().stats.stats.mn_stats.enabled).toBe('3,000'); + expect(state().groups.node.status).toBe('stale'); + expect(state().loadedAt).toBe(new Date(now).toISOString()); + expect(state().history).toHaveLength(1); + expect(state().error).toMatch(/last known/); +}); + +test('a long-open page ages a repeated source snapshot to stale', async () => { + jest.useFakeTimers(); + const now = Date.now(); + fetchNetworkStats.mockResolvedValue(validNetworkStats(now)); + fetchNodeHistory.mockResolvedValue([]); + render(); + await settle(); + for (let i = 0; i < 3; i += 1) { + await act(async () => jest.advanceTimersByTime(60000)); + await settle(); + } + expect(state().groups.node.status).toBe('stale'); + expect(state().loadedAt).toBe(new Date(now).toISOString()); +}); + +test('hidden tabs pause polling and visibility resumes with a catch-up read', async () => { + jest.useFakeTimers(); + fetchNetworkStats.mockResolvedValue(validNetworkStats()); + fetchNodeHistory.mockResolvedValue([]); + render(); + await settle(); + act(() => hidden(true)); + await act(async () => jest.advanceTimersByTime(180000)); + expect(fetchNetworkStats).toHaveBeenCalledTimes(1); + act(() => hidden(false)); + await settle(); + expect(fetchNetworkStats).toHaveBeenCalledTimes(2); +}); + +test('a superseded initial response cannot replace the newer visibility refresh', async () => { + let oldStats; + let oldHistory; + fetchNetworkStats.mockReturnValueOnce(new Promise((resolve) => { oldStats = resolve; })) + .mockResolvedValue(validNetworkStats()); + fetchNodeHistory.mockReturnValueOnce(new Promise((resolve) => { oldHistory = resolve; })) + .mockResolvedValue([{ date: '2026-10-21', users: 3123 }]); + render(); + act(() => { hidden(true); hidden(false); }); + await settle(); + await act(async () => { oldStats(coldNetworkStats); oldHistory([]); }); + expect(state().stats.stats.mn_stats.enabled).toBe('3,000'); + expect(state().history).toHaveLength(1); +}); diff --git a/src/lib/governanceWindow.js b/src/lib/governanceWindow.js index 8ddff88..b07716c 100644 --- a/src/lib/governanceWindow.js +++ b/src/lib/governanceWindow.js @@ -1,3 +1,5 @@ +import { hasFreshStatsGroups } from './networkStats'; + // Governance proposal window math. // // Syscoin governance proposals pay out on superblocks. Core gates @@ -310,6 +312,7 @@ export function nextSuperblockEpochSecFromStats(stats, nowSec) { ); } if (!stats || typeof stats !== 'object') return null; + if (!hasFreshStatsGroups(stats, ['chain', 'governance'], now * 1000)) return null; const root = stats.stats && typeof stats.stats === 'object' ? stats.stats : stats; const sb = root.superblock_stats; if (!sb || typeof sb !== 'object') return null; diff --git a/src/lib/governanceWindow.test.js b/src/lib/governanceWindow.test.js index f1dcd18..1fc589d 100644 --- a/src/lib/governanceWindow.test.js +++ b/src/lib/governanceWindow.test.js @@ -477,6 +477,34 @@ describe('computeProposalWindow', () => { describe('nextSuperblockEpochSecFromStats', () => { const NOW = 1_800_000_000; + function observed(status = 'ready', updatedAt = NOW * 1000) { + return { + stats_meta: { groups: { chain: { status, updated_at: updatedAt }, + governance: { status, updated_at: updatedAt } } }, + stats: { superblock_stats: { superblock_next_epoch_sec: NOW + 3600 } }, + }; + } + + test('accepts explicit fresh ready chain and governance observations', () => { + expect(nextSuperblockEpochSecFromStats(observed(), NOW)).toBe(NOW + 3600); + }); + + test.each(['stale', 'unavailable'])('rejects a future anchor whose source reports %s', (status) => { + expect(nextSuperblockEpochSecFromStats(observed(status), NOW)).toBeNull(); + }); + + test.each([null, 0, NOW * 1000 - 120001, NOW * 1000 + 60001])('rejects invalid or aged observation time %s', (time) => { + expect(nextSuperblockEpochSecFromStats(observed('ready', time), NOW)).toBeNull(); + }); + + test('both source groups must be present and fresh when metadata is supplied', () => { + const payload = observed(); + delete payload.stats_meta.groups.chain; + expect(nextSuperblockEpochSecFromStats(payload, NOW)).toBeNull(); + payload.stats_meta = null; + expect(nextSuperblockEpochSecFromStats(payload, NOW)).toBeNull(); + }); + test('returns the numeric field when strictly in the future', () => { expect( nextSuperblockEpochSecFromStats( diff --git a/src/lib/networkStats.js b/src/lib/networkStats.js new file mode 100644 index 0000000..1ddd920 --- /dev/null +++ b/src/lib/networkStats.js @@ -0,0 +1,164 @@ +import { parseUtcDateValue } from './formatters'; + +// The public dashboard must distinguish an absent observation from a real zero. +// Keep this validation local to network metrics; other wallet/count formatters +// intentionally have different fallback semantics. +export function metricNumber(value) { + if (typeof value !== 'number' && typeof value !== 'string') return null; + if (typeof value === 'string' && !value.trim()) return null; + const cleaned = typeof value === 'string' + ? value.replace(/[$,%]/g, '').replace(/\s*(?:SYS|BTC)\s*$/i, '').trim() + : value; + if (cleaned === '') return null; + const number = Number(cleaned); + return Number.isFinite(number) ? number : null; +} + +const count = (value) => { + const number = metricNumber(value); + return number !== null && Number.isSafeInteger(number) && number >= 0 ? number : null; +}; +const positive = (value) => { + const number = metricNumber(value); + return number !== null && number > 0; +}; +const nonnegative = (value) => { + const number = metricNumber(value); + return number !== null && number >= 0; +}; +const MARKET_FIELDS = ['price_usd', 'price_btc', 'circulating_supply', 'total_supply', + 'volume_usd', 'volume_btc', 'market_cap_usd', 'market_cap_btc', 'price_change']; + +export function hasFreshStatsGroups(payload, names, nowMs = Date.now()) { + if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'stats_meta')) return true; + const groups = payload.stats_meta && payload.stats_meta.groups; + return !!groups && names.every((name) => { + const group = groups[name]; + const updatedAt = metricNumber(group && group.updated_at); + return group && group.status === 'ready' && updatedAt !== null && updatedAt > 0 && + updatedAt <= nowMs + 60000 && nowMs - updatedAt <= 120000; + }); +} + +function numericFields(group) { + if (!group || typeof group !== 'object') return null; + return Object.fromEntries(Object.entries(group).map(([key, value]) => [ + key, + metricNumber(value) === null ? null : value, + ])); +} + +function incomeFields(group) { + if (!group || typeof group !== 'object') return null; + return Object.fromEntries(['usd', 'btc', 'sys'].map((currency) => [ + currency, numericFields(group[currency]) || {}, + ])); +} + +function normalizeMapData(value) { + if (!value || typeof value !== 'object') return {}; + return Object.fromEntries(Object.entries(value).flatMap(([country, entry]) => { + const nodes = entry && typeof entry === 'object' ? count(entry.masternodes) : null; + return nodes === null ? [] : [[country, { ...entry, masternodes: nodes }]]; + })); +} + +export function normalizeNetworkStats(payload, now = Date.now()) { + const raw = payload && payload.stats && typeof payload.stats === 'object' ? payload.stats : {}; + const metadata = payload && Object.prototype.hasOwnProperty.call(payload, 'stats_meta') + ? payload.stats_meta?.groups || {} : null; + const chain = raw.blockchain_stats; + const node = raw.mn_stats; + const governance = raw.superblock_stats; + const market = raw.price_stats; + const valid = { + chain: !!chain && positive(chain.connections) && positive(chain.version), + node: !!node && count(node.total) !== null && count(node.enabled) !== null && + count(node.pose_banned) !== null && count(node.enabled) <= count(node.total) && + nonnegative(node.total_locked), + governance: !!governance && positive(governance.superblock_next_epoch_sec) && + !!parseUtcDateValue(governance.superblock_date), + market: !!market && (metadata + ? MARKET_FIELDS.some((key) => key === 'price_change' + ? metricNumber(market[key]) !== null : nonnegative(market[key])) + : positive(market.price_usd) && nonnegative(market.volume_usd) && nonnegative(market.market_cap_usd)), + }; + // Older servers have no health metadata. Require a real chain observation, + // not the cold-start all-zero object, before showing their derived metrics. + if (!metadata && !valid.chain) valid.node = false; + const groups = {}; + for (const name of Object.keys(valid)) { + const observation = metadata && metadata[name]; + const updatedAt = metricNumber(observation && observation.updated_at); + const timestampValid = updatedAt !== null && updatedAt > 0 && updatedAt <= now + 60000; + const explicitStatus = observation && observation.status; + const available = valid[name] && (!metadata || ( + timestampValid && (explicitStatus === 'ready' || explicitStatus === 'stale') + )); + groups[name] = { + status: !available ? 'unavailable' : metadata + ? (explicitStatus === 'ready' && now - updatedAt > 120000 ? 'stale' : explicitStatus) + : 'unverified', + updatedAt: available && timestampValid ? updatedAt : null, + }; + } + const available = (name) => groups[name].status !== 'unavailable'; + const sanitizedNode = available('node') ? { ...node } : null; + if (sanitizedNode) { + for (const key of ['roi', 'roi_one', 'roi_two', 'coins_percent_locked', + 'masternode_price_usd', 'masternode_price_btc']) { + sanitizedNode[key] = metricNumber(node[key]) === null ? null : node[key]; + } + if (count(node.enabled) === 0) { + sanitizedNode.roi = sanitizedNode.roi_one = sanitizedNode.roi_two = null; + sanitizedNode.payout_frequency = null; + } + if (!available('market')) { + sanitizedNode.masternode_price_usd = sanitizedNode.masternode_price_btc = null; + sanitizedNode.current_supply = sanitizedNode.coins_percent_locked = null; + } + } + const sanitized = { + mn_stats: sanitizedNode, + blockchain_stats: available('chain') ? chain : null, + superblock_stats: available('governance') ? governance : null, + price_stats: available('market') ? numericFields(market) : null, + }; + for (const key of ['income_stats', 'income_stats_seniority_one_year', 'income_stats_seniority_two_year']) { + sanitized[key] = available('node') && count(node.enabled) > 0 ? incomeFields(raw[key]) : null; + if (sanitized[key] && !available('market')) { + sanitized[key].usd = {}; + sanitized[key].btc = {}; + } + } + const timestamps = Object.values(groups).map((group) => group.updatedAt).filter(Boolean); + return { + groups, + // A fresh market quote must not make an older chain observation look new. + loadedAt: timestamps.length ? new Date(Math.min(...timestamps)) : null, + stats: Object.values(groups).some((group) => group.status !== 'unavailable') + ? { ...payload, stats: sanitized, mapData: available('node') ? normalizeMapData(payload.mapData) : {} } + : null, + }; +} + +export function normalizeNodeHistory(history) { + if (!Array.isArray(history)) return []; + return history.flatMap((entry) => entry && count(entry.users) !== null && + Number.isFinite(Date.parse(entry.date)) ? [{ ...entry, users: count(entry.users) }] : []); +} + +export function metricText(value) { + return !['number', 'string'].includes(typeof value) || value === '' || /(?:NaN|Infinity)/.test(String(value)) + ? 'Unavailable' : value; +} + +export function metricFormat(value, formatter, ...args) { + const number = metricNumber(value); + return number === null ? 'Unavailable' : formatter(number, ...args); +} + +export function metricDate(value, formatter) { + return ['number', 'string'].includes(typeof value) && parseUtcDateValue(value) + ? formatter(value) : 'Unavailable'; +} diff --git a/src/lib/networkStats.test.js b/src/lib/networkStats.test.js new file mode 100644 index 0000000..c579f98 --- /dev/null +++ b/src/lib/networkStats.test.js @@ -0,0 +1,119 @@ +import { metricFormat, metricNumber, metricText, normalizeNetworkStats, normalizeNodeHistory } from './networkStats'; +import { coldNetworkStats, validNetworkStats } from '../testFixtures/networkStats'; + +describe('network snapshot validation', () => { + test('rejects the live cold-start zero/NaN/Infinity snapshot', () => { + const result = normalizeNetworkStats(coldNetworkStats); + expect(result.stats).toBeNull(); + expect(result.loadedAt).toBeNull(); + expect(Object.values(result.groups).every((group) => group.status === 'unavailable')).toBe(true); + }); + + test('keeps core data when the independent market group is unavailable', () => { + const payload = validNetworkStats(); + payload.stats_meta.groups.market = { status: 'unavailable', updated_at: null }; + const result = normalizeNetworkStats(payload); + expect(result.stats.stats.mn_stats.enabled).toBe('3,000'); + expect(result.stats.stats.mn_stats.roi).toBe('3.65%'); + expect(result.stats.stats.price_stats).toBeNull(); + expect(result.stats.stats.mn_stats.masternode_price_usd).toBeNull(); + expect(result.stats.stats.income_stats.usd).toEqual({}); + expect(result.stats.stats.income_stats.sys.daily).toBe('10 SYS'); + expect(result.stats.stats.mn_stats.current_supply).toBeNull(); + expect(result.stats.stats.mn_stats.coins_percent_locked).toBeNull(); + }); + + test('an explicitly ready zero-price quote is a real zero, not an outage', () => { + const payload = validNetworkStats(); + payload.stats.price_stats.price_usd = '0.00'; + payload.stats.price_stats.price_btc = '0.00000000'; + expect(normalizeNetworkStats(payload).groups.market.status).toBe('ready'); + expect(normalizeNetworkStats(payload).stats.stats.price_stats.price_usd).toBe('0.00'); + }); + + test('partial ready market data retains each known field without inventing missing values', () => { + const payload = validNetworkStats(); + payload.stats.price_stats = { price_usd: '0.12', volume_usd: null, market_cap_usd: null }; + const result = normalizeNetworkStats(payload); + expect(result.groups.market.status).toBe('ready'); + expect(result.stats.stats.price_stats).toEqual({ price_usd: '0.12', volume_usd: null, market_cap_usd: null }); + payload.stats.price_stats = { unknown: 5 }; + expect(normalizeNetworkStats(payload).groups.market.status).toBe('unavailable'); + }); + + test('allows a real zero enabled count while suppressing undefined returns', () => { + const payload = validNetworkStats(); + payload.stats.mn_stats.enabled = '0'; + payload.stats.mn_stats.roi = 'Infinity%'; + const result = normalizeNetworkStats(payload); + expect(result.stats.stats.mn_stats.enabled).toBe('0'); + expect(result.stats.stats.mn_stats.roi).toBeNull(); + expect(result.stats.stats.income_stats).toBeNull(); + expect(result.groups.node.status).toBe('ready'); + }); + + test('retains the original stale observation time rather than HTTP arrival time', () => { + const now = 1800000000000; + const payload = validNetworkStats(now - 300000); + const result = normalizeNetworkStats(payload, now); + expect(result.groups.chain.status).toBe('stale'); + expect(result.loadedAt.getTime()).toBe(now - 300000); + }); + + test('a new market quote does not refresh the displayed age of stale chain data', () => { + const now = 1800000000000; + const payload = validNetworkStats(now - 300000); + payload.stats_meta.groups.market.updated_at = now; + const result = normalizeNetworkStats(payload, now); + expect(result.groups.market.status).toBe('ready'); + expect(result.loadedAt.getTime()).toBe(now - 300000); + }); + + test('invalid historical rows cannot fabricate zero observations or crash charts', () => { + expect(normalizeNodeHistory([null, { date: 'not-a-date', users: 0 }, + { date: '2026-10-21', users: 'NaN' }, { date: '2026-10-21', users: 0 }, + { date: '2026-10-20', users: '3,123' }])) + .toEqual([{ date: '2026-10-21', users: 0 }, { date: '2026-10-20', users: 3123 }]); + }); + + test('malformed location entries cannot crash country sorting or produce false counts', () => { + const payload = validNetworkStats(); + payload.mapData = { USA: null, CAN: { masternodes: 'NaN' }, DEU: { masternodes: '1,234' } }; + expect(normalizeNetworkStats(payload).stats.mapData).toEqual({ DEU: { masternodes: 1234 } }); + }); + + test.each([null, 0, 1800000060001])('rejects invalid or future source timestamp %s', (timestamp) => { + const payload = validNetworkStats(1800000000000); + payload.stats_meta.groups.node.updated_at = timestamp; + expect(normalizeNetworkStats(payload, 1800000000000).groups.node.status).toBe('unavailable'); + }); + + test('valid legacy snapshots are explicitly freshness-unverified', () => { + const payload = validNetworkStats(); + delete payload.stats_meta; + const result = normalizeNetworkStats(payload); + expect(result.groups.chain.status).toBe('unverified'); + expect(result.loadedAt).toBeNull(); + }); + + test('malformed and missing groups cannot be displayed as successful zeros', () => { + expect(normalizeNetworkStats({}).stats).toBeNull(); + expect(normalizeNetworkStats({ stats: null }).stats).toBeNull(); + expect(normalizeNetworkStats(null).stats).toBeNull(); + const payload = validNetworkStats(); + payload.stats.mn_stats.total = '-1'; + expect(normalizeNetworkStats(payload).groups.node.status).toBe('unavailable'); + }); + + test.each([null, undefined, '', 'Infinity%', '$NaN', {}, false, '$', 'SYS', '%'])('does not format unknown value %s as zero', (value) => { + expect(metricNumber(value)).toBeNull(); + expect(metricFormat(value, String)).toBe('Unavailable'); + }); + + test('zero remains a real value; unsafe derived display strings are absent', () => { + expect(metricFormat('0', String)).toBe('0'); + expect(metricText('Infinity SYS')).toBe('Unavailable'); + expect(metricText('$NaN')).toBe('Unavailable'); + expect(metricText({})).toBe('Unavailable'); + }); +}); diff --git a/src/pages/Governance.js b/src/pages/Governance.js index 3a4e36f..ab45ab3 100644 --- a/src/pages/Governance.js +++ b/src/pages/Governance.js @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'; import DataState from '../components/DataState'; import GovernanceActivity from '../components/GovernanceActivity'; import GovernanceOpsHero from '../components/GovernanceOpsHero'; +import NetworkDataStatus from '../components/NetworkDataStatus'; import PageMeta from '../components/PageMeta'; import ProposalsCreatedPanel from '../components/ProposalsCreatedPanel'; import VoteMethodModal from '../components/VoteMethodModal'; @@ -26,6 +27,7 @@ import { getProposalDurationMonths, parseNumber, } from '../lib/formatters'; +import { metricDate, metricFormat, metricNumber } from '../lib/networkStats'; // Governance page. // @@ -94,12 +96,12 @@ function ProposalRow({ nowMs, }) { const [feedback, setFeedback] = useState(''); - const supportPercent = enabledCount + const supportPercent = enabledCount > 0 ? (Number(proposal.AbsoluteYesCount || 0) / enabledCount) * 100 - : 0; + : null; const yesVotes = Number(proposal.YesCount || 0); const noVotes = Number(proposal.NoCount || 0); - const passing = supportPercent > 10; + const passing = supportPercent === null ? null : supportPercent > 10; const paymentAmount = parseNumber(proposal.payment_amount); const durationMonths = getProposalDurationMonths( proposal.start_epoch, @@ -124,7 +126,7 @@ function ProposalRow({ ); const proposalTitle = proposal.title || proposal.name; - const statusLabel = passing ? 'Passing' : 'Not Enough Votes'; + const statusLabel = passing === null ? 'Support unavailable' : passing ? 'Passing' : 'Not Enough Votes'; async function copyCommand(direction) { try { @@ -139,7 +141,7 @@ function ProposalRow({ const rowClasses = [ 'proposal-row', - passing ? 'is-passing' : 'is-watch', + passing === null ? '' : passing ? 'is-passing' : 'is-watch', isHighlighted ? 'is-highlighted' : '', ] .filter(Boolean) @@ -152,7 +154,7 @@ function ProposalRow({ data-testid={isHighlighted ? 'proposal-row-highlighted' : undefined} >
- + {statusLabel} {cohort ? ( @@ -255,7 +257,7 @@ function ProposalRow({
Support - {formatPercent(supportPercent, 2)} + {metricFormat(supportPercent, formatPercent, 2)}
@@ -355,6 +357,8 @@ export default function Governance() { loading, proposals, stats, + groups, + proposalsLoaded, refresh: refreshGovernanceFeed, } = useGovernanceData(); const { isAuthenticated } = useAuth(); @@ -385,7 +389,15 @@ export default function Governance() { const networkStats = stats && stats.stats ? stats.stats.mn_stats : null; const superblockStats = stats && stats.stats ? stats.stats.superblock_stats : null; - const enabledCount = parseNumber(networkStats && networkStats.enabled); + const hasFeed = proposalsLoaded === undefined ? Boolean(stats) : proposalsLoaded; + const nodeStatus = groups?.node?.status; + const governanceStatus = groups?.governance?.status; + const nodeFresh = !nodeStatus || nodeStatus === 'ready' || nodeStatus === 'unverified'; + const governanceFresh = !governanceStatus || governanceStatus === 'ready' || governanceStatus === 'unverified'; + const enabledCount = nodeFresh ? metricNumber(networkStats && networkStats.enabled) : null; + useEffect(() => { + if (!(enabledCount > 0)) setFilter('all'); + }, [enabledCount]); const requestedBudget = proposals.reduce(function sumBudget(total, proposal) { return total + Number(proposal.payment_amount || 0); }, 0); @@ -400,9 +412,9 @@ export default function Governance() { computeOverBudgetMap({ proposals, enabledCount, - budget: superblockStats ? superblockStats.budget : 0, + budget: governanceFresh && superblockStats ? metricNumber(superblockStats.budget) : null, }), - [proposals, enabledCount, superblockStats] + [proposals, enabledCount, superblockStats, governanceFresh] ); // Per-row selection chip depends on the estimated trigger-selection time AND @@ -415,15 +427,15 @@ export default function Governance() { const closing = useMemo( () => closingChip({ - votingDeadline: superblockStats ? superblockStats.voting_deadline : 0, + votingDeadline: governanceFresh && superblockStats ? superblockStats.voting_deadline : null, nowMs, }), - [superblockStats, nowMs] + [superblockStats, nowMs, governanceFresh] ); const visibleProposals = proposals.filter(function filterProposal(proposal) { - const supportPercent = enabledCount + const supportPercent = enabledCount > 0 ? (Number(proposal.AbsoluteYesCount || 0) / enabledCount) * 100 - : 0; + : null; const matchesQuery = !query ? true : `${proposal.name} ${proposal.title || ''}` @@ -435,11 +447,11 @@ export default function Governance() { } if (filter === 'passing') { - return supportPercent > 10; + return supportPercent !== null && supportPercent > 10; } if (filter === 'watch') { - return supportPercent <= 10; + return supportPercent !== null && supportPercent <= 10; } return true; @@ -570,40 +582,41 @@ export default function Governance() {
Current Proposals - {formatNumber(proposals.length)} + {hasFeed ? formatNumber(proposals.length) : 'Unavailable'} Vote Today!
Monthly Budget - {superblockStats ? `${formatCompactNumber(superblockStats.budget)} SYS` : 'Loading...'} + {superblockStats?.budget !== null && superblockStats?.budget !== undefined + ? `${metricFormat(superblockStats.budget, formatCompactNumber)} SYS` : 'Unavailable'} Current Monthly Ceiling
Requested Budget - {`${formatCompactNumber(requestedBudget)} SYS`} + {hasFeed ? `${formatCompactNumber(requestedBudget)} SYS` : 'Unavailable'} Total Proposal Budget Ask
Estimated Selection Start - {superblockStats ? formatDayMonth(superblockStats.voting_deadline) : 'Loading...'} + {metricDate(superblockStats?.voting_deadline, formatDayMonth)} - {superblockStats - ? `${formatUtcTime(superblockStats.voting_deadline)} (UTC)` + {superblockStats?.voting_deadline + ? `${metricDate(superblockStats.voting_deadline, formatUtcTime)} (UTC)` : 'Pending feed'}
Next Superblock - {superblockStats ? formatDayMonth(superblockStats.superblock_date) : 'Loading...'} + {metricDate(superblockStats?.superblock_date, formatDayMonth)} - {superblockStats - ? `${formatUtcTime(superblockStats.superblock_date)} (UTC)` + {superblockStats?.superblock_date + ? `${metricDate(superblockStats.superblock_date, formatUtcTime)} (UTC)` : 'Pending feed'}
@@ -618,7 +631,8 @@ export default function Governance() { loading={loading && !stats} loadingMessage="Loading the live governance feed..." /> - {!isAuthenticated && stats ? ( + + {!isAuthenticated && hasFeed ? (
) : null} {isAuthenticated ? : null} - {isAuthenticated && stats ? ( + {isAuthenticated && hasFeed ? (
- {stats ? ( + {hasFeed ? (
@@ -684,6 +698,7 @@ export default function Governance() {
@@ -167,11 +166,12 @@ export default function Home() {
+ - {stats ? ( + {!statsLoading || stats ? (
{highlightCards.map(function renderCard(card) { return ( @@ -187,7 +187,7 @@ export default function Home() {
- {stats ? ( + {!historyLoading || stats ? (
- : null}
) : null} diff --git a/src/pages/Network.js b/src/pages/Network.js index 937268c..bc8bf0e 100644 --- a/src/pages/Network.js +++ b/src/pages/Network.js @@ -4,6 +4,7 @@ import BreakdownChart from '../components/BreakdownChart'; import CountryList from '../components/CountryList'; import DataState from '../components/DataState'; import DefinitionGrid from '../components/DefinitionGrid'; +import NetworkDataStatus from '../components/NetworkDataStatus'; import PageMeta from '../components/PageMeta'; import TrendChart from '../components/TrendChart'; import useNetworkData from '../hooks/useNetworkData'; @@ -14,8 +15,8 @@ import { formatNumber, formatPercent, formatToken, - parseNumber, } from '../lib/formatters'; +import { metricNumber, metricFormat, metricText, metricDate } from '../lib/networkStats'; function IncomeTierCard(props) { return ( @@ -46,7 +47,7 @@ function IncomeTierCard(props) { } export default function Network() { - const { loading, error, history, stats } = useNetworkData(); + const { statsLoading, historyLoading, error, historyError, groups, history, stats } = useNetworkData(); const networkStats = stats && stats.stats ? stats.stats.mn_stats : null; const incomeStats = stats && stats.stats ? stats.stats.income_stats : null; const oneYearIncome = @@ -56,53 +57,52 @@ export default function Network() { const priceStats = stats && stats.stats ? stats.stats.price_stats : null; const blockchainStats = stats && stats.stats ? stats.stats.blockchain_stats : null; const superblockStats = stats && stats.stats ? stats.stats.superblock_stats : null; - const enabledCount = parseNumber(networkStats && networkStats.enabled); - const bannedCount = parseNumber(networkStats && networkStats.pose_banned); - const lockedSupply = parseNumber(networkStats && networkStats.total_locked); - const currentSupply = parseNumber(networkStats && networkStats.current_supply); - const remainingSupply = Math.max(currentSupply - lockedSupply, 0); + const enabledCount = metricNumber(networkStats && networkStats.enabled); + const bannedCount = metricNumber(networkStats && networkStats.pose_banned); + const lockedSupply = metricNumber(networkStats && networkStats.total_locked); + const currentSupply = metricNumber(networkStats && networkStats.current_supply); + const remainingSupply = currentSupply !== null && lockedSupply !== null + ? Math.max(currentSupply - lockedSupply, 0) : null; const topCountries = Object.entries(stats && stats.mapData ? stats.mapData : {}) .sort(function sortCountries(a, b) { return Number(b[1].masternodes || 0) - Number(a[1].masternodes || 0); }) .slice(0, 8); - const priceBtcValue = parseNumber(priceStats && priceStats.price_btc); - const readablePriceBtc = priceBtcValue > 0 ? `${priceBtcValue.toFixed(8)} BTC` : 'Unavailable'; - const networkHeroCards = networkStats - ? [ + const priceBtcValue = metricNumber(priceStats && priceStats.price_btc); + const readablePriceBtc = priceBtcValue !== null && priceBtcValue >= 0 ? `${priceBtcValue.toFixed(8)} BTC` : 'Unavailable'; + const networkHeroCards = [ { label: 'Total Nodes', - value: formatNumber(parseNumber(networkStats.total)), + value: metricFormat(networkStats?.total, formatNumber), }, { label: 'Enabled Nodes', - value: formatNumber(enabledCount), + value: metricFormat(enabledCount, formatNumber), }, { label: 'Locked Supply', - value: formatPercent(networkStats.coins_percent_locked, 2), + value: metricFormat(networkStats?.coins_percent_locked, formatPercent, 2), }, { label: 'ROI', - value: `${networkStats.roi}-${networkStats.roi_two}`, + value: networkStats?.roi && networkStats?.roi_two ? `${networkStats.roi}-${networkStats.roi_two}` : 'Unavailable', }, { label: 'Payout Frequency', - value: networkStats.payout_frequency, + value: metricText(networkStats?.payout_frequency), }, { label: 'Next Superblock', - value: superblockStats ? formatDayMonth(superblockStats.superblock_date) : 'Loading...', + value: superblockStats ? formatDayMonth(superblockStats.superblock_date) : 'Unavailable', }, - ] - : []; + ]; const marketItems = - priceStats && networkStats + priceStats ? [ { label: 'SYS price', - value: formatCurrency(parseNumber(priceStats.price_usd)), + value: metricFormat(priceStats.price_usd, formatCurrency), }, { label: 'SYS / BTC', @@ -110,19 +110,19 @@ export default function Network() { }, { label: '24h Volume', - value: formatCurrency(parseNumber(priceStats.volume_usd)), + value: metricFormat(priceStats.volume_usd, formatCurrency), }, { label: 'Market Cap', - value: formatCurrency(parseNumber(priceStats.market_cap_usd)), + value: metricFormat(priceStats.market_cap_usd, formatCurrency), }, { label: '24h Change', - value: formatPercent(priceStats.price_change, 2), + value: metricFormat(priceStats.price_change, formatPercent, 2), }, { label: 'Circulating Supply', - value: formatToken(priceStats.circulating_supply, 'SYS', 0), + value: metricFormat(priceStats.circulating_supply, formatToken, 'SYS', 0), }, ] : []; @@ -130,27 +130,27 @@ export default function Network() { ? [ { label: 'Current Block Height', - value: formatNumber(parseNumber(blockchainStats.connections)), + value: metricFormat(blockchainStats.connections, formatNumber), }, { label: 'Average Block Time', - value: blockchainStats.avg_block, + value: metricText(blockchainStats.avg_block), }, { label: 'Version', - value: blockchainStats.version, + value: metricFormat(blockchainStats.version, String), }, { label: 'Sub-version', - value: blockchainStats.sub_version, + value: metricText(blockchainStats.sub_version), }, { label: 'Protocol', - value: blockchainStats.protocol, + value: metricFormat(blockchainStats.protocol, String), }, { label: 'Genesis Block', - value: formatLongDate(blockchainStats.genesis), + value: metricDate(blockchainStats.genesis, formatLongDate), }, ] : []; @@ -177,7 +177,7 @@ export default function Network() { return (
{card.label} - {card.value} + {statsLoading && !stats ? 'Loading...' : card.value}
); })} @@ -189,13 +189,14 @@ export default function Network() {
+
- {stats ? ( + {!historyLoading || stats ? (
- : null}
) : null} - {stats ? ( + {networkStats ? (
- + /> :

Syscoin Sentry Node TVL

+

Supply proportions are unavailable until a current supply observation is available.

+

Locked collateral: {metricFormat(lockedSupply, formatToken, 'SYS', 0)}

+
}
) : null} - {stats ? ( + {networkStats ? (
@@ -306,33 +310,33 @@ export default function Network() {
diff --git a/src/pages/NetworkAvailability.test.js b/src/pages/NetworkAvailability.test.js new file mode 100644 index 0000000..1b7e81a --- /dev/null +++ b/src/pages/NetworkAvailability.test.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Home from './Home'; +import Network from './Network'; +import { fetchNetworkStats, fetchNodeHistory } from '../lib/api'; +import { coldNetworkStats, validNetworkStats } from '../testFixtures/networkStats'; +jest.mock('../lib/api', () => ({ fetchNetworkStats: jest.fn(), fetchNodeHistory: jest.fn() })); +jest.mock('../components/TrendChart', () => ({ historyData }) =>
History observations: {historyData.length}
); +jest.mock('../components/BreakdownChart', () => () =>
Breakdown
); +jest.mock('../components/NetworkGraphic', () => () =>
); +beforeEach(() => { + jest.clearAllMocks(); + fetchNodeHistory.mockResolvedValue([{ date: '2026-10-21', users: 3123 }]); +}); + +test.each([Home, Network])('cold live fixture does not present defaults as real metrics in %p', async (Page) => { + fetchNetworkStats.mockResolvedValue(coldNetworkStats); + render(); + await screen.findByText('History observations: 1'); + expect(screen.getByText(/Live network statistics are unavailable/)).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/Infinity|NaN|SB0|0\.00%/); + expect(screen.getAllByText('Unavailable').length).toBeGreaterThan(0); +}); + +test.each([Home, Network])('market outage does not hide valid node metrics in %p', async (Page) => { + const payload = validNetworkStats(); + payload.stats_meta.groups.market = { status: 'unavailable', updated_at: null }; + payload.stats.mn_stats.current_supply = null; + payload.stats.mn_stats.coins_percent_locked = null; + fetchNetworkStats.mockResolvedValue(payload); + render(); + await waitFor(() => expect(screen.getAllByText('3,000').length).toBeGreaterThan(0)); + expect(screen.getByText('Market statistics are unavailable.')).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/Infinity|NaN/); +}); + +test('stale source is visibly marked with its original successful timestamp', async () => { + fetchNetworkStats.mockResolvedValue(validNetworkStats(Date.now() - 300000)); + render(); + await screen.findByText(/Chain, Sentry node, Governance, Market statistics are stale\. Last successful update:/); + expect(screen.getAllByText('3,000').length).toBeGreaterThan(0); +}); + +test.each([Home, Network])('market-only availability retains circulating supply in %p', async (Page) => { + const payload = validNetworkStats(); + for (const name of ['chain', 'node', 'governance']) { + payload.stats_meta.groups[name] = { status: 'unavailable', updated_at: null }; + } + payload.stats.price_stats.price_usd = '0.00'; + payload.stats.price_stats.price_btc = '0.00000000'; + fetchNetworkStats.mockResolvedValue(payload); + render(); + await screen.findByText('800,000,000 SYS'); + expect(document.body.textContent).toContain('$0.0000'); + if (Page === Network) expect(screen.getByText('0.00000000 BTC')).toBeInTheDocument(); +}); + +test('valid core counts survive malformed optional chain display fields without a render crash', async () => { + const payload = validNetworkStats(); + payload.stats.blockchain_stats.sub_version = { unexpected: true }; + payload.stats.blockchain_stats.protocol = Infinity; + payload.stats.blockchain_stats.avg_block = { unexpected: true }; + payload.stats.blockchain_stats.genesis = { unexpected: true }; + payload.mapData = { USA: null, CAN: { masternodes: 'NaN' } }; + fetchNetworkStats.mockResolvedValue(payload); + render(); + await waitFor(() => expect(screen.getAllByText('3,000').length).toBeGreaterThan(0)); + expect(document.body.textContent).not.toMatch(/Infinity|NaN|\[object Object\]/); + expect(screen.getAllByText('Unavailable').length).toBeGreaterThanOrEqual(4); +}); + +test('technical version and protocol identifiers keep their ungrouped representation', async () => { + fetchNetworkStats.mockResolvedValue(validNetworkStats()); + render(); + await screen.findByText('320000'); + expect(screen.getByText('70016')).toBeInTheDocument(); + expect(screen.queryByText('320,000')).not.toBeInTheDocument(); + expect(screen.queryByText('70,016')).not.toBeInTheDocument(); +}); diff --git a/src/pages/NewProposal.js b/src/pages/NewProposal.js index 8ad4ef4..0a889d4 100644 --- a/src/pages/NewProposal.js +++ b/src/pages/NewProposal.js @@ -197,6 +197,7 @@ export default function NewProposal() { // risks a window that doesn't cover the first superblock or // accidentally fits an extra one. const [nextSuperblockSec, setNextSuperblockSec] = useState(null); + const [statsFreshUntil, setStatsFreshUntil] = useState(null); const [statsError, setStatsError] = useState(null); const [statsLoading, setStatsLoading] = useState(true); // Monotonic counter tracking the "latest issued /mnstats request". @@ -230,6 +231,10 @@ export default function NewProposal() { setNextSuperblockSec(null); return; } + const groups = stats.stats_meta && stats.stats_meta.groups; + setStatsFreshUntil(groups + ? Math.min(Number(groups.chain.updated_at), Number(groups.governance.updated_at)) + 120000 + : null); setNextSuperblockSec(anchor); }) .catch((err) => { @@ -288,7 +293,8 @@ export default function NewProposal() { // collapses to 1 ms and fires immediately). const nowSecForAnchor = Math.floor(Date.now() / 1000); const isAnchorLive = - Number.isFinite(nextSuperblockSec) && nextSuperblockSec > nowSecForAnchor; + Number.isFinite(nextSuperblockSec) && nextSuperblockSec > nowSecForAnchor && + (statsFreshUntil === null || Date.now() <= statsFreshUntil); const dirty = !formsEqual(form, baseline) && prepared == null; @@ -969,6 +975,11 @@ export default function NewProposal() { Couldn't load draft: {loadError.code || 'unknown_error'}
) : null} + {isAnchorLive && statsFreshUntil === null ? ( +

+ The server does not report a source freshness timestamp. Superblock timing is rechecked before preparation. +

+ ) : null} {currentStep === 'basics' ? ( { expect(screen.getByTestId('wizard-next')).toBeInTheDocument(); }); + test.each(['stale', 'unavailable'])('does not prepare from explicit %s statistics with a future anchor', async (status) => { + await renderWizard(); + await screen.findByTestId('wizard-panel-basics'); + validBasics(); + fireEvent.click(screen.getByTestId('wizard-next')); + await screen.findByTestId('window-preview'); + validPayment(); + fireEvent.click(screen.getByTestId('wizard-next')); + const now = Date.now(); + fetchNetworkStats.mockResolvedValueOnce({ + stats_meta: { groups: { chain: { status, updated_at: now }, + governance: { status, updated_at: now } } }, + stats: { superblock_stats: { superblock_next_epoch_sec: currentStableNextSb } }, + }); + await act(async () => fireEvent.click(screen.getByTestId('wizard-prepare'))); + expect(proposalService.prepare).not.toHaveBeenCalled(); + expect(screen.getByTestId('wizard-prepare')).toBeDisabled(); + expect(screen.getAllByRole('alert').some((element) => /could not confirm live superblock timing/i.test(element.textContent))).toBe(true); + }); + + test('legacy timing remains usable but explicitly lacks a source freshness timestamp', async () => { + await renderWizard(); + expect(await screen.findByText(/server does not report a source freshness timestamp/)).toBeInTheDocument(); + }); + test('Next stays on Basics and surfaces errors until Basics validates', async () => { await renderWizard(); await screen.findByTestId('wizard-panel-basics'); diff --git a/src/testFixtures/networkStats.js b/src/testFixtures/networkStats.js new file mode 100644 index 0000000..1d64190 --- /dev/null +++ b/src/testFixtures/networkStats.js @@ -0,0 +1,35 @@ +export const coldNetworkStats = { + stats: { + mn_stats: { total: '0', enabled: '0', pose_banned: '0', total_locked: '0.00', + coins_percent_locked: 'NaN%', current_supply: '0.00', collateral_req: '100,000', + roi: 'Infinity%', roi_one: 'Infinity%', roi_two: 'Infinity%', payout_frequency: '0ms' }, + price_stats: { price_usd: '0.0000', volume_usd: '0.00', market_cap_usd: '0.00' }, + blockchain_stats: { version: 0, connections: '0', genesis: 0, avg_block: '0ms' }, + superblock_stats: { superblock_date: 0, superblock_next_epoch_sec: 0, next_superblock: 'SB0 - 0' }, + income_stats: { usd: { daily: '$NaN' }, sys: { daily: 'Infinity SYS' } }, + }, + mapData: { DEU: { masternodes: 120 } }, +}; + +export function validNetworkStats(now = Date.now()) { + const income = { usd: { daily: '$1.00', monthly: '$30.00', yearly: '$365.00' }, + sys: { daily: '10 SYS', monthly: '300 SYS', yearly: '3,650 SYS' } }; + return { + stats_meta: { groups: Object.fromEntries(['chain', 'node', 'governance', 'market'].map((name) => + [name, { status: 'ready', updated_at: now }])) }, + stats: { + mn_stats: { total: '3,123', enabled: '3,000', pose_banned: '123', total_locked: '300,000,000', + coins_percent_locked: '37.50%', current_supply: '800,000,000', collateral_req: '100,000', + roi: '3.65%', roi_one: '4.56%', roi_two: '4.92%', payout_frequency: '2 days', + masternode_price_usd: '$10,000', masternode_price_btc: '0.1 BTC' }, + price_stats: { price_usd: '0.1', price_btc: '0.000001', volume_usd: '100000', + market_cap_usd: '80000000', circulating_supply: '800000000', price_change: '-1.25%' }, + blockchain_stats: { version: 320000, connections: '2,100,000', protocol: 70016, + sub_version: '/Syscoin/', genesis: '2018-01-01', avg_block: '2 minutes' }, + superblock_stats: { superblock_date: 'October 30th 2026, 1:00:00 pm', + superblock_next_epoch_sec: Math.floor(now / 1000) + 86400, next_superblock: 'SB 42' }, + income_stats: income, income_stats_seniority_one_year: income, income_stats_seniority_two_year: income, + }, + mapData: { DEU: { masternodes: 120 } }, + }; +} diff --git a/tests/browser/network-availability.spec.cjs b/tests/browser/network-availability.spec.cjs new file mode 100644 index 0000000..51db1c6 --- /dev/null +++ b/tests/browser/network-availability.spec.cjs @@ -0,0 +1,107 @@ +const { test, expect } = require('@playwright/test'); + +const cold = { + stats: { + mn_stats: { total: '0', enabled: '0', pose_banned: '0', total_locked: '0.00', + current_supply: '0.00', coins_percent_locked: 'NaN%', roi: 'Infinity%', + roi_one: 'Infinity%', roi_two: 'Infinity%' }, + price_stats: { price_usd: '0.0000', volume_usd: '0.00', market_cap_usd: '0.00' }, + blockchain_stats: { version: 0, connections: '0' }, + superblock_stats: { superblock_date: 0, superblock_next_epoch_sec: 0 }, + }, + mapData: {}, +}; + +for (const route of ['/', '/network']) { + for (const width of [320, 390]) { + test(`${route} keeps cold-start errors and history readable at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 640 }); + await page.route('**/auth/me', (request) => request.fulfill({ status: 401, json: { error: 'unauthorized' } })); + await page.route('**/mnstats', (request) => request.fulfill({ json: cold })); + await page.route('**/mncount', (request) => request.fulfill({ json: [ + { date: '2026-10-20', users: 3119 }, { date: '2026-10-21', users: 3123 }, + ] })); + await page.goto(route); + await expect(page.getByText('Live network statistics are unavailable right now.')).toBeVisible(); + await expect(page.locator('canvas')).toHaveCount(1); + await expect(page.locator('main')).not.toContainText(/Infinity|NaN|SB0/); + const layout = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + overflow: [...document.querySelectorAll('body *')].filter((element) => + element.getBoundingClientRect().right > innerWidth + 1).slice(0, 12).map((element) => + ({ tag: element.tagName, className: element.className })), + })); + expect(layout.width, JSON.stringify(layout.overflow)).toBeLessThanOrEqual(width + 1); + await page.screenshot({ path: testInfo.outputPath('stats-unavailable.png'), fullPage: true }); + }); + } +} + +test('an HTTP stats failure still shows the independent history chart', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 360, height: 640 }); + await page.route('**/auth/me', (request) => request.fulfill({ status: 401, json: { error: 'unauthorized' } })); + await page.route('**/mnstats', (request) => request.fulfill({ status: 503, json: { error: 'stats_unavailable' } })); + await page.route('**/mncount', (request) => request.fulfill({ json: [{ date: '2026-10-21', users: 3123 }] })); + await page.goto('/network'); + await expect(page.getByText('Live network statistics are unavailable right now.')).toBeVisible(); + await expect(page.locator('canvas')).toHaveCount(1); + await expect(page.locator('main')).not.toContainText(/Infinity|NaN/); + await page.screenshot({ path: testInfo.outputPath('stats-http-error.png'), fullPage: true }); +}); + +test('market failure keeps known Core metrics and null-derived values readable', async ({ page }, testInfo) => { + const now = Date.now(); + const payload = { + stats_meta: { groups: { + chain: { status: 'ready', updated_at: now }, + node: { status: 'ready', updated_at: now }, + governance: { status: 'ready', updated_at: now }, + market: { status: 'unavailable', updated_at: null }, + } }, + stats: { + mn_stats: { total: '3,123', enabled: '2,139', pose_banned: '984', total_locked: '312,300,000.00', + coins_percent_locked: null, current_supply: null, collateral_req: '100,000', roi: '5.20%', + roi_one: '7.02%', roi_two: '10.40%', payout_frequency: '3d 17h 7m 30s' }, + blockchain_stats: { version: 50102, sub_version: 'Syscoin:5.0.1.2', protocol: 70022, + connections: '2,329,762', genesis: '2018-01-01', avg_block: '2m 30s' }, + superblock_stats: { next_superblock: 'SB133 - 2330160', superblock_date: 'October 30th 2026, 1:00:00 pm', + superblock_next_epoch_sec: Math.floor(now / 1000) + 86400 }, + price_stats: { price_usd: null, volume_usd: null, market_cap_usd: null }, + income_stats: { usd: { daily: null }, sys: { daily: '14.25 SYS' } }, + }, mapData: {}, + }; + await page.setViewportSize({ width: 320, height: 640 }); + await page.route('**/auth/me', (request) => request.fulfill({ status: 401, json: { error: 'unauthorized' } })); + await page.route('**/mnstats', (request) => request.fulfill({ json: payload })); + await page.route('**/mncount', (request) => request.fulfill({ json: [{ date: '2026-10-21', users: 3123 }] })); + await page.goto('/network'); + await expect(page.getByText('Market statistics are unavailable.')).toBeVisible(); + await expect(page.locator('.summary-card').filter({ hasText: 'Enabled Nodes' }).getByText('2,139', { exact: true })).toBeVisible(); + await expect(page.getByText('14.25 SYS', { exact: true })).toBeVisible(); + await expect(page.locator('main')).not.toContainText(/Infinity|NaN|undefined/); + const overflow = await page.evaluate(() => [...document.querySelectorAll('body *')] + .filter((element) => element.getBoundingClientRect().right > innerWidth + 1) + .slice(0, 12).map((element) => ({ tag: element.tagName, className: element.className }))); + expect(await page.evaluate(() => document.documentElement.scrollWidth), JSON.stringify(overflow)).toBeLessThanOrEqual(321); + await page.screenshot({ path: testInfo.outputPath('market-unavailable.png'), fullPage: true }); +}); + +test('governance stats outage preserves proposals without false support at phone width', async ({ page }, testInfo) => { + const now = Math.floor(Date.now() / 1000); + await page.setViewportSize({ width: 320, height: 640 }); + await page.route('**/auth/me', (request) => request.fulfill({ status: 401, json: { error: 'unauthorized' } })); + await page.route('**/mnstats', (request) => request.fulfill({ json: cold })); + await page.route('**/govlist', (request) => request.fulfill({ json: [{ + Key: 'a'.repeat(64), Hash: 'b'.repeat(64), ObectType: 1, fBlockchainValidity: true, + fCachedValid: true, fCachedDelete: false, start_epoch: now - 86400, end_epoch: now + 86400 * 30, + AbsoluteYesCount: 18, YesCount: 20, NoCount: 2, payment_amount: '1000', + name: 'Example grant', title: 'Example grant', CreationTime: now - 86400, + }] })); + await page.goto('/governance'); + await expect(page.getByText('Support unavailable')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Passing', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Copy yes vote command' })).toBeEnabled(); + await expect(page.locator('main')).not.toContainText(/Not Enough Votes|0\.00%|Infinity|NaN/); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(321); + await page.screenshot({ path: testInfo.outputPath('governance-stats-unavailable.png'), fullPage: true }); +});