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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "node-admin-ui",
"version": "4.0.0-rc.3",
"version": "4.0.0-rc.4",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.0",
Expand All @@ -27,6 +27,7 @@
"react-router-dom": "7.15.0",
"react-timeago": "^7.2.0",
"react-toastify": "11.0.5",
"react-virtuoso": "^4.18.12",
"redux": "^4.2.1",
"typescript": "^5.1.6",
"uuid": "^9.0.1",
Expand Down
4 changes: 4 additions & 0 deletions src/LayoutEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const LayoutEnhanced = () => {
const fetchingChannels = useAppSelector((store) => store.node.channels.isFetching);
const numberOfSessions = useAppSelector((store) => store.node.sessions.data?.length);
const fetchingSessions = useAppSelector((store) => store.node.sessions.isFetching);
const numberOfSafeNodes = useAppSelector((store) => store.blokli.safeNodes.data?.length);
const fetchingSafeNodes = useAppSelector((store) => store.blokli.safeNodes.isFetching);

const numberForDrawer = {
numberOfPeers,
Expand All @@ -43,12 +45,14 @@ const LayoutEnhanced = () => {
numberOfChannelsIn,
numberOfChannelsOut,
numberOfSessions,
numberOfSafeNodes,
};

const drawerNumbersLoading = {
fetchingPeers,
fetchingChannels,
fetchingSessions,
fetchingSafeNodes,
};

useEffect(() => {
Expand Down
19 changes: 19 additions & 0 deletions src/applicationMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import ChannelsPageIncoming from './pages/node/channelsIncoming';
import ChannelsPageOutgoing from './pages/node/channelsOutgoing';
import ConfigurationPage from './pages/node/configuration';
import SessionsPage from './pages/node/sessions';
import SafeNodesPage from './pages/safe/nodes';

// Icons
import InfoIcon from '@mui/icons-material/Info';
Expand All @@ -26,6 +27,8 @@ import TelegramIcon from '@mui/icons-material/Telegram';
import IncomingChannelsIcon from './future-hopr-lib-components/Icons/channelsIn';
import OutgoingChannelsIcon from './future-hopr-lib-components/Icons/channelsOut';
import SettingsPhoneIcon from '@mui/icons-material/SettingsPhone';
import SafeIcon from '@mui/icons-material/Shield';
import SafeNodesIcon from '@mui/icons-material/Dns';

export type ApplicationMapType = {
groupName: string;
Expand Down Expand Up @@ -127,6 +130,22 @@ export const applicationMapNode: ApplicationMapType = [
},
],
},
{
groupName: 'SAFE',
path: 'safe',
icon: <SafeIcon />,
items: [
{
name: 'NODES',
path: 'nodes',
icon: <SafeNodesIcon />,
element: <SafeNodesPage />,
loginNeeded: 'node',
numberKey: 'numberOfSafeNodes',
fetchingKey: 'fetchingSafeNodes',
},
],
},
{
groupName: 'LINKS',
path: 'links',
Expand Down
256 changes: 254 additions & 2 deletions src/blokli/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { queryBlokli, unwrapUnion } from './client';
import { parseTokenValue, type ChannelStatsType, type TicketRedemptionType, type TokenValueString } from './types';
import { formatEther, getAddress } from 'viem';
import { queryBlokli, tryUnwrapUnion, unwrapUnion } from './client';
import {
parseTokenValue,
type ChannelStatsType,
type SafeNodeType,
type TicketRedemptionType,
type TokenValueString,
} from './types';

export type BlokliPayloadType = {
blokliUrl: string;
Expand Down Expand Up @@ -100,3 +107,248 @@ export const getTicketRedemptionStats = async (
rejectionCount: stats.rejectionCount,
};
};

// safe(address:) is deprecated in blokli's target schema in favor of
// safeBy(address:, selector:), the swap is mechanical once deployed everywhere
const SAFE_NODES_QUERY = `
query AdminSafeNodes($safeAddress: String!) {
safe(address: $safeAddress) {
__typename
... on Safe {
registeredNodes
}
... on InvalidAddressError {
code
message
}
... on QueryFailedError {
code
message
}
}
}
`;

// one request for the OPEN channels of the whole safe, grouped by source keyid
// client side - much cheaper than per node channelStats calls
const SAFE_CHANNELS_QUERY = `
query AdminSafeChannels($safeAddress: String!) {
channels(safeAddress: $safeAddress, status: OPEN) {
__typename
... on ChannelsList {
channels {
source
balance
}
}
... on MissingFilterError {
code
message
}
... on InvalidAddressError {
code
message
}
... on QueryFailedError {
code
message
}
}
}
`;

/**
* Blokli enforces a query complexity budget of 500 per request and weighs the
* RPC-backed root fields: nativeBalance costs 50 and ticketRedemptionStats 100,
* so the aliased batches below are chunked to stay under the budget.
*/
const ACCOUNTS_PER_REQUEST = 40;
const NATIVE_BALANCES_PER_REQUEST = 8;
const REDEMPTIONS_PER_REQUEST = 4;

const buildAccountsQuery = (indexes: number[]) => {
const variables = indexes.map((i) => `$a${i}: String!`).join(', ');
const fields = indexes
.map(
(i) => `
k${i}: accounts(chainKey: $a${i}) {
__typename
... on AccountsList {
accounts {
keyid
chainKey
}
}
... on MissingFilterError {
code
message
}
... on QueryFailedError {
code
message
}
}`,
)
.join('\n');
return `query AdminSafeNodeAccounts(${variables}) {${fields}
}`;
};

const buildNativeBalancesQuery = (indexes: number[]) => {
const variables = indexes.map((i) => `$a${i}: String!`).join(', ');
const fields = indexes
.map(
(i) => `
b${i}: nativeBalance(address: $a${i}) {
__typename
... on NativeBalance {
balance
}
... on InvalidAddressError {
code
message
}
... on QueryFailedError {
code
message
}
}`,
)
.join('\n');
return `query AdminSafeNodeNativeBalances(${variables}) {${fields}
}`;
};

const buildRedemptionsQuery = (indexes: number[]) => {
const variables = indexes.map((i) => `$a${i}: String!`).join(', ');
const fields = indexes
.map(
(i) => `
r${i}: ticketRedemptionStats(filter: { nodeAddress: $a${i} }) {
__typename
... on RedeemedStats {
redeemedAmount
}
... on InvalidAddressError {
code
message
}
... on MissingFilterError {
code
message
}
... on QueryFailedError {
code
message
}
}`,
)
.join('\n');
return `query AdminSafeNodeRedemptions(${variables}) {${fields}
}`;
};

/**
* All nodes registered to the safe with their xDai balance, OPEN outgoing channel
* stats and lifetime redeemed amount. Blokli has no single query for it, so this
* assembles: safe() for the node list, one channels(safeAddress) request grouped
* by source keyid client side, and complexity-chunked aliased batches for the
* accounts, native balances and redemptions. Only the safe() lookup is a hard
* failure - any other failing request or per node field just nulls the affected
* cells, rendered as '-'.
*/
export const getSafeNodes = async (payload: BlokliPayloadType & { safeAddress: string }): Promise<SafeNodeType[]> => {
const safeData = await queryBlokli<{
safe: { __typename?: string; registeredNodes?: string[] } | null;
}>(payload.blokliUrl, SAFE_NODES_QUERY, { safeAddress: payload.safeAddress }, payload.timeout);

// blokli answers null (not an error member) when it does not know the safe
if (safeData.safe === null) {
console.warn(`Blokli does not know safe ${payload.safeAddress}`);
return [];
}
const safe = unwrapUnion<{ registeredNodes: string[] }>(safeData.safe, 'Safe');

// checksummed so the addresses match the alias and channel maps of the node slice
const nodeAddresses = [...safe.registeredNodes].map((address) => getAddress(address)).sort();
if (nodeAddresses.length === 0) return [];
const indexes = nodeAddresses.map((_, i) => i);

const runChunked = async (chunkSize: number, buildQuery: (chunkIndexes: number[]) => string) => {
const merged: Record<string, { __typename?: string } | null> = {};
const chunks: number[][] = [];
for (let start = 0; start < indexes.length; start += chunkSize) {
chunks.push(indexes.slice(start, start + chunkSize));
}
await Promise.all(
chunks.map(async (chunkIndexes) => {
try {
const data = await queryBlokli<Record<string, { __typename?: string } | null>>(
payload.blokliUrl,
buildQuery(chunkIndexes),
Object.fromEntries(chunkIndexes.map((i) => [`a${i}`, nodeAddresses[i]])),
payload.timeout,
);
Object.assign(merged, data);
} catch (e) {
console.warn('Blokli batched safe nodes request failed', e);
}
}),
);
return merged;
};

const [accountsData, nativeData, redemptionData, channelsList] = await Promise.all([
runChunked(ACCOUNTS_PER_REQUEST, buildAccountsQuery),
runChunked(NATIVE_BALANCES_PER_REQUEST, buildNativeBalancesQuery),
runChunked(REDEMPTIONS_PER_REQUEST, buildRedemptionsQuery),
queryBlokli<{ channels: { __typename?: string } | null }>(
payload.blokliUrl,
SAFE_CHANNELS_QUERY,
{ safeAddress: payload.safeAddress },
payload.timeout,
)
.then((data) =>
tryUnwrapUnion<{ channels: { source: number; balance: TokenValueString }[] }>(data.channels, 'ChannelsList'),
)
.catch((e) => {
console.warn('Blokli safe channels request failed', e);
return null;
}),
]);

return nodeAddresses.map((nodeAddress, i) => {
const accountsList = tryUnwrapUnion<{ accounts: { keyid: number; chainKey: string }[] }>(
accountsData[`k${i}`],
'AccountsList',
);
const keyid =
accountsList?.accounts.find((account) => account.chainKey.toLowerCase() === nodeAddress.toLowerCase())?.keyid ??
null;
const native = tryUnwrapUnion<{ balance: TokenValueString }>(nativeData[`b${i}`], 'NativeBalance');
const redeemedStats = tryUnwrapUnion<{ redeemedAmount: TokenValueString }>(
redemptionData[`r${i}`],
'RedeemedStats',
);

// a resolved keyid with no matching channels is real data (0 channels),
// an unresolved keyid or a failed channels request is unknown ('-')
let channels: ChannelStatsType | null = null;
if (channelsList && keyid !== null) {
const outgoing = channelsList.channels.filter((channel) => channel.source === keyid);
const value = outgoing.reduce((sum, channel) => sum + BigInt(parseTokenValue(channel.balance).value), BigInt(0));
channels = {
count: outgoing.length,
value: value.toString(),
formatted: formatEther(value),
};
}

return {
nodeAddress,
xDai: native ? parseTokenValue(native.balance) : null,
channels,
redeemed: redeemedStats ? parseTokenValue(redeemedStats.redeemedAmount) : null,
};
});
};
17 changes: 17 additions & 0 deletions src/blokli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,20 @@ export const unwrapUnion = <T>(
}
return result as T;
};

/**
* Lenient variant of unwrapUnion for batched per-node root fields: one node's
* error member should null that node's cell, not fail the whole query.
*/
export const tryUnwrapUnion = <T>(
result: { __typename?: string; code?: string; message?: string } | null | undefined,
expected: string,
): T | null => {
if (!result || result.__typename !== expected) {
if (result) {
console.warn(`Blokli returned ${result.__typename} instead of ${expected}`, result.code, result.message);
}
return null;
}
return result as T;
};
5 changes: 3 additions & 2 deletions src/blokli/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as api from './api';
import { blokliApiError, blokliGraphqlUrl, queryBlokli, unwrapUnion } from './client';
import { blokliApiError, blokliGraphqlUrl, queryBlokli, tryUnwrapUnion, unwrapUnion } from './client';
import { parseTokenValue } from './types';

export { api };
Expand All @@ -8,8 +8,9 @@ export const utils = {
blokliGraphqlUrl,
queryBlokli,
unwrapUnion,
tryUnwrapUnion,
parseTokenValue,
};

export type { ChannelStatsType, TicketRedemptionType, TokenValueString, TokenValueType } from './types';
export type { ChannelStatsType, SafeNodeType, TicketRedemptionType, TokenValueString, TokenValueType } from './types';
export type { BlokliPayloadType } from './api';
Loading
Loading