From 95f2d170beacd6893ee0f62bc6dc02e860167b40 Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Wed, 4 Feb 2026 14:20:36 +0200 Subject: [PATCH 1/4] show 0 Epoch if ready --- src/widgets/EpochProgressRing/EpochProgressRing.tsx | 6 +++--- src/widgets/HeroPills/EpochHeroPill.tsx | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/widgets/EpochProgressRing/EpochProgressRing.tsx b/src/widgets/EpochProgressRing/EpochProgressRing.tsx index a292fa9e2..c36dc1b3a 100644 --- a/src/widgets/EpochProgressRing/EpochProgressRing.tsx +++ b/src/widgets/EpochProgressRing/EpochProgressRing.tsx @@ -13,7 +13,7 @@ export const EpochProgressRing = ({ showTime = true, className }: EpochRingType) => { - const { epoch, epochPercentage, epochTimeRemaining, roundsLeft } = + const { epoch, epochPercentage, epochTimeRemaining, roundsLeft, isReady } = useFetchEpochProgress(); return ( @@ -22,13 +22,13 @@ export const EpochProgressRing = ({
Epoch
- {formatBigNumber({ value: epoch, showEllipsisIfZero: true })} + {formatBigNumber({ value: epoch, showEllipsisIfZero: !isReady })}
- {formatBigNumber({ value: roundsLeft, showEllipsisIfZero: true })}{' '} + {formatBigNumber({ value: roundsLeft, showEllipsisIfZero: !isReady })}{' '} Rounds Left
diff --git a/src/widgets/HeroPills/EpochHeroPill.tsx b/src/widgets/HeroPills/EpochHeroPill.tsx index 2b4aa8ad2..cb239d6fe 100644 --- a/src/widgets/HeroPills/EpochHeroPill.tsx +++ b/src/widgets/HeroPills/EpochHeroPill.tsx @@ -6,7 +6,7 @@ import { useFetchEpochProgress } from 'hooks'; import { WithClassnameType } from 'types'; export const EpochHeroPill = ({ className }: WithClassnameType) => { - const { epoch, epochPercentage, epochTimeRemaining, roundsLeft } = + const { epoch, epochPercentage, epochTimeRemaining, roundsLeft, isReady } = useFetchEpochProgress(); return ( @@ -18,10 +18,11 @@ export const EpochHeroPill = ({ className }: WithClassnameType) => { >
- Epoch {formatBigNumber({ value: epoch, showEllipsisIfZero: true })} + Epoch{' '} + {formatBigNumber({ value: epoch, showEllipsisIfZero: !isReady })}
- {formatBigNumber({ value: roundsLeft, showEllipsisIfZero: true })}{' '} + {formatBigNumber({ value: roundsLeft, showEllipsisIfZero: !isReady })}{' '} Rounds Left
From f4e0a459deb46d9281fbc9c6531ee03a9d2ce0d0 Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Thu, 5 Feb 2026 14:28:58 +0200 Subject: [PATCH 2/4] added lastExecutionResultHash and lastExecutionResultNonce --- .../BlockDetails/components/BlockData.tsx | 56 ++++++++++++++++--- src/types/block.types.ts | 2 + 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/pages/BlockDetails/components/BlockData.tsx b/src/pages/BlockDetails/components/BlockData.tsx index 36d5f8f79..2ee53dc97 100644 --- a/src/pages/BlockDetails/components/BlockData.tsx +++ b/src/pages/BlockDetails/components/BlockData.tsx @@ -17,7 +17,12 @@ import { } from 'components'; import { formatDate, formatSize, urlBuilder } from 'helpers'; import { useIsSovereign } from 'hooks'; -import { faChevronLeft, faChevronRight, faClock } from 'icons/regular'; +import { + faChevronLeft, + faChevronRight, + faClock, + faArrowUpRightFromSquare +} from 'icons/regular'; import { UIBlockType } from 'types'; function decodeHex(hex: string) { @@ -258,17 +263,54 @@ export const BlockData = ({ block }: { block: UIBlockType }) => { {isFirstBlock ? ( N/A ) : block.prevHash ? ( - - - + <> + + + + + + + + ) : ( N/A )} + {block.lastExecutionResultHash && ( + +
+ + + + + + + +
+
+ )} + {block.lastExecutionResultNonce && ( + + {block.lastExecutionResultNonce} + + )} {block.pubKeyBitmap ? ( diff --git a/src/types/block.types.ts b/src/types/block.types.ts index 90a7920b2..79aae8a61 100644 --- a/src/types/block.types.ts +++ b/src/types/block.types.ts @@ -25,6 +25,8 @@ export interface BlockType { maxGasLimit: number; proposerIdentity?: IdentityType; reserved?: string; + lastExecutionResultHash?: string; + lastExecutionResultNonce?: number; } export interface UIBlockType extends BlockType { From 5b9a8d35549abf05dd9f4c33de0017a50aea2ccd Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Mon, 2 Mar 2026 18:01:15 +0200 Subject: [PATCH 3/4] useFetchEpochProgress - 600ms updates, avoid cached api/websocket updates/ avoid animation glitches on network/refreshRate changes BlockHeightStatsCard - avoid cache issues, blockHeight can only increase EpochProgressRing/EpochHeroPill - use plurals on rounds left --- src/components/ProgressRing/ProgressRing.tsx | 11 +- src/hooks/fetch/useFetchEpochProgress.ts | 139 ++++++++++-------- .../BlockHeightStatsCard.tsx | 26 ++-- .../EpochProgressRing/EpochProgressRing.tsx | 7 +- src/widgets/HeroPills/EpochHeroPill.tsx | 7 +- 5 files changed, 114 insertions(+), 76 deletions(-) diff --git a/src/components/ProgressRing/ProgressRing.tsx b/src/components/ProgressRing/ProgressRing.tsx index dabe9b024..ee325eb46 100644 --- a/src/components/ProgressRing/ProgressRing.tsx +++ b/src/components/ProgressRing/ProgressRing.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from 'react'; import classNames from 'classnames'; import { WithClassnameType } from 'types'; @@ -11,7 +12,7 @@ export interface ProgressRingType extends WithClassnameType { children?: React.ReactNode; } -export const ProgressRing = ({ +const ProgressRingBase = ({ progress = 0, size = 24, trackWidth = 3, @@ -22,10 +23,8 @@ export const ProgressRing = ({ className }: ProgressRingType) => { const center = size / 2; - const radius = - center - (trackWidth > indicatorWidth ? trackWidth : indicatorWidth); - - const dashArray = 2 * Math.PI * radius; + const radius = center - Math.max(trackWidth, indicatorWidth); + const dashArray = useMemo(() => 2 * Math.PI * radius, [radius]); const dashOffset = dashArray * ((100 - progress) / 100); const showLabel = size > 80 && children; @@ -74,3 +73,5 @@ export const ProgressRing = ({ ); }; + +export const ProgressRing = memo(ProgressRingBase); diff --git a/src/hooks/fetch/useFetchEpochProgress.ts b/src/hooks/fetch/useFetchEpochProgress.ts index 88439935a..f654926e5 100644 --- a/src/hooks/fetch/useFetchEpochProgress.ts +++ b/src/hooks/fetch/useFetchEpochProgress.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import BigNumber from 'bignumber.js'; import { useSelector } from 'react-redux'; @@ -21,34 +21,97 @@ export const useFetchEpochProgress = () => { const { epochPercentage, epochTimeRemaining } = stats; const { epoch, refreshRate, roundsPerEpoch, roundsPassed } = unprocessed; - const [oldTestnetId, setOldTestnetId] = useState(activeNetworkId); - const [isNewState, setIsNewState] = useState(true); - const [hasCallMade, setHasCallMade] = useState(false); - const [epochRoundsLeft, setEpochRoundsLeft] = useState(0); + const hasCallMadeRef = useRef(false); - const refreshInterval = + const rawRefreshInterval = refreshRate || initialNetworkRefreshRate || REFRESH_RATE; - const refreshIntervalSec = new BigNumber(refreshInterval).dividedBy(1000); - const stepInterval = getProgressStepInterval(refreshInterval); - const stepProgressSec = stepInterval.dividedBy(1000); + const [epochRoundsLeft, setEpochRoundsLeft] = useState(0); + const [effectiveRefreshInterval, setEffectiveRefreshInterval] = + useState(rawRefreshInterval); + + const refreshIntervalSec = useMemo( + () => new BigNumber(effectiveRefreshInterval).dividedBy(1000), + [effectiveRefreshInterval] + ); + + const stepInterval = useMemo( + () => getProgressStepInterval(effectiveRefreshInterval), + [effectiveRefreshInterval] + ); + + const stepProgressSec = useMemo( + () => stepInterval.dividedBy(1000), + [stepInterval] + ); const [roundTimeProgress, setRoundTimeProgress] = useState( new BigNumber(stepProgressSec) ); - const updateStats = () => { - if (!refreshInterval) { + const roundProgress = useMemo( + () => roundTimeProgress.times(100).dividedBy(refreshIntervalSec), + [roundTimeProgress, refreshIntervalSec] + ); + + const roundsLeft = useMemo(() => { + if (epochRoundsLeft) { + return epochRoundsLeft; + } + + // add one in order to take into account the css animation and the api call sync on the first run + return new BigNumber(roundsPerEpoch).minus(roundsPassed).plus(1).toNumber(); + }, [epochRoundsLeft, roundsPerEpoch, roundsPassed]); + + useEffect(() => { + if (!rawRefreshInterval) { return; } - setIsNewState(oldTestnetId !== activeNetworkId); - if (isNewState) { - startRoundTime(); + setEffectiveRefreshInterval((prev) => + rawRefreshInterval < prev ? rawRefreshInterval : prev + ); + }, [rawRefreshInterval]); + + // Reset on network change + useEffect(() => { + setEffectiveRefreshInterval(rawRefreshInterval); + setRoundTimeProgress(new BigNumber(stepProgressSec)); + hasCallMadeRef.current = false; + setEpochRoundsLeft(0); + }, [activeNetworkId]); + + useEffect(() => { + if (!effectiveRefreshInterval) { + return; + } + + const intervalRoundTime = setInterval(() => { + if (!document.hidden) { + setRoundTimeProgress((prev) => + prev.isGreaterThanOrEqualTo(refreshIntervalSec) + ? new BigNumber(stepProgressSec) + : prev.plus(stepProgressSec) + ); + } + }, stepInterval.toNumber()); + + return () => clearInterval(intervalRoundTime); + }, [effectiveRefreshInterval]); + + useEffect(() => { + if (!effectiveRefreshInterval || !roundTimeProgress || !timestamp) { + return; } - if (roundTimeProgress.isEqualTo(refreshIntervalSec) && !hasCallMade) { + if ( + roundTimeProgress.isGreaterThanOrEqualTo(refreshIntervalSec) && + !hasCallMadeRef.current + ) { + hasCallMadeRef.current = true; + fetchStats().then(({ success }) => { if (!success) { + hasCallMadeRef.current = false; return; } @@ -59,7 +122,6 @@ export const useFetchEpochProgress = () => { return; } - setHasCallMade(true); setEpochRoundsLeft((existingRound) => { if (!existingRound) { return roundsLeft; @@ -75,49 +137,10 @@ export const useFetchEpochProgress = () => { return existingRound; }); }); - } else { - setHasCallMade(false); - } - }; - - const startRoundTime = () => { - if (!refreshInterval) { - return; - } - const intervalRoundTime = setInterval(() => { - if (!document.hidden) { - setRoundTimeProgress((roundTimeProgress) => - roundTimeProgress.isEqualTo(refreshIntervalSec) - ? new BigNumber(stepProgressSec) - : roundTimeProgress.plus(stepProgressSec) - ); - } - }, stepInterval.toNumber()); - return () => clearInterval(intervalRoundTime); - }; - - useEffect(() => { - setOldTestnetId(activeNetworkId); - }, [activeNetworkId]); - - useEffect(() => { - if (refreshInterval && roundTimeProgress && timestamp) { - updateStats(); + } else if (roundTimeProgress.isLessThan(refreshIntervalSec)) { + hasCallMadeRef.current = false; } - }, [timestamp, roundTimeProgress, refreshInterval]); - - const roundProgress = roundTimeProgress - .times(100) - .dividedBy(refreshIntervalSec ?? 1); - - const roundsLeft = useMemo(() => { - if (epochRoundsLeft) { - return epochRoundsLeft; - } - - // add one in order to take into account the css animation and the api call sync on the first run - return new BigNumber(roundsPerEpoch).minus(roundsPassed).plus(1).toNumber(); - }, [epochRoundsLeft, roundsPerEpoch, roundsPassed]); + }, [timestamp, roundTimeProgress]); return { isReady: isDataReady, diff --git a/src/widgets/BlockHeightStatsCard/BlockHeightStatsCard.tsx b/src/widgets/BlockHeightStatsCard/BlockHeightStatsCard.tsx index 4816ffb3c..389e95736 100644 --- a/src/widgets/BlockHeightStatsCard/BlockHeightStatsCard.tsx +++ b/src/widgets/BlockHeightStatsCard/BlockHeightStatsCard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import BigNumber from 'bignumber.js'; import { useSelector } from 'react-redux'; @@ -14,18 +14,26 @@ export const BlockHeightStatsCard = () => { const { blockHeight } = useSelector(pageHeadersBlocksStatsSelector); const { blocks: statsBlocks } = unprocessed; + const higherRef = useRef(0); + const displayValue = useMemo(() => { - const bNBlocks = new BigNumber(unprocessed?.blocks ?? 0); - if (bNBlocks.isInteger() && bNBlocks.isGreaterThan(0)) { - return bNBlocks.toNumber(); - } + const bNBlocks = new BigNumber(statsBlocks ?? 0); + const bNToolsBlocks = new BigNumber( + blockHeight ? String(blockHeight).replaceAll(',', '') : 0 + ); - const bNToolsBlocks = new BigNumber(blockHeight ?? 0); - if (bNToolsBlocks.isInteger() && bNToolsBlocks.isGreaterThan(0)) { - return bNToolsBlocks.toNumber(); + const highest = bNBlocks.isGreaterThan(bNToolsBlocks) + ? bNBlocks + : bNToolsBlocks; + + if (highest.isInteger() && highest.isGreaterThan(0)) { + const num = highest.toNumber(); + if (num > higherRef.current) { + higherRef.current = num; + } } - return ELLIPSIS; + return higherRef.current > 0 ? higherRef.current : ELLIPSIS; }, [blockHeight, statsBlocks]); const isAnimated = Boolean( diff --git a/src/widgets/EpochProgressRing/EpochProgressRing.tsx b/src/widgets/EpochProgressRing/EpochProgressRing.tsx index c36dc1b3a..6895e69be 100644 --- a/src/widgets/EpochProgressRing/EpochProgressRing.tsx +++ b/src/widgets/EpochProgressRing/EpochProgressRing.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { ProgressRing } from 'components'; -import { formatBigNumber } from 'helpers'; +import { formatBigNumber, getStringPlural } from 'helpers'; import { useFetchEpochProgress } from 'hooks'; import { WithClassnameType } from 'types'; @@ -29,7 +29,10 @@ export const EpochProgressRing = ({ {...(showTime ? { title: epochTimeRemaining } : {})} > {formatBigNumber({ value: roundsLeft, showEllipsisIfZero: !isReady })}{' '} - Rounds Left + {getStringPlural(roundsLeft, { + string: 'Round' + })}{' '} + Left diff --git a/src/widgets/HeroPills/EpochHeroPill.tsx b/src/widgets/HeroPills/EpochHeroPill.tsx index cb239d6fe..c6cda899f 100644 --- a/src/widgets/HeroPills/EpochHeroPill.tsx +++ b/src/widgets/HeroPills/EpochHeroPill.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { ProgressRing } from 'components'; -import { formatBigNumber } from 'helpers'; +import { formatBigNumber, getStringPlural } from 'helpers'; import { useFetchEpochProgress } from 'hooks'; import { WithClassnameType } from 'types'; @@ -23,7 +23,10 @@ export const EpochHeroPill = ({ className }: WithClassnameType) => {
{formatBigNumber({ value: roundsLeft, showEllipsisIfZero: !isReady })}{' '} - Rounds Left + {getStringPlural(roundsLeft, { + string: 'Round' + })}{' '} + Left
From 1e294b6ef16d4ad8e0ca0114071cf68a15f5d47c Mon Sep 17 00:00:00 2001 From: Radu Mojic Date: Mon, 2 Mar 2026 18:08:08 +0200 Subject: [PATCH 4/4] base AGENTS.md file with instructions --- AGENTS.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..4a16412b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# AGENTS.md + +This file provides guidance to AI Agents when working with code in this repository. + +--- + +## Project + +MultiversX Blockchain Explorer — a React + Redux Toolkit SPA for browsing the MultiversX Network (mainnet, testnet, devnet). Built with Vite, deployed at `explorer.multiversx.com`. + +--- + +## Commands + +```bash +yarn # install dependencies (uses yarn, not npm/pnpm) + +# Dev — must pick a network config before starting: +cp src/config/config.devnet.ts src/config/index.ts +npm run start-devnet # copies config + starts dev server → https://localhost:3002 + +npm run start-mainnet # mainnet +npm run start-testnet # testnet + +# Build +npm run build-devnet +npm run build-mainnet + +# Lint (zero warnings policy) +npm run lint # eslint src --max-warnings 0 + +# E2E tests (Cypress, runs against integration-explorer.multiversx.com) +node scripts/cypress.ts # or: npm run cy:run +``` + +`src/config/index.ts` **must exist** before starting. The `start-*` scripts create it automatically via the `copy-*-config` step, but if you run `npm run start` directly you need it manually. + +HTTPS is enabled by default (self-signed cert via `@vitejs/plugin-basic-ssl`). Set `VITE_APP_USE_HTTPS=false` to disable. + +--- + +## Architecture + +### Entry & Routing + +`src/index.tsx` → `App.tsx` wraps the app in Redux `` + `` + ``. + +Routes are defined in `src/routes/routes.tsx` using React Router v6 `createBrowserRouter`. Every network has its routes prefixed with `/:network/` (e.g. `/devnet/blocks/...`). `generateNetworkRoutes` in `src/routes/helpers/` iterates `networks` from config and wraps routes per network. The `Layout` component (`src/layouts/Layout/`) is the shell that renders the header, hero stats widgets, and footer around page content. + +### Network Configuration + +`src/config/index.ts` exports `networks: NetworkType[]` — an array of network definitions (id, apiAddress, chainId, adapter, websocket URL, etc.). The active network is stored in Redux (`networksSlice`) and selected via `activeNetworkSelector`. Switching networks dispatches `changeNetwork`. + +### Data Fetching — Adapter Pattern + +`useAdapter()` (`src/hooks/adapter/useAdapter.ts`) is the single entry point for all API calls. It aggregates domain-specific request hooks (blocks, transactions, accounts, tokens, validators, etc.) and exposes them as a flat object. Internally `useAdapterConfig` reads the active network's `adapter` field (`'api'` or `'elastic'`) and routes calls through the corresponding provider. + +All API responses are wrapped by `useAdapterConfig`'s `wrap()` function and return `{ data, success: boolean }` — never throw. Always check `success` before using `data`. + +### Polling Loop + +`useLoopManager` (`src/hooks/layout/`) runs a `setInterval` that dispatches `triggerRefresh()` on the Redux `refreshSlice`. This updates `timestamp` in the store. Page-level hooks react to `timestamp` from `refreshSelector` to re-fetch data. The poll interval adapts to the API's `refreshRate` (from stats) — typically 6000ms, drops to 600ms after Supernova. + +### Websocket (Optional) + +When the network config includes `updatesWebsocketUrl`, a Socket.IO connection is established via `useInitWebsocket`. Components that want real-time updates call `useRegisterWebsocketListener` with a subscription name and event handler. Stats updates are the primary websocket consumer — when active, the polling loop defers to websocket events instead. + +### Redux Store + +`src/redux/store.ts` configures Redux Toolkit + `redux-persist` (localStorage). The root reducer is in `src/redux/reducers.ts`. All slices in `customIgnoredSlices` are excluded from persistence (they refetch on load). Selectors use `reselect` and live in `src/redux/selectors/`. + +Key slices: + +- `statsSlice` — network stats with `unprocessed` (raw numbers) and `stats` (formatted strings for display) +- `networksSlice` — `activeNetwork` and `defaultNetwork` +- `refreshSlice` — `timestamp` used as the global polling trigger +- Page-specific data slices: `blocks`, `transactions`, `account`, `token`, `nft`, etc. + +### Stats Data Pattern + +`StatsType` → `ExtendedStatsType` (adds epoch timing) → `ProcessedStatsType` (formatted strings). The `setStats` reducer runs `getExtraStats()` and `processStats()` on every stats update and stores both raw (`unprocessed`) and display-ready (`stats`) values. Always read from `unprocessed` for calculations and from `stats` for display. + +### Components vs Widgets + +- `src/components/` — reusable presentational components (tables, badges, format utilities, etc.) +- `src/widgets/` — higher-level stateful widgets that wire up Redux and hooks (e.g. `EpochProgressRing`, `BlockHeightStatsCard`, `HeroHome`) +- `src/layouts/` — page-shell layouts (account layout, collection layout, etc.) +- `src/pages/` — route-level page components + +### Helpers + +`src/helpers/` contains pure utility functions organized by category: + +- `formatValue/` — number/token/duration formatting +- `getValue/` — computed value helpers (e.g. `getProgressStepInterval`) +- `processData/` — data transformation (`processStats`, `getExtraStats`) +- `isCondition/`, `hasCondition/` — predicate helpers + +### Icons + +Free FontAwesome icons are used by default (`npm run prepare-free-icons`, run automatically by `yarn` via `prepare`). Pro icons require a FontAwesome npm token. Icon sets live in `src/icons/` with a generated `index.ts` per variant. + +--- + +## Key Conventions + +**`ELLIPSIS` constant** — `'...'` used as loading placeholder before data arrives. Components check `isDataReady` or pass `showEllipsisIfZero` to format helpers. + +**Named exports only** — All components and hooks use named arrow function exports (`export const Foo = () => ...`). No default exports. + +**`useAdapter()` is always the API call point** — Never call `axios` or fetch directly in components or hooks. Go through `useAdapter()`. + +**BigNumber.js** — All numeric calculations use `BigNumber` from `bignumber.js`. Never use plain JS arithmetic on chain values. Use `.toNumber()` / `.toFormat()` only for final display. + +**Import order** — ESLint enforces: React → external packages → internal (alphabetized). The `import/order` rule is set to `warn`.