diff --git a/bridge/reporting/__tests__/components/ReportTable.test.jsx b/bridge/reporting/__tests__/components/ReportTable.test.jsx
new file mode 100644
index 0000000000..766bcf6ca5
--- /dev/null
+++ b/bridge/reporting/__tests__/components/ReportTable.test.jsx
@@ -0,0 +1,223 @@
+import ReportTable from '@/components/ReportTable';
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+
+const REQUEST_TAB = {
+ resource: 'requests',
+ sourceAsset: { ticker: 'XYM', divisibility: 6 },
+ destinationAsset: { ticker: 'WXYM', divisibility: 6 },
+ sourceNetwork: 'nativeNetwork',
+ destinationNetwork: 'wrappedNetwork'
+};
+
+const ERROR_TAB = {
+ ...REQUEST_TAB,
+ resource: 'errors'
+};
+
+const CONFIGURATION = {
+ nativeNetwork: {
+ blockchain: 'symbol',
+ explorerUrl: 'https://symbol.example'
+ },
+ wrappedNetwork: {
+ blockchain: 'ethereum',
+ explorerUrl: 'https://ethereum.example'
+ }
+};
+
+const REQUEST_ROW = {
+ destinationAddress: '0x1f533cd9711049fA7604D0F49C45B6e5Af30ef8e',
+ errorMessage: null,
+ payoutConversionRate: '1000000',
+ payoutNetAmount: '299642570825',
+ payoutSentTimestamp: 4,
+ payoutStatus: 2,
+ payoutTimestamp: 3,
+ payoutTotalFee: '357429175',
+ payoutTransactionHash: 'A'.repeat(64),
+ payoutTransactionHeight: '11',
+ requestAmount: '300000000000',
+ requestTimestamp: 2,
+ requestTransactionHash: 'B'.repeat(64),
+ requestTransactionHeight: '10',
+ requestTransactionSubindex: -1,
+ senderAddress: 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I'
+};
+
+const ERROR_ROW = {
+ errorMessage: 'Required message is missing',
+ requestTimestamp: 5,
+ requestTransactionHash: 'C'.repeat(64),
+ requestTransactionHeight: '13',
+ requestTransactionSubindex: -1,
+ senderAddress: 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY'
+};
+
+const renderTable = ({
+ configuration = CONFIGURATION,
+ onSortChange = jest.fn(),
+ rows = [REQUEST_ROW],
+ sort = 0,
+ tab = REQUEST_TAB
+} = {}) => {
+ const reportTable = (
+
+ );
+ const result = render(reportTable);
+
+ return {
+ ...result,
+ onSortChange,
+ table: screen.getByRole('table'),
+ mobileCard: screen.getByRole('article')
+ };
+};
+
+describe('ReportTable', () => {
+ it('renders and formats request report fields', () => {
+ // Arrange:
+ const { table } = renderTable();
+
+ // Act:
+ const tableView = within(table);
+
+ // Assert:
+ expect(tableView.getByText('Completed')).toBeInTheDocument();
+ expect(tableView.getByText('TCONKG47F…JMYM4I')).toHaveAttribute('title', 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I');
+ expect(tableView.getByText('AAAAAAAA…AAAAAA')).toHaveAttribute('title', 'A'.repeat(64));
+ expect(tableView.getByText('300000')).toBeInTheDocument();
+ expect(tableView.getByText('0x1f533cd…30ef8e')).toHaveAttribute('title', '0x1f533cd9711049fA7604D0F49C45B6e5Af30ef8e');
+ expect(tableView.getByText('BBBBBBBB…BBBBBB')).toHaveAttribute('title', 'B'.repeat(64));
+ expect(tableView.getByText('1')).toHaveAttribute('title', '1000000 PPM');
+ expect(tableView.getByText('357.429175')).toBeInTheDocument();
+ expect(tableView.getByText('299642.570825')).toBeInTheDocument();
+ expect(tableView.getByText('1970-01-01 00:00:02 UTC')).toBeInTheDocument();
+ expect(tableView.getByText('1970-01-01 00:00:03 UTC')).toBeInTheDocument();
+ });
+
+ it('renders the active sort direction and handles sort changes', () => {
+ // Arrange:
+ const onSortChange = jest.fn();
+ const { table } = renderTable({ onSortChange });
+ const sortButton = within(table).getByRole('button', {
+ name: 'Sort by request block height ascending'
+ });
+
+ // Act:
+ fireEvent.click(sortButton);
+
+ // Assert:
+ expect(onSortChange).toHaveBeenCalledTimes(1);
+ expect(sortButton.closest('th')).toHaveAttribute('aria-sort', 'descending');
+ });
+
+ const runDescendingSortTest = params => {
+ // Arrange:
+ const { table } = renderTable(params);
+
+ // Act:
+ const sortButton = within(table).getByRole('button', {
+ name: 'Sort by request block height descending'
+ });
+
+ // Assert:
+ expect(sortButton).toHaveTextContent('↑');
+ expect(sortButton.closest('th')).toHaveAttribute('aria-sort', 'ascending');
+ };
+
+ it('renders the descending sort direction for request reports', () => {
+ runDescendingSortTest({ sort: 1 });
+ });
+
+ it('renders the descending sort direction for error reports', () => {
+ runDescendingSortTest({
+ rows: [ERROR_ROW],
+ sort: 1,
+ tab: ERROR_TAB
+ });
+ });
+
+ it('renders and formats errors report fields', () => {
+ // Arrange:
+ const { table } = renderTable({
+ rows: [ERROR_ROW],
+ tab: ERROR_TAB
+ });
+
+ // Act:
+ const tableView = within(table);
+
+ // Assert:
+ expect(tableView.getByText('TARDV42KT…IXVJQY')).toHaveAttribute('title', 'TARDV42KTAIZEF64EQT4NXT7K55DHWBEFIXVJQY');
+ expect(tableView.getByText('CCCCCCCC…CCCCCC')).toHaveAttribute('title', 'C'.repeat(64));
+ expect(tableView.getByText('Required message is missing')).toBeInTheDocument();
+ });
+
+ it('renders and formats request mobile card fields', () => {
+ // Arrange:
+ const { mobileCard } = renderTable();
+
+ // Act:
+ const cardView = within(mobileCard);
+
+ // Assert:
+ expect(cardView.getByText('#10')).toBeInTheDocument();
+ expect(cardView.getByText('Completed')).toBeInTheDocument();
+ expect(cardView.getByText('Sender')).toBeInTheDocument();
+ expect(cardView.getByText('TCONKG47F…JMYM4I')).toHaveAttribute('title', REQUEST_ROW.senderAddress);
+ expect(cardView.getByText('Request')).toBeInTheDocument();
+ expect(cardView.getByText('BBBBBBBB…BBBBBB')).toHaveAttribute('title', REQUEST_ROW.requestTransactionHash);
+ expect(cardView.getByText('300000')).toBeInTheDocument();
+ expect(cardView.getByText('Payout Address')).toBeInTheDocument();
+ expect(cardView.getByText('0x1f533cd…30ef8e')).toHaveAttribute('title', REQUEST_ROW.destinationAddress);
+ expect(cardView.getByText('AAAAAAAA…AAAAAA')).toHaveAttribute('title', REQUEST_ROW.payoutTransactionHash);
+ expect(cardView.getByText('1')).toHaveAttribute('title', '1000000 PPM');
+ expect(cardView.getByText('357.429175')).toBeInTheDocument();
+ expect(cardView.getByText('299642.570825')).toBeInTheDocument();
+ });
+
+ it('renders and formats errors mobile card fields', () => {
+ // Arrange:
+ const { mobileCard } = renderTable({
+ rows: [ERROR_ROW],
+ tab: ERROR_TAB
+ });
+
+ // Act:
+ const cardView = within(mobileCard);
+
+ // Assert:
+ expect(cardView.getByText('#13')).toBeInTheDocument();
+ expect(cardView.getByText('Error')).toBeInTheDocument();
+ expect(cardView.getByText('Sender')).toBeInTheDocument();
+ expect(cardView.getByText('TARDV42KT…IXVJQY')).toHaveAttribute('title', ERROR_ROW.senderAddress);
+ expect(cardView.getByText('Request')).toBeInTheDocument();
+ expect(cardView.getByText('CCCCCCCC…CCCCCC')).toHaveAttribute('title', ERROR_ROW.requestTransactionHash);
+ expect(cardView.getByText('1970-01-01 00:00:05 UTC')).toBeInTheDocument();
+ expect(cardView.getByText('Required message is missing')).toBeInTheDocument();
+ });
+
+ it('renders a placeholder when an error message is unavailable', () => {
+ // Arrange:
+ const row = {
+ ...ERROR_ROW,
+ errorMessage: null
+ };
+
+ // Act:
+ renderTable({
+ rows: [row],
+ tab: ERROR_TAB
+ });
+
+ // Assert:
+ expect(screen.getAllByText('—')).toHaveLength(2);
+ });
+});
diff --git a/bridge/reporting/__tests__/components/ReportTableFields.test.jsx b/bridge/reporting/__tests__/components/ReportTableFields.test.jsx
new file mode 100644
index 0000000000..527eab0eab
--- /dev/null
+++ b/bridge/reporting/__tests__/components/ReportTableFields.test.jsx
@@ -0,0 +1,143 @@
+import {
+ AmountValue,
+ ExternalValue,
+ RateValue,
+ StatusBadge,
+ TransactionValue
+} from '@/components/ReportTableFields';
+import '@testing-library/jest-dom';
+import { render, screen } from '@testing-library/react';
+
+const SYMBOL_NETWORK = {
+ blockchain: 'symbol',
+ explorerUrl: 'https://symbol.example'
+};
+
+describe('StatusBadge', () => {
+ it('renders the configured payout status', () => {
+ // Act:
+ render();
+
+ // Assert:
+ expect(screen.getByText('Completed')).toBeInTheDocument();
+ });
+
+ it('renders error details through an accessible tooltip', () => {
+ // Act:
+ render();
+ const failedStatus = screen.getByLabelText('Failed: Payout rejected');
+
+ // Assert:
+ expect(failedStatus).toHaveTextContent('Failed');
+ expect(failedStatus).toHaveAttribute('data-tooltip', 'Payout rejected');
+ expect(failedStatus).toHaveAttribute('tabindex', '0');
+ });
+
+ it('renders an unknown label for an unconfigured payout status', () => {
+ // Act:
+ render();
+
+ // Assert:
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
+ });
+});
+
+describe('ExternalValue', () => {
+ it('renders a truncated value linked to the configured explorer', () => {
+ // Arrange:
+ const value = 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I';
+
+ // Act:
+ render();
+ const link = screen.getByRole('link');
+
+ // Assert:
+ expect(link).toHaveTextContent('TCONKG47F…JMYM4I');
+ expect(link).toHaveAttribute('href', `https://symbol.example/accounts/${value}`);
+ expect(link).toHaveAttribute('title', value);
+ expect(link).toHaveAttribute('rel', 'noreferrer');
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+
+ it('renders a value without a link when an explorer URL is unavailable', () => {
+ // Arrange:
+ const value = 'TCONKG47FW2ZEZBPV6G7F422LXBDSMVT3JMYM4I';
+
+ // Act:
+ render();
+ const externalValue = screen.getByTitle(value);
+
+ // Assert:
+ expect(externalValue).toHaveTextContent('TCONKG47F…JMYM4I');
+ expect(externalValue).not.toHaveAttribute('href');
+ });
+
+ it('renders a placeholder and empty title when the value is unavailable', () => {
+ // Act:
+ render();
+ const placeholder = screen.getByText('—');
+
+ // Assert:
+ expect(placeholder).toHaveAttribute('title', '');
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+});
+
+describe('TransactionValue', () => {
+ it('renders the formatted timestamp and linked transaction hash', () => {
+ // Arrange:
+ const hash = 'A'.repeat(64);
+
+ // Act:
+ render();
+ const link = screen.getByRole('link');
+
+ // Assert:
+ expect(screen.getByText('1970-01-01 00:00:02 UTC')).toBeInTheDocument();
+ expect(link).toHaveTextContent('AAAAAAAA…AAAAAA');
+ expect(link).toHaveAttribute('href', `https://symbol.example/transactions/${hash}`);
+ expect(link).toHaveAttribute('title', hash);
+ });
+});
+
+describe('AmountValue', () => {
+ const asset = { ticker: 'XYM', divisibility: 6 };
+
+ it('renders a formatted amount with its asset ticker and raw value', () => {
+ // Act:
+ render();
+ const amount = screen.getByTitle('300000000000');
+
+ // Assert:
+ expect(amount).toHaveTextContent('300000');
+ expect(amount).toHaveTextContent('XYM');
+ });
+
+ it.each([null, undefined])('renders a placeholder and empty title when the value is %s', value => {
+ // Act:
+ render();
+ const placeholder = screen.getByText('—');
+
+ // Assert:
+ expect(placeholder.parentElement).toHaveAttribute('title', '');
+ expect(screen.queryByText('XYM')).not.toBeInTheDocument();
+ });
+});
+
+describe('RateValue', () => {
+ it('renders a formatted rate with its raw PPM value', () => {
+ // Act:
+ render();
+
+ // Assert:
+ expect(screen.getByText('1')).toHaveAttribute('title', '1000000 PPM');
+ });
+
+ it.each([null, undefined])('renders a placeholder and empty title when the value is %s', value => {
+ // Act:
+ render();
+
+ // Assert:
+ expect(screen.getByText('—')).toHaveAttribute('title', '');
+ });
+});
diff --git a/bridge/reporting/__tests__/components/SortHeader.test.jsx b/bridge/reporting/__tests__/components/SortHeader.test.jsx
new file mode 100644
index 0000000000..844fcfcbc7
--- /dev/null
+++ b/bridge/reporting/__tests__/components/SortHeader.test.jsx
@@ -0,0 +1,26 @@
+import SortHeader from '@/components/SortHeader';
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+describe('SortHeader', () => {
+ it.each([
+ [0, 'ascending', '↓'],
+ [1, 'descending', '↑']
+ ])('renders sort value %s as %s and handles a sort change', (sort, direction, indicator) => {
+ // Arrange:
+ const onSortChange = jest.fn();
+ render();
+ const button = screen.getByRole('button', {
+ name: `Sort by request block height ${direction}`
+ });
+
+ // Act:
+ fireEvent.click(button);
+
+ // Assert:
+ expect(button).toHaveTextContent('Request hash');
+ expect(button).toHaveTextContent(indicator);
+ expect(button).toHaveAttribute('title', 'Sorted by request block height');
+ expect(onSortChange).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/bridge/reporting/__tests__/utils/format.test.js b/bridge/reporting/__tests__/utils/format.test.js
new file mode 100644
index 0000000000..34b1d69032
--- /dev/null
+++ b/bridge/reporting/__tests__/utils/format.test.js
@@ -0,0 +1,86 @@
+import { createExplorerUrl, formatAtomicAmount, formatPpm, formatTimestamp } from '@/utils/format';
+
+describe('report formatting', () => {
+ it('formats atomic values without losing integer precision', () => {
+ // Arrange:
+ const inputs = [
+ ['100000000', 6],
+ ['151458904500184', 18],
+ ['999999999999999999999999', 18]
+ ];
+
+ // Act:
+ const formattedAmounts = inputs.map(([value, divisibility]) => formatAtomicAmount(value, divisibility));
+
+ // Assert:
+ expect(formattedAmounts).toEqual(['100', '0.000151458904500184', '999999.999999999999999999']);
+ });
+
+ it('formats conversion rate PPM values', () => {
+ // Arrange:
+ const rates = ['1000000', '999999', '2000000'];
+
+ // Act:
+ const formattedRates = rates.map(formatPpm);
+
+ // Assert:
+ expect(formattedRates).toEqual(['1', '0.999999', '2']);
+ });
+
+ it('formats Unix seconds as UTC', () => {
+ // Arrange:
+ const timestamps = [1759781792.879, null, 'hello'];
+
+ // Act:
+ const formattedTimestamps = timestamps.map(formatTimestamp);
+
+ // Assert:
+ expect(formattedTimestamps).toEqual(['2025-10-06 20:16:32 UTC', '—', '—']);
+ });
+
+ it('creates Symbol explorer URLs', () => {
+ // Arrange:
+ const network = { blockchain: 'symbol', explorerUrl: 'https://symbol.example/' };
+
+ // Act:
+ const transactionUrl = createExplorerUrl(network, 'transaction', 'ABC123');
+ const accountUrl = createExplorerUrl(network, 'address', 'TADDRESS');
+
+ // Assert:
+ expect(transactionUrl).toBe('https://symbol.example/transactions/ABC123');
+ expect(accountUrl).toBe('https://symbol.example/accounts/TADDRESS');
+ });
+
+ it('creates Ethereum explorer URLs and normalizes transaction hash prefixes', () => {
+ // Arrange:
+ const network = { blockchain: 'ethereum', explorerUrl: 'https://ethereum.example/' };
+
+ // Act:
+ const transactionUrls = [
+ createExplorerUrl(network, 'transaction', 'ABC123'),
+ createExplorerUrl(network, 'transaction', '0xABC123')
+ ];
+ const addressUrl = createExplorerUrl(network, 'address', '0x123ABC');
+
+ // Assert:
+ expect(transactionUrls).toEqual([
+ 'https://ethereum.example/tx/0xABC123',
+ 'https://ethereum.example/tx/0xABC123'
+ ]);
+ expect(addressUrl).toBe('https://ethereum.example/address/0x123ABC');
+ });
+
+ it('returns null when explorer URL or value is missing', () => {
+ // Arrange:
+ const network = { blockchain: 'symbol', explorerUrl: 'https://symbol.example' };
+
+ // Act:
+ const urls = [
+ createExplorerUrl({}, 'transaction', 'ABC123'),
+ createExplorerUrl(network, 'transaction', null)
+ ];
+
+ // Assert:
+ expect(urls).toEqual([null, null]);
+ });
+});
diff --git a/bridge/reporting/components/ReportTable.jsx b/bridge/reporting/components/ReportTable.jsx
new file mode 100644
index 0000000000..982c4fd2df
--- /dev/null
+++ b/bridge/reporting/components/ReportTable.jsx
@@ -0,0 +1,187 @@
+import { AmountValue, ExternalValue, RateValue, StatusBadge, TransactionValue } from '@/components/ReportTableFields';
+import SortHeader from '@/components/SortHeader';
+import styles from '@/styles/ReportTable.module.css';
+
+const RequestRow = ({ row, tab, configuration }) => {
+ const sourceNetwork = configuration?.[tab.sourceNetwork];
+ const destinationNetwork = configuration?.[tab.destinationNetwork];
+
+ return (
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ );
+};
+
+const ErrorRow = ({ row, tab, configuration }) => {
+ const sourceNetwork = configuration?.[tab.sourceNetwork];
+ return (
+
+ |
+ |
+ {row.errorMessage || '—'} |
+
+ );
+};
+
+const MobileField = ({ label, children, wide }) => (
+
+
{label}
+ {children}
+
+);
+
+const RequestCard = ({ row, tab, configuration }) => {
+ const sourceNetwork = configuration?.[tab.sourceNetwork];
+ const destinationNetwork = configuration?.[tab.destinationNetwork];
+ return (
+
+
+ #{row.requestTransactionHeight}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const ErrorCard = ({ row, tab, configuration }) => {
+ const sourceNetwork = configuration?.[tab.sourceNetwork];
+ return (
+
+
+ #{row.requestTransactionHeight}
+ Error
+
+
+
+
+
+
+
+
+ {row.errorMessage || '—'}
+
+
+ );
+};
+
+const ReportTable = ({ rows, tab, configuration, sort, onSortChange }) => {
+ const isRequests = 'requests' === tab.resource;
+ return (
+ <>
+
+
+ {isRequests ? (
+
+ | Payout status |
+ Sender address |
+
+
+ |
+ Request amount |
+ Payout address |
+ Payout hash |
+ Conversion rate |
+ Payout fee |
+ Payout amount |
+
+ ) : (
+
+ | Sender address |
+
+
+ |
+ Error message |
+
+ )}
+
+
+ {rows.map(row => isRequests
+ ? (
+
+ )
+ : (
+
+ ))}
+
+
+
+ {rows.map(row => isRequests
+ ? (
+
+ )
+ : (
+
+ ))}
+
+ >
+ );
+};
+
+export default ReportTable;
diff --git a/bridge/reporting/components/ReportTableFields.jsx b/bridge/reporting/components/ReportTableFields.jsx
new file mode 100644
index 0000000000..f573569cd4
--- /dev/null
+++ b/bridge/reporting/components/ReportTableFields.jsx
@@ -0,0 +1,57 @@
+import { PAYOUT_STATUS_DETAILS } from '@/constants';
+import styles from '@/styles/ReportTable.module.css';
+import { createExplorerUrl, formatAtomicAmount, formatPpm, formatTimestamp, truncateMiddle } from '@/utils/format';
+
+export const StatusBadge = ({ status, errorMessage }) => {
+ const details = PAYOUT_STATUS_DETAILS[status] || { label: 'Unknown', tone: 'neutral' };
+ const className = `${styles.statusBadge} ${styles[`status_${details.tone}`]}`;
+
+ if (!errorMessage)
+ return {details.label};
+
+ return (
+
+ {details.label}
+
+ );
+};
+
+export const ExternalValue = ({ network, type, value, truncateType = 'default' }) => {
+ const url = createExplorerUrl(network, type, value);
+ const label = 'hash' === truncateType ? truncateMiddle(value, 8, 6) : truncateMiddle(value, 9, 6);
+
+ if (!url)
+ return {label};
+
+ return (
+
+ {label}
+ ↗
+
+ );
+};
+
+export const TransactionValue = ({ hash, timestamp, network }) => (
+
+ {formatTimestamp(timestamp)}
+
+
+);
+
+export const AmountValue = ({ value, asset }) => (
+
+ {formatAtomicAmount(value, asset.divisibility)}
+ {null !== value && value !== undefined && {asset.ticker}}
+
+);
+
+export const RateValue = ({ value }) => (
+
+ {formatPpm(value)}
+
+);
diff --git a/bridge/reporting/components/SortHeader.jsx b/bridge/reporting/components/SortHeader.jsx
new file mode 100644
index 0000000000..7af9ff73af
--- /dev/null
+++ b/bridge/reporting/components/SortHeader.jsx
@@ -0,0 +1,16 @@
+import styles from '@/styles/ReportTable.module.css';
+
+const SortHeader = ({ sort, onSortChange }) => (
+
+);
+
+export default SortHeader;
diff --git a/bridge/reporting/constants/index.js b/bridge/reporting/constants/index.js
index 8ab5cc70ab..ee1ad278ca 100644
--- a/bridge/reporting/constants/index.js
+++ b/bridge/reporting/constants/index.js
@@ -1 +1,15 @@
export const PAGE_SIZE = 100;
+
+export const PAYOUT_STATUS = {
+ UNPROCESSED: 0,
+ SENT: 1,
+ COMPLETED: 2,
+ FAILED: 3
+};
+
+export const PAYOUT_STATUS_DETAILS = {
+ [PAYOUT_STATUS.UNPROCESSED]: { label: 'Unprocessed', tone: 'neutral' },
+ [PAYOUT_STATUS.SENT]: { label: 'Sent', tone: 'info' },
+ [PAYOUT_STATUS.COMPLETED]: { label: 'Completed', tone: 'success' },
+ [PAYOUT_STATUS.FAILED]: { label: 'Failed', tone: 'danger' }
+};
diff --git a/bridge/reporting/jest.config.js b/bridge/reporting/jest.config.js
index 6040ae4e1f..a685f6cf42 100644
--- a/bridge/reporting/jest.config.js
+++ b/bridge/reporting/jest.config.js
@@ -7,6 +7,7 @@ module.exports = createJestConfig({
moduleNameMapper: {
'^@/(.*)$': '/$1'
},
+ coverageProvider: 'babel',
modulePathIgnorePatterns: ['/.next/'],
setupFilesAfterEnv: ['/setupTests.js'],
testEnvironment: 'jsdom'
diff --git a/bridge/reporting/package-lock.json b/bridge/reporting/package-lock.json
index ebfab7387b..7ebb1b811c 100644
--- a/bridge/reporting/package-lock.json
+++ b/bridge/reporting/package-lock.json
@@ -17,7 +17,6 @@
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
- "c8": "^12.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "15.5.6",
"eslint-import-resolver-jsconfig": "^1.1.0",
@@ -3409,250 +3408,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/c8": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz",
- "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@bcoe/v8-coverage": "^1.0.1",
- "@istanbuljs/schema": "^0.1.3",
- "find-up": "^5.0.0",
- "foreground-child": "^3.1.1",
- "istanbul-lib-coverage": "^3.2.0",
- "istanbul-lib-report": "^3.0.1",
- "istanbul-reports": "^3.1.6",
- "test-exclude": "^8.0.0",
- "v8-to-istanbul": "^9.0.0",
- "yargs": "^18.0.0",
- "yargs-parser": "^21.1.1"
- },
- "bin": {
- "c8": "bin/c8.js"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=23"
- },
- "peerDependencies": {
- "monocart-coverage-reports": "^2"
- },
- "peerDependenciesMeta": {
- "monocart-coverage-reports": {
- "optional": true
- }
- }
- },
- "node_modules/c8/node_modules/@bcoe/v8-coverage": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
- "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/c8/node_modules/ansi-regex": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
- "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/c8/node_modules/ansi-styles": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
- "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/c8/node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/c8/node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/c8/node_modules/cliui": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
- "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^7.2.0",
- "strip-ansi": "^7.1.0",
- "wrap-ansi": "^9.0.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/c8/node_modules/emoji-regex": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
- "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/c8/node_modules/glob": {
- "version": "13.0.6",
- "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
- "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "minimatch": "^10.2.2",
- "minipass": "^7.1.3",
- "path-scurry": "^2.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/c8/node_modules/minimatch": {
- "version": "10.2.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
- "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "brace-expansion": "^5.0.5"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/c8/node_modules/string-width": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
- "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^10.3.0",
- "get-east-asian-width": "^1.0.0",
- "strip-ansi": "^7.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/c8/node_modules/strip-ansi": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
- "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^6.2.2"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/c8/node_modules/test-exclude": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
- "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "@istanbuljs/schema": "^0.1.2",
- "glob": "^13.0.6",
- "minimatch": "^10.2.2"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/c8/node_modules/wrap-ansi": {
- "version": "9.0.2",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
- "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.2.1",
- "string-width": "^7.0.0",
- "strip-ansi": "^7.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/c8/node_modules/yargs": {
- "version": "18.0.0",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
- "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cliui": "^9.0.1",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "string-width": "^7.2.0",
- "y18n": "^5.0.5",
- "yargs-parser": "^22.0.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=23"
- }
- },
- "node_modules/c8/node_modules/yargs/node_modules/yargs-parser": {
- "version": "22.0.0",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
- "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=23"
- }
- },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -5221,36 +4976,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/foreground-child": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
- "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "cross-spawn": "^7.0.6",
- "signal-exit": "^4.0.1"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/foreground-child/node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@@ -5362,19 +5087,6 @@
"node": "6.* || 8.* || >= 10.*"
}
},
- "node_modules/get-east-asian-width": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
- "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -7753,16 +7465,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/minipass": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
- "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8248,33 +7950,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/path-scurry": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
- "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "lru-cache": "^11.0.0",
- "minipass": "^7.1.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "11.5.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
- "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/bridge/reporting/package.json b/bridge/reporting/package.json
index edfcf48d34..0464cd2530 100644
--- a/bridge/reporting/package.json
+++ b/bridge/reporting/package.json
@@ -9,7 +9,7 @@
"test": "jest",
"lint": "eslint --ext js,jsx .",
"lint:fix": "eslint --ext js,jsx . --fix",
- "test:jenkins": "c8 --reporter=lcov --reporter=text --report-dir=coverage npm run test",
+ "test:jenkins": "jest --coverage --coverageProvider=v8",
"version": "echo $npm_package_version"
},
"dependencies": {
@@ -22,7 +22,6 @@
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
- "c8": "^12.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "15.5.6",
"eslint-import-resolver-jsconfig": "^1.1.0",
diff --git a/bridge/reporting/styles/ReportTable.module.css b/bridge/reporting/styles/ReportTable.module.css
new file mode 100644
index 0000000000..2bd18a8776
--- /dev/null
+++ b/bridge/reporting/styles/ReportTable.module.css
@@ -0,0 +1,248 @@
+.table {
+ width: 100%;
+ color: var(--color-text);
+ font: 700 11px/1.45 var(--font-data);
+ border-collapse: separate;
+ border-spacing: 0;
+ table-layout: fixed;
+}
+
+.requestTable {
+ min-width: 1480px;
+}
+
+.errorTable {
+ min-width: 880px;
+}
+
+.table thead {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+}
+
+.table th {
+ height: 40px;
+ padding: 0 12px;
+ color: var(--color-muted);
+ font: 700 9px/1 var(--font-data);
+ letter-spacing: 0.09em;
+ text-align: left;
+ text-transform: uppercase;
+ background: #1b1527;
+ border-bottom: 1px solid var(--color-line);
+}
+
+.requestTable th:nth-child(1) { width: 118px; }
+.requestTable th:nth-child(2) { width: 170px; }
+.requestTable th:nth-child(3) { width: 205px; }
+.requestTable th:nth-child(4) { width: 130px; }
+.requestTable th:nth-child(5) { width: 170px; }
+.requestTable th:nth-child(6) { width: 205px; }
+.requestTable th:nth-child(7) { width: 115px; }
+.requestTable th:nth-child(8) { width: 130px; }
+.requestTable th:nth-child(9) { width: 140px; }
+.errorTable th:nth-child(1) { width: 220px; }
+.errorTable th:nth-child(2) { width: 250px; }
+
+.table td {
+ height: 52px;
+ padding: 8px 12px;
+ vertical-align: middle;
+ background: rgba(34, 28, 49, 0.62);
+ border-bottom: 1px solid #342d42;
+}
+
+.table tbody tr:nth-child(even) td {
+ background: rgba(25, 19, 36, 0.88);
+}
+
+.table tbody tr:hover td {
+ background: #30283e;
+}
+
+.sortButton {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ letter-spacing: inherit;
+ text-transform: inherit;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+}
+
+.sortButton span {
+ color: var(--color-cyan);
+ font-size: 13px;
+}
+
+.statusBadge {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 82px;
+ height: 24px;
+ padding: 0 10px;
+ font: 700 9px/1 var(--font-data);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+}
+
+.status_neutral { color: #a6a0ac; background: rgba(166, 160, 172, 0.1); }
+.status_info { color: var(--color-cyan); background: rgba(38, 195, 242, 0.1); }
+.status_success { color: var(--color-success); background: rgba(139, 224, 160, 0.1); }
+.status_danger { color: var(--color-danger); background: rgba(255, 146, 157, 0.1); }
+
+.statusWithTooltip {
+ cursor: help;
+}
+
+.statusWithTooltip::after {
+ position: absolute;
+ bottom: calc(100% + 9px);
+ left: 0;
+ z-index: 20;
+ display: none;
+ width: max-content;
+ max-width: 280px;
+ padding: 9px 11px;
+ color: var(--color-text);
+ font: 400 11px/1.4 var(--font-body);
+ letter-spacing: 0;
+ text-align: left;
+ text-transform: none;
+ background: #06000d;
+ border: 1px solid var(--color-danger);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
+ content: attr(data-tooltip);
+}
+
+.statusWithTooltip:hover::after,
+.statusWithTooltip:focus::after {
+ display: block;
+}
+
+.externalValue {
+ display: inline-flex;
+ gap: 5px;
+ align-items: center;
+ max-width: 100%;
+ overflow: hidden;
+ color: #bcecff;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.externalValue:hover {
+ color: var(--color-cyan);
+ text-decoration: underline;
+}
+
+.externalMark {
+ color: var(--color-purple);
+ font-size: 9px;
+}
+
+.transactionValue,
+.amountValue {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.timestamp,
+.amountValue small {
+ color: var(--color-muted);
+ font: 400 9px/1.2 var(--font-body);
+}
+
+.amountValue span,
+.rateValue {
+ font-variant-numeric: tabular-nums;
+}
+
+.amountValue small {
+ color: var(--color-purple);
+ font-family: var(--font-data);
+}
+
+.errorMessage {
+ color: #ffd8dc;
+ font-family: var(--font-body);
+ font-weight: 400;
+}
+
+.mobileList {
+ display: none;
+}
+
+@media (max-width: 700px) {
+ .table {
+ display: none;
+ }
+
+ .mobileList {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px;
+ }
+
+ .mobileCard {
+ padding: 12px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-line);
+ border-left: 2px solid var(--color-purple);
+ }
+
+ .mobileCardTop {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ padding-bottom: 9px;
+ border-bottom: 1px solid var(--color-line);
+ }
+
+ .mobileRowId {
+ color: var(--color-muted);
+ font: 700 9px/1 var(--font-data);
+ }
+
+ .mobileGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px 12px;
+ margin: 0;
+ }
+
+ .mobileField {
+ min-width: 0;
+ }
+
+ .mobileFieldWide {
+ grid-column: 1 / -1;
+ }
+
+ .mobileField dt {
+ margin-bottom: 4px;
+ color: var(--color-muted);
+ font: 700 8px/1 var(--font-data);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+
+ .mobileField dd {
+ min-width: 0;
+ margin: 0;
+ font: 700 11px/1.4 var(--font-data);
+ }
+}
diff --git a/bridge/reporting/utils/format.js b/bridge/reporting/utils/format.js
new file mode 100644
index 0000000000..8a20c15d1a
--- /dev/null
+++ b/bridge/reporting/utils/format.js
@@ -0,0 +1,53 @@
+export const formatAtomicAmount = (value, divisibility) => {
+ if (null === value || value === undefined || '' === value)
+ return '—';
+
+ const digits = String(value);
+
+ if (!/^\d+$/.test(digits))
+ return digits;
+
+ if (!divisibility)
+ return digits;
+
+ const padded = digits.padStart(divisibility + 1, '0');
+ const whole = padded.slice(0, -divisibility);
+ const fraction = padded.slice(-divisibility).replace(/0+$/, '');
+
+ return `${whole}${fraction ? `.${fraction}` : ''}`;
+};
+
+export const formatPpm = value => formatAtomicAmount(value, 6);
+
+export const formatTimestamp = value => {
+ if (null === value || value === undefined)
+ return '—';
+
+ const date = new Date(Number(value) * 1000);
+ if (Number.isNaN(date.getTime()))
+ return '—';
+
+ return `${date.toISOString().slice(0, 19).replace('T', ' ')} UTC`;
+};
+
+export const truncateMiddle = (value, start = 8, end = 6) => {
+ if (!value)
+ return '—';
+
+ const text = String(value);
+ return text.length <= start + end ? text : `${text.slice(0, start)}…${text.slice(-end)}`;
+};
+
+export const createExplorerUrl = (network, type, value) => {
+ if (!network?.explorerUrl || !value)
+ return null;
+
+ const baseUrl = network.explorerUrl.replace(/\/$/, '');
+ if ('ethereum' === network.blockchain) {
+ const path = 'transaction' === type ? 'tx' : 'address';
+ const normalizedValue = 'transaction' === type ? `0x${value.replace(/^0x/i, '')}` : value;
+ return `${baseUrl}/${path}/${normalizedValue}`;
+ }
+
+ return `${baseUrl}/${'transaction' === type ? 'transactions' : 'accounts'}/${value}`;
+};