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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 `<Provider>` + `<PersistGate>` + `<Interceptor>`.

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`.
11 changes: 6 additions & 5 deletions src/components/ProgressRing/ProgressRing.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import classNames from 'classnames';
import { WithClassnameType } from 'types';

Expand All @@ -11,7 +12,7 @@ export interface ProgressRingType extends WithClassnameType {
children?: React.ReactNode;
}

export const ProgressRing = ({
const ProgressRingBase = ({
progress = 0,
size = 24,
trackWidth = 3,
Expand All @@ -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;
Expand Down Expand Up @@ -74,3 +73,5 @@ export const ProgressRing = ({
</div>
);
};

export const ProgressRing = memo(ProgressRingBase);
139 changes: 81 additions & 58 deletions src/hooks/fetch/useFetchEpochProgress.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<boolean>(true);
const [hasCallMade, setHasCallMade] = useState<boolean>(false);
const [epochRoundsLeft, setEpochRoundsLeft] = useState<number>(0);
const hasCallMadeRef = useRef<boolean>(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<number>(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;
}

Expand All @@ -59,7 +122,6 @@ export const useFetchEpochProgress = () => {
return;
}

setHasCallMade(true);
setEpochRoundsLeft((existingRound) => {
if (!existingRound) {
return roundsLeft;
Expand All @@ -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,
Expand Down
Loading