Tauri prod CSP for Emotion/MUI and window maximize ACL

- Tauri was injecting nonces/hashes into style-src, which disables
'unsafe-inline' and blocked Emotion/MUI runtime <style> tags.
- Grant core:window:allow-maximize so frontend maximize() passes ACL.
- Add node-status and explorer helpers plus chart mappers; Jest coverage
- NodeOperatorInsights on BondedNymNode; optional API moniker/location
- Shared MUI Emotion cache (speedy: false) and CacheProvider wiring
- SendInputModal: amount/recipient validation timing; memoized fee check
- AuthLayout refresh; NodeTable overflow-x; Bonding error title typo fix
This commit is contained in:
Tommy Verrall
2026-04-17 12:01:40 +02:00
parent 754994ba01
commit 3ae986acc8
22 changed files with 1718 additions and 198 deletions
+6
View File
@@ -5,13 +5,18 @@
"main": "index.js",
"scripts": {
"build": "run-s webpack:prod tauri:build",
"build:no-sign": "run-s webpack:prod tauri:build:no-sign",
"build-macx86": "run-s webpack:prod tauri:buildx86",
"dev": "run-p tauri:dev webpack:dev",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prebuild": "yarn --cwd .. build",
"prewebpack:dev": "yarn --cwd .. build",
"prewebpack:prod": "yarn check:singletons",
"check:singletons": "node scripts/check-mui-singletons.js",
"tauri:build": "yarn tauri build",
"tauri:build:no-sign": "yarn tauri build --no-sign -b app",
"tauri:build:adhoc": "APPLE_SIGNING_IDENTITY=- yarn tauri build -b app",
"tauri:dev": "yarn tauri dev",
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
"test": "jest --config jest.config.cjs",
@@ -42,6 +47,7 @@
"@tauri-apps/tauri-forage": "^1.0.0-beta.2",
"big.js": "^6.2.1",
"bs58": "^4.0.1",
"@emotion/cache": "^11.14.0",
"clsx": "^1.1.1",
"date-fns": "^2.28.0",
"joi": "^17.11.0",
@@ -0,0 +1,45 @@
/* eslint-disable no-console -- build-time diagnostic */
const path = require('path');
const fs = require('fs');
const walletRoot = path.resolve(__dirname, '..');
const PKGS = [
'@emotion/react',
'@emotion/styled',
'@emotion/cache',
'@mui/material',
'@mui/system',
'@mui/styled-engine',
'@mui/private-theming',
'@mui/utils',
'@mui/lab',
'@mui/icons-material',
'react',
'react-dom',
];
const probes = [
walletRoot,
path.join(walletRoot, 'node_modules', '@nymproject', 'react'),
path.join(walletRoot, 'node_modules', '@mui', 'material'),
];
let bad = 0;
PKGS.forEach((pkg) => {
const seen = new Set();
probes.forEach((probe) => {
try {
const p = require.resolve(`${pkg}/package.json`, { paths: [probe] });
seen.add(fs.realpathSync(p));
} catch {
/* probe may not resolve this package */
}
});
if (seen.size > 1) {
bad += 1;
console.error(`[singleton] DUPLICATE ${pkg}:\n ${[...seen].join('\n ')}`);
}
});
process.exit(bad ? 1 : 0);
+1 -1
View File
@@ -40,7 +40,7 @@ serde_json = "1.0"
serde_repr = "0.1"
strum = { version = "0.27.2", features = ["derive"] }
tap = "1"
tauri = { version = "2.10.3", features = [] }
tauri = { version = "2.10.3", features = ["devtools"] }
#tendermint-rpc = "0.23.0"
time = { version = "0.3.30", features = ["local-offset"] }
thiserror = "1.0"
@@ -24,6 +24,7 @@
"opener:allow-open-url",
"opener:allow-default-urls",
"core:window:allow-set-title",
"core:window:allow-maximize",
"core:app:allow-version",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
@@ -1 +1 @@
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","shell:allow-open","process:default"],"platforms":["linux","macOS","windows"]}}
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:window:allow-maximize","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","shell:allow-open","process:default"],"platforms":["linux","macOS","windows"]}}
+7 -4
View File
@@ -14,7 +14,7 @@
],
"resources": [],
"externalBin": [],
"copyright": "Copyright © 2021-2025 Nym Technologies SA",
"copyright": "Copyright © 2021-2026 Nym Technologies SA",
"category": "Business",
"shortDescription": "Nym desktop wallet allows you to manage your NYM tokens",
"longDescription": "",
@@ -45,7 +45,7 @@
},
"productName": "NymWallet",
"mainBinaryName": "NymWallet",
"version": "1.2.19",
"version": "1.2.20",
"identifier": "net.nymtech.wallet",
"plugins": {
"updater": {
@@ -60,12 +60,15 @@
"capabilities": [
"main-capability"
],
"dangerousDisableAssetCspModification": [
"style-src"
],
"csp": {
"default-src": "'self' customprotocol: asset:",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
"style-src": "'unsafe-inline' 'self'",
"style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com",
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data:",
"font-src": "'self' data:",
"font-src": "'self' data: https://fonts.gstatic.com",
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* wss://127.0.0.1:* wss://localhost:* http: https: ws: wss:",
"media-src": "'self' blob: data:",
"worker-src": "'self' blob:",
+297
View File
@@ -0,0 +1,297 @@
import type { ExplorerNymNodeRow } from './nodeStatus';
import {
clearNodeStatusExplorerCaches,
fetchGatewayStatus,
fetchGatewayStatusIfBonded,
findExplorerNymNodeByIdentity,
getNodeStatusBaseUrl,
isGatewayRole,
normalizeExplorerUptimePercent,
parseTotalStakeToNymAmount,
} from './nodeStatus';
describe('getNodeStatusBaseUrl', () => {
it('uses mainnet base for MAINNET and unknown', () => {
expect(getNodeStatusBaseUrl('MAINNET')).toBe('https://mainnet-node-status-api.nymtech.cc');
expect(getNodeStatusBaseUrl(undefined)).toBe('https://mainnet-node-status-api.nymtech.cc');
});
it('uses sandbox base for SANDBOX and QA', () => {
expect(getNodeStatusBaseUrl('SANDBOX')).toBe('https://sandbox-node-status-api.nymte.ch');
expect(getNodeStatusBaseUrl('QA')).toBe('https://sandbox-node-status-api.nymte.ch');
});
});
describe('isGatewayRole', () => {
it('is true only for gateway roles', () => {
expect(isGatewayRole('entryGateway')).toBe(true);
expect(isGatewayRole('exitGateway')).toBe(true);
expect(isGatewayRole('layer1')).toBe(false);
expect(isGatewayRole(undefined)).toBe(false);
});
});
describe('parseTotalStakeToNymAmount', () => {
it('divides base unym by 1e6', () => {
expect(parseTotalStakeToNymAmount('1000000')).toBe(1);
expect(parseTotalStakeToNymAmount('2500000')).toBe(2.5);
});
it('returns 0 for non-finite values', () => {
expect(parseTotalStakeToNymAmount('')).toBe(0);
expect(parseTotalStakeToNymAmount('x')).toBe(0);
});
});
describe('normalizeExplorerUptimePercent', () => {
it('maps ratio 0-1 to percent', () => {
expect(normalizeExplorerUptimePercent(0.98)).toBe(98);
expect(normalizeExplorerUptimePercent(1)).toBe(100);
expect(normalizeExplorerUptimePercent(0)).toBe(0);
});
it('leaves values already in 0-100 percent scale', () => {
expect(normalizeExplorerUptimePercent(98)).toBe(98);
expect(normalizeExplorerUptimePercent(100)).toBe(100);
});
it('parses numeric strings', () => {
expect(normalizeExplorerUptimePercent('0.98')).toBe(98);
expect(normalizeExplorerUptimePercent(' 98 ')).toBe(98);
});
it('clamps negatives and caps at 100', () => {
expect(normalizeExplorerUptimePercent(-1)).toBe(0);
expect(normalizeExplorerUptimePercent(150)).toBe(100);
});
it('returns 0 for non-numeric input', () => {
expect(normalizeExplorerUptimePercent('')).toBe(0);
expect(normalizeExplorerUptimePercent('abc')).toBe(0);
expect(normalizeExplorerUptimePercent(Number.NaN)).toBe(0);
});
});
function jsonResponse(ok: boolean, status: number, body: unknown): Response {
return {
ok,
status,
json: async () => body,
} as Response;
}
describe('fetchGatewayStatusIfBonded', () => {
const baseUrl = 'https://status.example';
const identity = 'nym1abc';
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns null on 404 and 400', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValueOnce(jsonResponse(false, 404, null));
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
fetchMock.mockResolvedValueOnce(jsonResponse(false, 400, null));
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
});
it('throws on other non-OK responses', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValueOnce(jsonResponse(false, 500, null));
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).rejects.toThrow('Gateway status request failed (500)');
});
it('returns null when bonded is false or identity mismatches', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValueOnce(
jsonResponse(true, 200, {
gateway_identity_key: identity,
bonded: false,
performance: 0,
routing_score: 0,
last_updated_utc: 't',
}),
);
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
fetchMock.mockResolvedValueOnce(
jsonResponse(true, 200, {
gateway_identity_key: 'other',
bonded: true,
performance: 0,
routing_score: 0,
last_updated_utc: 't',
}),
);
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
});
it('returns payload when bonded and identity matches', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
const payload = {
gateway_identity_key: identity,
bonded: true,
performance: 88,
routing_score: 1,
last_updated_utc: 't',
};
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, payload));
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toStrictEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'https://status.example/v2/gateways/nym1abc',
expect.objectContaining({ cache: 'no-store' }),
);
});
it('strips trailing slash from base URL', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValueOnce(
jsonResponse(true, 200, {
gateway_identity_key: identity,
bonded: true,
performance: 1,
routing_score: 0,
last_updated_utc: 't',
}),
);
await fetchGatewayStatusIfBonded('https://status.example/', identity);
expect(fetchMock).toHaveBeenCalledWith('https://status.example/v2/gateways/nym1abc', expect.any(Object));
});
});
describe('fetchGatewayStatus', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('throws when gateway is not bonded', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValueOnce(jsonResponse(false, 404, null));
await expect(fetchGatewayStatus('https://x', 'id')).rejects.toThrow('Gateway status request failed (404)');
});
});
describe('findExplorerNymNodeByIdentity', () => {
const base = 'https://status.example';
const identity = 'nodeIdentity1';
const sampleRow = (key: string): ExplorerNymNodeRow => ({
identity_key: key,
node_id: 1,
bonded: true,
uptime: 0.99,
total_stake: '1000000',
original_pledge: 1,
description: { moniker: 'm', website: '', details: '', security_contact: '' },
});
beforeEach(() => {
clearNodeStatusExplorerCaches();
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
clearNodeStatusExplorerCaches();
});
it('finds row on first page', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
const row = sampleRow(identity);
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, { items: [row], page: 0, size: 200, total: 1 }));
const result = await findExplorerNymNodeByIdentity('MAINNET', base, identity);
expect(result).toStrictEqual(row);
expect(fetchMock).toHaveBeenCalledWith(
'https://status.example/explorer/v3/nym-nodes?page=0&size=200',
expect.objectContaining({ cache: 'no-store' }),
);
});
it('paginates until match', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
const other = sampleRow('other');
const row = sampleRow(identity);
fetchMock
.mockResolvedValueOnce(
jsonResponse(true, 200, {
items: Array(200).fill(other),
page: 0,
size: 200,
total: 250,
}),
)
.mockResolvedValueOnce(
jsonResponse(true, 200, {
items: [row],
page: 1,
size: 200,
total: 250,
}),
);
const result = await findExplorerNymNodeByIdentity('MAINNET', base, identity);
expect(result).toStrictEqual(row);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws when explorer returns non-OK', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValueOnce(jsonResponse(false, 503, null));
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).rejects.toThrow(
'Explorer nym-nodes request failed (503)',
);
});
it('returns null when not found and caches miss', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
fetchMock.mockResolvedValue(jsonResponse(true, 200, { items: [], page: 0, size: 200, total: 0 }));
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toBeNull();
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('returns cached hit without refetching', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
const row = sampleRow(identity);
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, { items: [row], page: 0, size: 200, total: 1 }));
await findExplorerNymNodeByIdentity('MAINNET', base, identity);
await findExplorerNymNodeByIdentity('MAINNET', base, identity);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent scans for the same key', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
let resolveFetch!: (v: Response) => void;
const fetchPromise = new Promise<Response>((r) => {
resolveFetch = r;
});
fetchMock.mockReturnValueOnce(fetchPromise);
const p1 = findExplorerNymNodeByIdentity('MAINNET', base, identity);
const p2 = findExplorerNymNodeByIdentity('MAINNET', base, identity);
resolveFetch(jsonResponse(true, 200, { items: [], page: 0, size: 200, total: 0 }));
await expect(Promise.all([p1, p2])).resolves.toStrictEqual([null, null]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not share cache between networks for the same identity', async () => {
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
const rowMainnet = sampleRow(identity);
const rowSandbox = { ...sampleRow(identity), node_id: 99 };
fetchMock
.mockResolvedValueOnce(jsonResponse(true, 200, { items: [rowMainnet], page: 0, size: 200, total: 1 }))
.mockResolvedValueOnce(jsonResponse(true, 200, { items: [rowSandbox], page: 0, size: 200, total: 1 }));
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toStrictEqual(rowMainnet);
await expect(findExplorerNymNodeByIdentity('SANDBOX', base, identity)).resolves.toStrictEqual(rowSandbox);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
+255
View File
@@ -0,0 +1,255 @@
import type { Network, TNodeRole } from 'src/types';
const MAINNET_BASE = 'https://mainnet-node-status-api.nymtech.cc';
const SANDBOX_BASE = 'https://sandbox-node-status-api.nymte.ch';
/** Explorer list responses are paginated; cache successful lookups to avoid repeated full scans. */
const EXPLORER_CACHE_TTL_MS = 5 * 60 * 1000;
const EXPLORER_MISS_TTL_MS = 60 * 1000;
const EXPLORER_PAGE_SIZE = 200;
type ExplorerCacheEntry = { data: ExplorerNymNodeRow | null; expiresAt: number };
const explorerCache = new Map<string, ExplorerCacheEntry>();
const explorerInflight = new Map<string, Promise<ExplorerNymNodeRow | null>>();
/**
* Clears explorer list cache and in-flight scans. Call from tests between cases; optional for a future forced refresh UX.
*/
export function clearNodeStatusExplorerCaches(): void {
explorerCache.clear();
explorerInflight.clear();
}
export function getNodeStatusBaseUrl(network?: Network): string {
if (network === 'SANDBOX' || network === 'QA') {
return SANDBOX_BASE;
}
return MAINNET_BASE;
}
const jsonHeaders = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
};
/** Subset of probe outcome used for charts (matches node-status API JSON shape). */
export type GatewayProbeOutcome = {
as_entry?: { can_connect?: boolean; can_route?: boolean } | null;
as_exit?: {
can_connect?: boolean;
can_route_ip_v4?: boolean;
can_route_ip_v6?: boolean;
can_route_ip_external_v4?: boolean;
can_route_ip_external_v6?: boolean;
} | null;
socks5?: {
can_connect_socks5?: boolean;
https_connectivity?: { https_success?: boolean; https_latency_ms?: number };
} | null;
wg?: {
can_handshake_v4?: boolean;
can_handshake_v6?: boolean;
can_query_metadata_v4?: boolean;
can_register?: boolean;
can_resolve_dns_v4?: boolean;
can_resolve_dns_v6?: boolean;
ping_hosts_performance_v4?: number;
ping_hosts_performance_v6?: number;
ping_ips_performance_v4?: number;
ping_ips_performance_v6?: number;
download_duration_milliseconds_v4?: number;
download_duration_milliseconds_v6?: number;
} | null;
lp?: {
can_connect?: boolean;
can_handshake?: boolean;
can_register?: boolean;
error?: string | null;
} | null;
};
export type GatewayStatusPayload = {
gateway_identity_key: string;
bonded: boolean;
performance: number;
routing_score: number;
last_probe_result?: {
outcome?: GatewayProbeOutcome;
} | null;
last_testrun_utc?: string | null;
last_updated_utc: string;
description?: {
moniker?: string;
website?: string;
details?: string;
security_contact?: string;
};
explorer_pretty_bond?: {
location?: {
two_letter_iso_country_code?: string;
city?: string;
region?: string;
};
};
};
/** Fields used from `GET /explorer/v3/nym-nodes` items (identity-key match). */
export type ExplorerNymNodeRow = {
identity_key: string;
node_id: number;
bonded: boolean;
/** Explorer may send percent, ratio 0-1, or a numeric string. */
uptime: number | string;
total_stake: string;
original_pledge: number;
description: {
moniker: string;
website: string;
details: string;
security_contact: string;
};
geoip?: {
city?: string;
country?: string;
region?: string;
two_letter_iso_country_code?: string;
} | null;
rewarding_details?: {
unique_delegations?: number;
} | null;
};
type PagedExplorerNymNodes = {
items: ExplorerNymNodeRow[];
page: number;
size: number;
total: number;
};
function explorerCacheKey(network: Network | undefined, identityKey: string): string {
return `${network ?? 'MAINNET'}:${identityKey}`;
}
async function fetchExplorerNymNodesPage(baseUrl: string, page: number, size: number): Promise<PagedExplorerNymNodes> {
const root = baseUrl.replace(/\/$/, '');
const url = `${root}/explorer/v3/nym-nodes?page=${page}&size=${size}`;
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
if (!response.ok) {
throw new Error(`Explorer nym-nodes request failed (${response.status})`);
}
return response.json() as Promise<PagedExplorerNymNodes>;
}
/**
* Paginates `GET /explorer/v3/nym-nodes` until `identity_key` matches or the list ends.
* Results are cached per network + identity (hits: 5 min, misses: 60 s). Concurrent calls share one in-flight scan.
*/
export function findExplorerNymNodeByIdentity(
network: Network | undefined,
baseUrl: string,
identityKey: string,
): Promise<ExplorerNymNodeRow | null> {
const key = explorerCacheKey(network, identityKey);
const now = Date.now();
const cached = explorerCache.get(key);
if (cached && cached.expiresAt > now) {
return Promise.resolve(cached.data);
}
const pending = explorerInflight.get(key);
if (pending) {
return pending;
}
const promise = (async (): Promise<ExplorerNymNodeRow | null> => {
try {
let page = 0;
let total = Number.POSITIVE_INFINITY;
while (page * EXPLORER_PAGE_SIZE < total) {
// eslint-disable-next-line no-await-in-loop -- sequential pagination required
const data = await fetchExplorerNymNodesPage(baseUrl, page, EXPLORER_PAGE_SIZE);
total = data.total;
const found = data.items.find((item) => item.identity_key === identityKey);
if (found) {
explorerCache.set(key, { data: found, expiresAt: Date.now() + EXPLORER_CACHE_TTL_MS });
return found;
}
const totalPages = Math.ceil(data.total / EXPLORER_PAGE_SIZE);
if (page >= totalPages - 1 || data.items.length < EXPLORER_PAGE_SIZE) {
break;
}
page += 1;
}
explorerCache.set(key, { data: null, expiresAt: Date.now() + EXPLORER_MISS_TTL_MS });
return null;
} finally {
explorerInflight.delete(key);
}
})();
explorerInflight.set(key, promise);
return promise;
}
export function isGatewayRole(role: TNodeRole | undefined): boolean {
return role === 'entryGateway' || role === 'exitGateway';
}
/**
* When the status API lists this identity as a bonded gateway, returns the payload.
* Otherwise `null` (404, not bonded, or identity mismatch) so callers can fall back to explorer / mixnode paths.
* Still throws on other HTTP errors so real failures surface.
*/
export async function fetchGatewayStatusIfBonded(
baseUrl: string,
identityKey: string,
): Promise<GatewayStatusPayload | null> {
const root = baseUrl.replace(/\/$/, '');
const url = `${root}/v2/gateways/${encodeURIComponent(identityKey)}`;
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
// Mixnodes are not in the gateway index. The API returns 404 or, for some identities, 400 with a short body.
// Treat both as "no gateway row" so the wallet can fall back to explorer. Other 4xx are unexpected; 5xx throws below.
if (response.status === 404 || response.status === 400) {
return null;
}
if (!response.ok) {
throw new Error(`Gateway status request failed (${response.status})`);
}
const data = (await response.json()) as GatewayStatusPayload;
if (!data.bonded || data.gateway_identity_key !== identityKey) {
return null;
}
return data;
}
export async function fetchGatewayStatus(baseUrl: string, identityKey: string): Promise<GatewayStatusPayload> {
const g = await fetchGatewayStatusIfBonded(baseUrl, identityKey);
if (!g) {
throw new Error('Gateway status request failed (404)');
}
return g;
}
/** Parse API `total_stake` string (base unym) to NYM display amount. */
export function parseTotalStakeToNymAmount(totalStake: string): number {
const n = Number.parseFloat(totalStake);
if (!Number.isFinite(n)) {
return 0;
}
return n / 1_000_000;
}
/**
* API may return 0-100 (percent), 0-1 (ratio), or numeric strings. Values in [0, 1] are treated as a ratio.
*/
export function normalizeExplorerUptimePercent(raw: unknown): number {
const n = typeof raw === 'string' ? Number.parseFloat(raw.trim()) : Number(raw);
if (!Number.isFinite(n) || n < 0) {
return 0;
}
if (n <= 1) {
return Math.min(100, n * 100);
}
return Math.min(100, n);
}
@@ -0,0 +1,94 @@
import { probeGroupsForChart, socks5LatencyMs, wgComparisonBars } from './nodeStatusCharts';
describe('probeGroupsForChart', () => {
it('returns empty array when outcome is missing', () => {
expect(probeGroupsForChart(undefined)).toStrictEqual([]);
expect(probeGroupsForChart(null)).toStrictEqual([]);
});
it('aggregates entry booleans', () => {
expect(
probeGroupsForChart({
as_entry: { can_connect: true, can_route: false },
}),
).toStrictEqual([{ name: 'Entry', passed: 1, total: 2, pctPassed: 50 }]);
});
it('skips entry group when no boolean fields are present', () => {
expect(probeGroupsForChart({ as_entry: {} })).toStrictEqual([]);
});
it('aggregates exit routing flags', () => {
const groups = probeGroupsForChart({
as_exit: {
can_connect: true,
can_route_ip_v4: false,
can_route_ip_v6: true,
},
});
expect(groups).toContainEqual({ name: 'Exit', passed: 2, total: 3, pctPassed: 67 });
});
it('includes SOCKS5 when connectivity flags exist', () => {
expect(
probeGroupsForChart({
socks5: {
can_connect_socks5: true,
https_connectivity: { https_success: false, https_latency_ms: 12 },
},
}),
).toStrictEqual([{ name: 'SOCKS5', passed: 1, total: 2, pctPassed: 50 }]);
});
it('includes WireGuard and LP groups from booleans', () => {
const groups = probeGroupsForChart({
wg: { can_handshake_v4: true, can_handshake_v6: false },
lp: { can_connect: true, can_handshake: true, can_register: false },
});
expect(groups).toContainEqual({ name: 'WireGuard', passed: 1, total: 2, pctPassed: 50 });
expect(groups).toContainEqual({ name: 'LP', passed: 2, total: 3, pctPassed: 67 });
});
});
describe('wgComparisonBars', () => {
it('prefers download duration bars when present', () => {
expect(
wgComparisonBars({
wg: { download_duration_milliseconds_v4: 120, download_duration_milliseconds_v6: 200 },
}),
).toStrictEqual([
{ name: 'IPv4 download', value: 120, kind: 'milliseconds' },
{ name: 'IPv6 download', value: 200, kind: 'milliseconds' },
]);
});
it('falls back to ping performance as percent', () => {
expect(
wgComparisonBars({
wg: { ping_ips_performance_v4: 0.88, ping_ips_performance_v6: 0.5 },
}),
).toStrictEqual([
{ name: 'IPv4 ping success', value: 88, kind: 'percent' },
{ name: 'IPv6 ping success', value: 50, kind: 'percent' },
]);
});
it('returns empty when wg is absent', () => {
expect(wgComparisonBars(undefined)).toStrictEqual([]);
});
});
describe('socks5LatencyMs', () => {
it('returns latency when numeric', () => {
expect(
socks5LatencyMs({
socks5: { https_connectivity: { https_latency_ms: 42 } },
}),
).toBe(42);
});
it('returns undefined when missing or non-numeric', () => {
expect(socks5LatencyMs(undefined)).toBeUndefined();
expect(socks5LatencyMs({ socks5: {} })).toBeUndefined();
});
});
+159
View File
@@ -0,0 +1,159 @@
import type { GatewayProbeOutcome } from './nodeStatus';
/** Measures from WireGuard probe: either download latency (ms) or ping success (0-100 %). */
export type WgMeasureBar = { name: string; value: number; kind: 'milliseconds' | 'percent' };
/** Per probe category: percent of checks that passed (0-100). */
export type ProbeGroupBar = { name: string; pctPassed: number; passed: number; total: number };
export function probeGroupsForChart(outcome: GatewayProbeOutcome | undefined | null): ProbeGroupBar[] {
const groups: ProbeGroupBar[] = [];
const pushGroup = (name: string, p: number, t: number) => {
if (t === 0) {
return;
}
groups.push({
name,
passed: p,
total: t,
pctPassed: Math.round((p / t) * 100),
});
};
if (outcome?.as_entry) {
const e = outcome.as_entry;
let p = 0;
let t = 0;
if (typeof e.can_connect === 'boolean') {
t += 1;
if (e.can_connect) {
p += 1;
}
}
if (typeof e.can_route === 'boolean') {
t += 1;
if (e.can_route) {
p += 1;
}
}
pushGroup('Entry', p, t);
}
if (outcome?.as_exit) {
const x = outcome.as_exit;
const keys = [
'can_connect',
'can_route_ip_v4',
'can_route_ip_v6',
'can_route_ip_external_v4',
'can_route_ip_external_v6',
] as const;
const exitCounts = keys.reduce(
(acc, k) => {
const v = x[k];
if (typeof v === 'boolean') {
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
}
return acc;
},
{ p: 0, t: 0 },
);
pushGroup('Exit', exitCounts.p, exitCounts.t);
}
if (outcome?.socks5) {
const s = outcome.socks5;
let p = 0;
let t = 0;
if (typeof s.can_connect_socks5 === 'boolean') {
t += 1;
if (s.can_connect_socks5) {
p += 1;
}
}
if (s.https_connectivity && typeof s.https_connectivity.https_success === 'boolean') {
t += 1;
if (s.https_connectivity.https_success) {
p += 1;
}
}
pushGroup('SOCKS5', p, t);
}
if (outcome?.wg) {
const w = outcome.wg;
const boolKeys = [
'can_handshake_v4',
'can_handshake_v6',
'can_query_metadata_v4',
'can_register',
'can_resolve_dns_v4',
'can_resolve_dns_v6',
] as const;
const wgCounts = boolKeys.reduce(
(acc, k) => {
const v = w[k];
if (typeof v === 'boolean') {
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
}
return acc;
},
{ p: 0, t: 0 },
);
pushGroup('WireGuard', wgCounts.p, wgCounts.t);
}
if (outcome?.lp) {
const l = outcome.lp;
const lpKeys = ['can_connect', 'can_handshake', 'can_register'] as const;
const lpCounts = lpKeys.reduce(
(acc, k) => {
const v = l[k];
if (typeof v === 'boolean') {
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
}
return acc;
},
{ p: 0, t: 0 },
);
pushGroup('LP', lpCounts.p, lpCounts.t);
}
return groups;
}
/** Bars for WG download latency (ms) or ping success (percent). Only one family is returned per probe. */
export function wgComparisonBars(outcome: GatewayProbeOutcome | undefined | null): WgMeasureBar[] {
const w = outcome?.wg;
if (!w) {
return [];
}
const v4 = w.download_duration_milliseconds_v4;
const v6 = w.download_duration_milliseconds_v6;
if (typeof v4 === 'number' || typeof v6 === 'number') {
const out: WgMeasureBar[] = [];
if (typeof v4 === 'number') {
out.push({ name: 'IPv4 download', value: v4, kind: 'milliseconds' });
}
if (typeof v6 === 'number') {
out.push({ name: 'IPv6 download', value: v6, kind: 'milliseconds' });
}
return out;
}
const p4 = w.ping_ips_performance_v4;
const p6 = w.ping_ips_performance_v6;
const out: WgMeasureBar[] = [];
if (typeof p4 === 'number') {
out.push({ name: 'IPv4 ping success', value: Math.round(p4 * 100), kind: 'percent' });
}
if (typeof p6 === 'number') {
out.push({ name: 'IPv6 ping success', value: Math.round(p6 * 100), kind: 'percent' });
}
return out;
}
export function socks5LatencyMs(outcome: GatewayProbeOutcome | undefined | null): number | undefined {
const ms = outcome?.socks5?.https_connectivity?.https_latency_ms;
return typeof ms === 'number' ? ms : undefined;
}
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
@@ -11,9 +11,22 @@ import { TBondedNymNode } from 'src/requests/nymNodeDetails';
import { Node as NodeIcon } from '../../svg-icons/node';
import { Cell, Header, NodeTable } from './NodeTable';
import { BondedNymNodeActions, TBondedNymNodeActions } from './BondedNymNodeActions';
import { NodeOperatorInsights, type NodeStatusMetadata } from './NodeOperatorInsights';
const textWhenNotName = 'This node has not yet set a name';
/** Wallet default name vs legacy copy in BondedNymNode. */
function isUnsetNodeName(name: string | undefined): boolean {
if (!name) {
return true;
}
return (
name.includes('Name has not been set') ||
name.includes(textWhenNotName) ||
name.toLowerCase().includes('not been set')
);
}
const headers: Header[] = [
{
header: 'Stake',
@@ -66,6 +79,7 @@ export const BondedNymNode = ({
onActionSelect: (action: TBondedNymNodeActions) => void;
}) => {
const [nextEpoch, setNextEpoch] = useState<string | Error>();
const [statusMeta, setStatusMeta] = useState<NodeStatusMetadata | null>(null);
const navigate = useNavigate();
const {
name,
@@ -79,8 +93,13 @@ export const BondedNymNode = ({
delegators,
identityKey,
host,
uptime,
} = nymnode;
const handleStatusLoaded = useCallback((meta: NodeStatusMetadata) => {
setStatusMeta(meta);
}, []);
const getNextInterval = async () => {
try {
const { nextEpoch: newNextEpoch } = await getIntervalAsDate();
@@ -136,22 +155,41 @@ export const BondedNymNode = ({
getNextInterval();
}, []);
useEffect(() => {
setStatusMeta(null);
}, [identityKey]);
const showWalletName = !isUnsetNodeName(name);
const apiMoniker = statusMeta?.displayMoniker?.trim();
const showApiMoniker = !showWalletName && Boolean(apiMoniker);
const locationChip = statusMeta?.locationLabel;
return (
<Stack gap={2}>
<NymCard
borderless
title={
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={2}>
<Stack gap={2}>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
<Typography variant="h5" fontWeight={600}>
Nym node
</Typography>
{locationChip ? (
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
{locationChip}
</Typography>
) : null}
</Box>
{name?.includes(textWhenNotName) ? null : (
{showWalletName ? (
<Typography fontWeight="regular" variant="h6" width="fit-content">
{name}
</Typography>
)}
) : null}
{showApiMoniker ? (
<Typography fontWeight="regular" variant="h6" width="fit-content" color="text.primary">
{apiMoniker}
</Typography>
) : null}
<Tooltip title={host} placement="top" arrow>
<Box width="fit-content">
<IdentityKey identityKey={identityKey} />
@@ -190,17 +228,26 @@ export const BondedNymNode = ({
</Box>
}
>
<Stack spacing={3} sx={{ width: '100%', minWidth: 0 }}>
<Box sx={{ width: '100%', minWidth: 0 }}>
<NodeTable headers={headers} cells={cells} />
{network && (
{network ? (
<Typography sx={{ mt: 2, fontSize: 'small' }}>
Check more stats of your node on the{' '}
<Link href={`${urls(network).networkExplorer}/nodes/${nodeId}`} target="_blank">
explorer
</Link>
</Typography>
)}
) : null}
</Box>
<NodeOperatorInsights
network={network}
identityKey={identityKey}
walletUptime={uptime}
onStatusLoaded={handleStatusLoaded}
/>
</Stack>
</NymCard>
{/* <NodeStats bondedNode={nymnode} /> */}
</Stack>
);
};
@@ -0,0 +1,574 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ExpandMore } from '@mui/icons-material';
import {
Alert,
Box,
Collapse,
Divider,
Grid,
IconButton,
LinearProgress,
Skeleton,
Stack,
Tooltip,
Typography,
} from '@mui/material';
import { alpha, useTheme, type Theme } from '@mui/material/styles';
import type { Network } from 'src/types';
import {
fetchGatewayStatusIfBonded,
findExplorerNymNodeByIdentity,
getNodeStatusBaseUrl,
normalizeExplorerUptimePercent,
type ExplorerNymNodeRow,
type GatewayStatusPayload,
} from 'src/api/nodeStatus';
import { probeGroupsForChart, socks5LatencyMs, wgComparisonBars, type ProbeGroupBar } from 'src/api/nodeStatusCharts';
const OPERATOR_INSIGHTS_EXPANDED_KEY = 'nymWallet.bonding.operatorInsightsExpanded';
function readOperatorInsightsExpanded(): boolean {
try {
const v = localStorage.getItem(OPERATOR_INSIGHTS_EXPANDED_KEY);
if (v === null) {
return true;
}
return v === 'true';
} catch {
return true;
}
}
type InsightsCollapseFrameProps = {
title: string;
subtitle: string;
children: React.ReactNode;
};
const InsightsCollapseFrame = ({ title, subtitle, children }: InsightsCollapseFrameProps) => {
const theme = useTheme();
const [open, setOpen] = useState(readOperatorInsightsExpanded);
const toggle = useCallback(() => {
setOpen((prev) => {
const next = !prev;
try {
localStorage.setItem(OPERATOR_INSIGHTS_EXPANDED_KEY, String(next));
} catch {
/* ignore */
}
return next;
});
}, []);
return (
<Box sx={{ width: '100%', mt: { xs: 1, md: 0 } }}>
<Stack
direction="row"
alignItems="flex-start"
justifyContent="space-between"
sx={{
gap: 1,
cursor: 'pointer',
userSelect: 'none',
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
borderRadius: 1,
},
}}
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
role="button"
tabIndex={0}
aria-expanded={open}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h6" component="h2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{subtitle}
</Typography>
</Box>
<IconButton
size="small"
aria-label={open ? 'Collapse operator insights' : 'Expand operator insights'}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
>
<ExpandMore
sx={{
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shortest }),
}}
/>
</IconButton>
</Stack>
<Collapse in={open}>
<Box sx={{ pt: 2.5 }}>{children}</Box>
</Collapse>
</Box>
);
};
const ACCENT = '#8482FD';
export type NodeStatusMetadata = {
displayMoniker?: string;
locationLabel?: string;
};
export type NodeOperatorInsightsProps = {
network?: Network;
identityKey: string;
walletUptime?: number;
onStatusLoaded?: (meta: NodeStatusMetadata) => void;
};
function chartCardSx(theme: Theme) {
return {
borderRadius: 2,
border: `1px solid ${theme.palette.divider}`,
bgcolor:
theme.palette.mode === 'dark'
? theme.palette.nym.nymWallet.nav.background
: theme.palette.nym.nymWallet.background.subtle,
p: 2,
height: '100%',
} as const;
}
/** Softer digest surface for gateway (single panel, less contrast than nested cards). */
function gatewayDigestSx(theme: Theme) {
return {
borderRadius: 2,
border: `1px solid ${alpha(theme.palette.divider, 0.85)}`,
background:
theme.palette.mode === 'dark'
? `linear-gradient(145deg, ${alpha(theme.palette.nym.nymWallet.nav.background, 0.97)} 0%, ${alpha(
ACCENT,
0.04,
)} 100%)`
: theme.palette.nym.nymWallet.background.subtle,
p: { xs: 2, sm: 2.5 },
overflow: 'hidden',
} as const;
}
const GatewayProbeRow = ({ theme, row, isLast }: { theme: Theme; row: ProbeGroupBar; isLast: boolean }) => {
const { name, pctPassed, passed, total } = row;
let barColor: string;
if (pctPassed >= 100) {
barColor = ACCENT;
} else if (pctPassed > 0) {
barColor = alpha(ACCENT, 0.65);
} else {
barColor = alpha(theme.palette.text.disabled, 0.35);
}
return (
<Tooltip title={`${passed} of ${total} checks passed`} arrow placement="top" enterDelay={400}>
<Box sx={{ width: '100%' }}>
<Stack
direction="row"
alignItems="center"
spacing={1.5}
sx={{
py: 1,
borderBottom: isLast ? 'none' : `1px solid ${alpha(theme.palette.divider, 0.5)}`,
}}
>
<Typography variant="body2" sx={{ minWidth: 76, color: 'text.secondary', fontWeight: 500 }}>
{name}
</Typography>
<Box sx={{ flex: 1, minWidth: 0 }}>
<LinearProgress
variant="determinate"
value={pctPassed}
sx={{
height: 5,
borderRadius: 2.5,
bgcolor: alpha(ACCENT, theme.palette.mode === 'dark' ? 0.1 : 0.14),
'& .MuiLinearProgress-bar': {
borderRadius: 2.5,
bgcolor: barColor,
},
}}
/>
</Box>
<Typography
variant="caption"
sx={{
minWidth: 56,
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
color: 'text.secondary',
}}
>
{pctPassed}%
</Typography>
</Stack>
</Box>
</Tooltip>
);
};
export const NodeOperatorInsights: React.FC<NodeOperatorInsightsProps> = ({
network,
identityKey,
walletUptime,
onStatusLoaded,
}) => {
const theme = useTheme();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | undefined>();
const [gateway, setGateway] = useState<GatewayStatusPayload | undefined>();
const [explorerRow, setExplorerRow] = useState<ExplorerNymNodeRow | undefined>();
const [explorerMissing, setExplorerMissing] = useState(false);
useEffect(() => {
let cancelled = false;
const baseUrl = getNodeStatusBaseUrl(network);
const run = async () => {
setLoading(true);
setError(undefined);
setExplorerMissing(false);
try {
const g = await fetchGatewayStatusIfBonded(baseUrl, identityKey);
if (cancelled) {
return;
}
if (g) {
setGateway(g);
setExplorerRow(undefined);
const loc = g.explorer_pretty_bond?.location;
const locationLabel =
loc && (loc.city || loc.two_letter_iso_country_code)
? [loc.city, loc.two_letter_iso_country_code].filter(Boolean).join(', ')
: undefined;
onStatusLoaded?.({
displayMoniker: g.description?.moniker,
locationLabel,
});
return;
}
setGateway(undefined);
const row = await findExplorerNymNodeByIdentity(network, baseUrl, identityKey);
if (cancelled) {
return;
}
if (row) {
setExplorerRow(row);
setExplorerMissing(false);
const geo = row.geoip;
const locationLabel =
geo && (geo.city || geo.country) ? [geo.city, geo.country].filter(Boolean).join(', ') : undefined;
onStatusLoaded?.({
displayMoniker: row.description?.moniker,
locationLabel,
});
} else {
setExplorerRow(undefined);
setExplorerMissing(true);
onStatusLoaded?.({});
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Failed to load node status');
setGateway(undefined);
setExplorerRow(undefined);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
run().catch(() => {
/* errors surfaced via setError inside run */
});
return () => {
cancelled = true;
};
}, [network, identityKey, onStatusLoaded]);
if (loading) {
return (
<Stack spacing={2} sx={{ width: '100%' }}>
<Skeleton variant="rounded" height={120} sx={{ borderRadius: 2 }} />
<Skeleton variant="rounded" height={180} sx={{ borderRadius: 2 }} />
</Stack>
);
}
if (error) {
return (
<Alert severity="warning" sx={{ borderRadius: 2 }}>
{error}
</Alert>
);
}
if (gateway) {
const outcome = gateway.last_probe_result?.outcome;
const probeGroups = probeGroupsForChart(outcome);
const wgBars = wgComparisonBars(outcome);
const latency = socks5LatencyMs(outcome);
const perf = Math.min(100, Math.max(0, gateway.performance));
const wgKind = wgBars[0]?.kind;
const socksGroup = probeGroups.find((g) => g.name === 'SOCKS5');
return (
<InsightsCollapseFrame
title="Operator insights"
subtitle="Quiet snapshot of how the network last probed this gateway. Expand for detail."
>
<Box sx={gatewayDigestSx(theme)}>
<Grid container spacing={2.5} alignItems="flex-start">
<Grid item xs={12} md={4}>
<Stack spacing={2}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.75 }}
>
Performance
</Typography>
<Typography
variant="h3"
sx={{
fontWeight: 600,
color: ACCENT,
lineHeight: 1.1,
fontSize: { xs: '1.65rem', sm: '1.9rem' },
}}
>
{perf}%
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.75, lineHeight: 1.45 }}
>
Reward-weight score (0-100) from the status API.
</Typography>
<LinearProgress
variant="determinate"
value={perf}
sx={{
height: 4,
borderRadius: 2,
mt: 1.25,
bgcolor: alpha(ACCENT, 0.14),
'& .MuiLinearProgress-bar': { bgcolor: ACCENT, borderRadius: 2 },
}}
/>
</Box>
<Divider flexItem sx={{ borderColor: alpha(theme.palette.divider, 0.55) }} />
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
Routing score
</Typography>
<Typography variant="body2" sx={{ mt: 0.35, fontWeight: 500 }}>
{gateway.routing_score > 0 ? gateway.routing_score : 'Not published'}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.75, lineHeight: 1.45 }}
>
Distinct from performance. Often 0 until the network publishes it.
</Typography>
</Box>
{gateway.last_testrun_utc ? (
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
Last probe: {gateway.last_testrun_utc}
</Typography>
) : null}
</Stack>
</Grid>
<Grid item xs={12} md={8}>
<Typography
variant="caption"
color="text.secondary"
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.5 }}
>
Categories
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25, lineHeight: 1.5 }}>
Hover a row for pass counts.
</Typography>
{probeGroups.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No probe data yet.
</Typography>
) : (
<Box>
{probeGroups.map((row, i) => (
<GatewayProbeRow key={row.name} theme={theme} row={row} isLast={i === probeGroups.length - 1} />
))}
</Box>
)}
{socksGroup !== undefined && socksGroup.pctPassed < 100 ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 1.25, lineHeight: 1.5 }}
>
SOCKS5 / NR did not fully pass this run - optional path, depends on topology.
</Typography>
) : null}
{latency !== undefined ? (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.75 }}>
SOCKS5 HTTPS latency: {latency} ms
</Typography>
) : null}
</Grid>
{wgBars.length > 0 ? (
<Grid item xs={12}>
<Divider sx={{ borderColor: alpha(theme.palette.divider, 0.55), my: 0.25 }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.75 }}
>
WireGuard
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25, lineHeight: 1.5 }}>
{wgKind === 'milliseconds'
? 'Probe download time over the tunnel (ms). Lower reads faster.'
: 'ICMP success rate through the tunnel. Higher is better.'}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
{wgBars.map((b) => (
<Box
key={b.name}
sx={{
flex: 1,
borderRadius: 1.5,
px: 2,
py: 1.5,
bgcolor: alpha(theme.palette.text.primary, 0.035),
border: `1px solid ${alpha(theme.palette.divider, 0.4)}`,
}}
>
<Typography variant="caption" color="text.secondary">
{b.name}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, mt: 0.35, fontVariantNumeric: 'tabular-nums' }}>
{b.kind === 'milliseconds' ? `${b.value} ms` : `${b.value}%`}
</Typography>
</Box>
))}
</Stack>
</Grid>
) : null}
</Grid>
</Box>
</InsightsCollapseFrame>
);
}
if (explorerRow) {
const apiUptime = normalizeExplorerUptimePercent(explorerRow.uptime);
const epochUptimePct =
walletUptime !== undefined && walletUptime !== null ? normalizeExplorerUptimePercent(walletUptime) : undefined;
return (
<InsightsCollapseFrame
title="Operator insights"
subtitle="Uptime from the explorer index versus epoch uptime from the chain. Stake, delegators, and saturation stay in the table above."
>
<Box sx={chartCardSx(theme)}>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="text.secondary" fontWeight={600}>
Explorer uptime
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.5, mb: 1.25, lineHeight: 1.5 }}
>
Public index value. The API may send a 0-1 ratio (for example 0.98) - shown here as 0-100%.
</Typography>
<LinearProgress
variant="determinate"
value={apiUptime}
sx={{
height: 10,
borderRadius: 5,
bgcolor: alpha(ACCENT, 0.2),
'& .MuiLinearProgress-bar': { bgcolor: ACCENT, borderRadius: 5 },
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.75 }}>
{apiUptime.toFixed(1)}%
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="body2" color="text.secondary" fontWeight={600}>
Epoch uptime (wallet)
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', mt: 0.5, mb: 1.25, lineHeight: 1.5 }}
>
Estimate from your wallet RPC for the current epoch (same scale as the explorer bar).
</Typography>
{epochUptimePct !== undefined ? (
<>
<LinearProgress
variant="determinate"
value={epochUptimePct}
sx={{
height: 10,
borderRadius: 5,
mt: 0,
bgcolor: alpha(theme.palette.primary.main, 0.15),
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.75 }}>
{epochUptimePct.toFixed(1)}%
</Typography>
</>
) : (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Not available from the wallet for this node.
</Typography>
)}
</Grid>
</Grid>
</Box>
</InsightsCollapseFrame>
);
}
if (explorerMissing) {
return (
<InsightsCollapseFrame
title="Operator insights"
subtitle="Explorer index for your node (identity match). Cached after the first full scan."
>
<Alert severity="info" sx={{ borderRadius: 2 }}>
Your node is not in the explorer listing yet, or the identity does not match the index. New bonds can take
time to appear. Insights will load automatically once indexed.
</Alert>
</InsightsCollapseFrame>
);
}
return null;
};
@@ -22,7 +22,7 @@ export interface TableProps {
}
export const NodeTable = ({ headers, cells }: TableProps) => (
<TableContainer>
<TableContainer sx={{ overflowX: 'auto', width: '100%' }}>
<Table aria-label="node-table">
<TableHead>
<TableRow>
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Stack, Typography, SxProps, FormControlLabel, Checkbox, Alert } from '@mui/material';
import { alpha, useTheme } from '@mui/material/styles';
import Big from 'big.js';
@@ -86,14 +86,20 @@ export const SendInputModal = ({
const [errorAmount, setErrorAmount] = useState<string | undefined>();
const [errorFee, setErrorFee] = useState<string | undefined>();
const [recipientTouched, setRecipientTouched] = useState(false);
/** Avoid "Amount is required" on modal open; show empty errors only after user edits the field. */
const [amountTouched, setAmountTouched] = useState(false);
/** Focus trap can fire a spurious blur on open; delay recipient blur validation slightly. */
const [recipientBlurReady, setRecipientBlurReady] = useState(false);
const theme = useTheme();
// Calculate noAccount at the component root level instead of using useEffect
const noAccount = !balance || balance === '0' || parseFloat(balance) === 0;
const validateSendAmount = async (value: DecCoin) => {
const validateSendAmount = useCallback(
async (value: DecCoin, assumeAmountInteracted = false) => {
let newValidatedValue = true;
let errorAmountMessage;
const showEmptyFieldErrors = assumeAmountInteracted || amountTouched;
if (noAccount) {
newValidatedValue = false;
@@ -105,7 +111,7 @@ export const SendInputModal = ({
if (!value?.amount || String(value.amount).trim() === '') {
newValidatedValue = false;
errorAmountMessage = 'Amount is required';
errorAmountMessage = showEmptyFieldErrors ? 'Amount is required' : undefined;
} else {
// Skip validation for partial decimal inputs during typing
if (value.amount === '.' || value.amount.endsWith('.')) {
@@ -142,9 +148,12 @@ export const SendInputModal = ({
setIsValid(newValidatedValue);
setErrorAmount(errorAmountMessage);
return newValidatedValue;
};
},
[amountTouched, noAccount, balance, denom],
);
const validateUserFees = (fees: DecCoin) => {
const validateUserFees = useCallback(
(fees: DecCoin) => {
let feeValid = true;
let errorFeeMessage;
@@ -187,12 +196,21 @@ export const SendInputModal = ({
setFeeAmountIsValid(feeValid);
setErrorFee(errorFeeMessage);
return feeValid;
};
},
[noAccount, denom],
);
useEffect(() => {
const id = window.setTimeout(() => setRecipientBlurReady(true), 400);
return () => window.clearTimeout(id);
}, []);
useEffect(() => {
const empty: DecCoin = { amount: '', denom: denom ?? 'nym' };
validateSendAmount(amount ?? empty);
}, [amount, balance, noAccount, denom]);
validateSendAmount(amount ?? empty).catch(() => {
/* validateSendAmount only updates state */
});
}, [amount, denom, validateSendAmount]);
// Effect to validate address whenever it changes
useEffect(() => {
@@ -213,7 +231,7 @@ export const SendInputModal = ({
} else {
setFeeAmountIsValid(true);
}
}, [userFees, noAccount]);
}, [userFees, validateUserFees]);
return (
<SimpleModal
@@ -302,13 +320,23 @@ export const SendInputModal = ({
<TextFieldWithPaste
label="Recipient address"
fullWidth
onChange={(e) => onAddressChange(e.target.value)}
onBlur={() => setRecipientTouched(true)}
onChange={(e) => {
setRecipientTouched(true);
onAddressChange(e.target.value);
}}
onBlur={() => {
if (recipientBlurReady) {
setRecipientTouched(true);
}
}}
value={toAddress}
error={(recipientTouched && !toAddress.trim()) || (toAddress !== '' && !addressIsValid)}
helperText={recipientHelperText(recipientTouched, toAddress, addressIsValid)}
InputLabelProps={{ shrink: true }}
onPasteValue={onAddressChange}
onPasteValue={(v) => {
setRecipientTouched(true);
onAddressChange(v);
}}
/>
{/* Amount field with paste button */}
@@ -316,8 +344,11 @@ export const SendInputModal = ({
label="Amount"
fullWidth
onChanged={(value) => {
setAmountTouched(true);
onAmountChange(value);
validateSendAmount(value);
validateSendAmount(value, true).catch(() => {
/* validateSendAmount only updates state */
});
}}
initialValue={amount?.amount}
denom={denom}
+3 -51
View File
@@ -1,6 +1,6 @@
import React, { useContext } from 'react';
import { NymWordmark } from '@nymproject/react/logo/NymWordmark';
import { Stack, Box, Typography } from '@mui/material';
import { Stack, Box } from '@mui/material';
import { alpha, useTheme } from '@mui/material/styles';
import { AppContext } from 'src/context';
import { AppSessionLoadingOverlay, LoadingPage } from 'src/components';
@@ -24,63 +24,16 @@ export const AuthLayout: FCWithChildren = ({ children }) => {
sx={{
minHeight: '100vh',
width: '100%',
display: 'grid',
// Below 2000px: single column (sign-in card centered). At 2000px+: optional left strip of static marketing copy (not navigation).
gridTemplateColumns: '1fr',
'@media (min-width: 2000px)': {
gridTemplateColumns: 'minmax(320px, 420px) minmax(0, 1fr)',
},
alignItems: 'stretch',
bgcolor: 'background.default',
}}
>
<Box
sx={{
width: '100%',
display: { xs: 'none' },
'@media (min-width: 2000px)': {
display: 'flex',
},
flexDirection: 'column',
justifyContent: 'space-between',
px: 6,
py: 7,
borderRight: (t) => `1px solid ${t.palette.divider}`,
background: isDark ? alpha(theme.palette.common.black, 0.2) : theme.palette.nym.nymWallet.background.subtle,
}}
>
<Stack spacing={3}>
<NymWordmark width={88} fill={wordmarkFill} />
<Stack spacing={1}>
<Typography variant="overline" sx={{ color: 'text.secondary', letterSpacing: 1.4 }}>
Secure wallet access
</Typography>
<Typography variant="h3" sx={{ maxWidth: 320, lineHeight: 1.1, color: 'text.primary' }}>
Access tokens, staking, and node operations in one place.
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 340 }}>
The wallet keeps the same trusted Nym visual language while making key tasks easier to scan and complete.
</Typography>
</Stack>
</Stack>
<Step />
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
px: { xs: 2, md: 4 },
py: { xs: 4, md: 6 },
bgcolor: 'background.default',
}}
>
<Stack spacing={3} alignItems="center" sx={{ width: '100%', maxWidth: 1080 }}>
<Box
sx={{
display: 'block',
'@media (min-width: 2000px)': { display: 'none' },
}}
>
<Box sx={{ display: 'block' }}>
<NymWordmark width={75} fill={wordmarkFill} />
</Box>
<Box
@@ -103,6 +56,5 @@ export const AuthLayout: FCWithChildren = ({ children }) => {
</Box>
</Stack>
</Box>
</Box>
);
};
+1 -1
View File
@@ -201,7 +201,7 @@ export const Bonding = () => {
return (
<ErrorModal
open
title="An error occured, please check logs for details"
title="An error occurred, please check logs for details"
message={error}
onClose={() => refresh()}
/>
+4
View File
@@ -1,7 +1,9 @@
import React, { useMemo, useEffect } from 'react';
import { CacheProvider } from '@emotion/react';
import { CssBaseline, PaletteMode } from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { getDesignTokens } from './theme';
import { muiEmotionCache } from './emotionCache';
import '@assets/fonts/non-variable/fonts.css';
let fontsInitialized = false;
@@ -43,10 +45,12 @@ export const NymWalletThemeWithMode: FCWithChildren<{ mode: PaletteMode; childre
const theme = useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
return (
<CacheProvider value={muiEmotionCache}>
<ThemeProvider theme={theme}>
<CssBaseline />
<FontLoader />
{children}
</ThemeProvider>
</CacheProvider>
);
};
+12
View File
@@ -0,0 +1,12 @@
import createCache from '@emotion/cache';
/**
* WKWebView (Tauri) can silently fail Emotion's production `insertRule` path (`speedy: true`),
* which shows up as React/MUI rendering with almost no CSS while scripts run.
* `speedy: false` uses `<style>` node insertion, which is reliable here.
*/
export const muiEmotionCache = createCache({
key: 'mui',
prepend: true,
speedy: false,
});
+4
View File
@@ -1,9 +1,11 @@
import React, { useContext } from 'react';
import { CacheProvider } from '@emotion/react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { getDesignTokens } from './theme';
import { AppContext } from '../context/main';
import { NymWalletThemeWithMode } from './NymWalletTheme';
import { muiEmotionCache } from './emotionCache';
/**
* Provides the theme for the Network Explorer by reacting to the light/dark mode choice stored in the app context.
@@ -20,9 +22,11 @@ export const AuthTheme: FCWithChildren = ({ children }) => {
// Uses dark mode by default for auth screens
const theme = createTheme(getDesignTokens('dark'));
return (
<CacheProvider value={muiEmotionCache}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</CacheProvider>
);
};
+1
View File
@@ -6,6 +6,7 @@
},
"include": [
"webpack.*.js",
"scripts/**/*.js",
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
+17 -3
View File
@@ -4,12 +4,14 @@ const { webpackCommon } = require('@nymproject/webpack');
const resolveFromWallet = (request) => require.resolve(request, { paths: [__dirname] });
/** Package root (folder), not main entry - required so `@mui/material/Button` style subpath imports resolve. */
const resolveMuiPackageRoot = (pkg) =>
path.dirname(require.resolve(`${pkg}/package.json`, { paths: [__dirname, path.resolve(__dirname, '..')] }));
const muiSystemDir = path.dirname(
require.resolve('@mui/system/package.json', { paths: [__dirname, path.resolve(__dirname, '..')] }),
);
const muiStyledEngineV5 = path.dirname(
require.resolve('@mui/styled-engine/package.json', { paths: [muiSystemDir] }),
);
const muiStyledEngineV5 = path.dirname(require.resolve('@mui/styled-engine/package.json', { paths: [muiSystemDir] }));
const entry = {
auth: path.resolve(__dirname, 'src/auth.tsx'), // JS bundle for sign up/sign in
@@ -36,7 +38,19 @@ module.exports = mergeWithRules({
// Yarn workspaces hoist deps to ../node_modules; resolve Tauri packages from there too.
modules: [path.resolve(__dirname, 'node_modules'), path.resolve(__dirname, '../node_modules')],
alias: {
// Single Emotion instance so CacheProvider matches MUI's styled engine (workspaces can duplicate).
'@emotion/react': resolveFromWallet('@emotion/react'),
'@emotion/styled': resolveFromWallet('@emotion/styled'),
'@emotion/cache': resolveFromWallet('@emotion/cache'),
'@mui/styled-engine': muiStyledEngineV5,
'@mui/material': resolveMuiPackageRoot('@mui/material'),
'@mui/system': resolveMuiPackageRoot('@mui/system'),
'@mui/private-theming': resolveMuiPackageRoot('@mui/private-theming'),
'@mui/utils': resolveMuiPackageRoot('@mui/utils'),
'@mui/base': resolveMuiPackageRoot('@mui/base'),
'@mui/styles': resolveMuiPackageRoot('@mui/styles'),
'@mui/icons-material': resolveMuiPackageRoot('@mui/icons-material'),
'@mui/lab': resolveMuiPackageRoot('@mui/lab'),
react$: resolveFromWallet('react'),
'react-dom$': resolveFromWallet('react-dom'),
'react-dom/client': resolveFromWallet('react-dom/client'),
+21
View File
@@ -3,12 +3,33 @@ const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'production',
// Tauri + WKWebView: `publicPath: 'auto'` resolves `__webpack_require__.p` from `document.currentScript`,
// which is unreliable here and can 404 async chunks so MUI/Emotion never runs (unstyled UI).
// Relative `./` matches `dist/*.html` + sibling chunk files under the same custom HTTPS origin.
output: {
publicPath: './',
},
node: {
__dirname: false,
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
cacheGroups: {
framework: {
name: 'framework',
test: /[\\/]node_modules[\\/](react|react-dom|scheduler|@emotion[\\/](react|styled|cache|sheet|serialize|utils|hash|memoize|weak-memoize|use-insertion-effect-with-fallbacks)|@mui)[\\/]/,
priority: 40,
enforce: true,
reuseExistingChunk: true,
},
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 10,
reuseExistingChunk: true,
},
},
},
},
});