diff --git a/nym-wallet/package.json b/nym-wallet/package.json index 6b8d15d9bf..82d997baba 100644 --- a/nym-wallet/package.json +++ b/nym-wallet/package.json @@ -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", diff --git a/nym-wallet/scripts/check-mui-singletons.js b/nym-wallet/scripts/check-mui-singletons.js new file mode 100644 index 0000000000..7ec57055bd --- /dev/null +++ b/nym-wallet/scripts/check-mui-singletons.js @@ -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); diff --git a/nym-wallet/src-tauri/Cargo.toml b/nym-wallet/src-tauri/Cargo.toml index db360fbfb3..02cc3d9ebd 100644 --- a/nym-wallet/src-tauri/Cargo.toml +++ b/nym-wallet/src-tauri/Cargo.toml @@ -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" diff --git a/nym-wallet/src-tauri/capabilities/main-capability.json b/nym-wallet/src-tauri/capabilities/main-capability.json index e616cf991b..2c4cd94042 100644 --- a/nym-wallet/src-tauri/capabilities/main-capability.json +++ b/nym-wallet/src-tauri/capabilities/main-capability.json @@ -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", diff --git a/nym-wallet/src-tauri/gen/schemas/capabilities.json b/nym-wallet/src-tauri/gen/schemas/capabilities.json index e45d08051a..db62dd6b89 100644 --- a/nym-wallet/src-tauri/gen/schemas/capabilities.json +++ b/nym-wallet/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/nym-wallet/src-tauri/tauri.conf.json b/nym-wallet/src-tauri/tauri.conf.json index b760597d1b..29c5400e6d 100644 --- a/nym-wallet/src-tauri/tauri.conf.json +++ b/nym-wallet/src-tauri/tauri.conf.json @@ -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:", diff --git a/nym-wallet/src/api/nodeStatus.test.ts b/nym-wallet/src/api/nodeStatus.test.ts new file mode 100644 index 0000000000..c28fce86f5 --- /dev/null +++ b/nym-wallet/src/api/nodeStatus.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + let resolveFetch!: (v: Response) => void; + const fetchPromise = new Promise((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; + 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); + }); +}); diff --git a/nym-wallet/src/api/nodeStatus.ts b/nym-wallet/src/api/nodeStatus.ts new file mode 100644 index 0000000000..3477a8b43b --- /dev/null +++ b/nym-wallet/src/api/nodeStatus.ts @@ -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(); +const explorerInflight = new Map>(); + +/** + * 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 { + 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; +} + +/** + * 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 { + 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 => { + 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 { + 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 { + 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); +} diff --git a/nym-wallet/src/api/nodeStatusCharts.test.ts b/nym-wallet/src/api/nodeStatusCharts.test.ts new file mode 100644 index 0000000000..846cd17369 --- /dev/null +++ b/nym-wallet/src/api/nodeStatusCharts.test.ts @@ -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(); + }); +}); diff --git a/nym-wallet/src/api/nodeStatusCharts.ts b/nym-wallet/src/api/nodeStatusCharts.ts new file mode 100644 index 0000000000..bb2eed17c8 --- /dev/null +++ b/nym-wallet/src/api/nodeStatusCharts.ts @@ -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; +} diff --git a/nym-wallet/src/components/Bonding/BondedNymNode.tsx b/nym-wallet/src/components/Bonding/BondedNymNode.tsx index c8ee8eb0bb..20c953275c 100644 --- a/nym-wallet/src/components/Bonding/BondedNymNode.tsx +++ b/nym-wallet/src/components/Bonding/BondedNymNode.tsx @@ -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(); + const [statusMeta, setStatusMeta] = useState(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 ( - + + Nym node + {locationChip ? ( + + {locationChip} + + ) : null} - {name?.includes(textWhenNotName) ? null : ( + {showWalletName ? ( {name} - )} + ) : null} + {showApiMoniker ? ( + + {apiMoniker} + + ) : null} @@ -190,17 +228,26 @@ export const BondedNymNode = ({ } > - - {network && ( - - Check more stats of your node on the{' '} - - explorer - - - )} + + + + {network ? ( + + Check more stats of your node on the{' '} + + explorer + + + ) : null} + + + - {/* */} ); }; diff --git a/nym-wallet/src/components/Bonding/NodeOperatorInsights.tsx b/nym-wallet/src/components/Bonding/NodeOperatorInsights.tsx new file mode 100644 index 0000000000..872972c27e --- /dev/null +++ b/nym-wallet/src/components/Bonding/NodeOperatorInsights.tsx @@ -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 ( + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }} + role="button" + tabIndex={0} + aria-expanded={open} + > + + + {title} + + + {subtitle} + + + { + e.stopPropagation(); + toggle(); + }} + > + + + + + {children} + + + ); +}; + +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 ( + + + + + {name} + + + + + + {pctPassed}% + + + + + ); +}; + +export const NodeOperatorInsights: React.FC = ({ + network, + identityKey, + walletUptime, + onStatusLoaded, +}) => { + const theme = useTheme(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [gateway, setGateway] = useState(); + const [explorerRow, setExplorerRow] = useState(); + 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 ( + + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + 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 ( + + + + + + + + Performance + + + {perf}% + + + Reward-weight score (0-100) from the status API. + + + + + + + Routing score + + + {gateway.routing_score > 0 ? gateway.routing_score : 'Not published'} + + + Distinct from performance. Often 0 until the network publishes it. + + + {gateway.last_testrun_utc ? ( + + Last probe: {gateway.last_testrun_utc} + + ) : null} + + + + + Categories + + + Hover a row for pass counts. + + {probeGroups.length === 0 ? ( + + No probe data yet. + + ) : ( + + {probeGroups.map((row, i) => ( + + ))} + + )} + {socksGroup !== undefined && socksGroup.pctPassed < 100 ? ( + + SOCKS5 / NR did not fully pass this run - optional path, depends on topology. + + ) : null} + {latency !== undefined ? ( + + SOCKS5 HTTPS latency: {latency} ms + + ) : null} + + {wgBars.length > 0 ? ( + + + + WireGuard + + + {wgKind === 'milliseconds' + ? 'Probe download time over the tunnel (ms). Lower reads faster.' + : 'ICMP success rate through the tunnel. Higher is better.'} + + + {wgBars.map((b) => ( + + + {b.name} + + + {b.kind === 'milliseconds' ? `${b.value} ms` : `${b.value}%`} + + + ))} + + + ) : null} + + + + ); + } + + if (explorerRow) { + const apiUptime = normalizeExplorerUptimePercent(explorerRow.uptime); + const epochUptimePct = + walletUptime !== undefined && walletUptime !== null ? normalizeExplorerUptimePercent(walletUptime) : undefined; + + return ( + + + + + + Explorer uptime + + + Public index value. The API may send a 0-1 ratio (for example 0.98) - shown here as 0-100%. + + + + {apiUptime.toFixed(1)}% + + + + + Epoch uptime (wallet) + + + Estimate from your wallet RPC for the current epoch (same scale as the explorer bar). + + {epochUptimePct !== undefined ? ( + <> + + + {epochUptimePct.toFixed(1)}% + + + ) : ( + + Not available from the wallet for this node. + + )} + + + + + ); + } + + if (explorerMissing) { + return ( + + + 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. + + + ); + } + + return null; +}; diff --git a/nym-wallet/src/components/Bonding/NodeTable.tsx b/nym-wallet/src/components/Bonding/NodeTable.tsx index 9fd1692eba..76e2ce2166 100644 --- a/nym-wallet/src/components/Bonding/NodeTable.tsx +++ b/nym-wallet/src/components/Bonding/NodeTable.tsx @@ -22,7 +22,7 @@ export interface TableProps { } export const NodeTable = ({ headers, cells }: TableProps) => ( - + diff --git a/nym-wallet/src/components/Send/SendInputModal.tsx b/nym-wallet/src/components/Send/SendInputModal.tsx index e35570e40b..b1ccce9b10 100644 --- a/nym-wallet/src/components/Send/SendInputModal.tsx +++ b/nym-wallet/src/components/Send/SendInputModal.tsx @@ -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,113 +86,131 @@ export const SendInputModal = ({ const [errorAmount, setErrorAmount] = useState(); const [errorFee, setErrorFee] = useState(); 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) => { - let newValidatedValue = true; - let errorAmountMessage; + const validateSendAmount = useCallback( + async (value: DecCoin, assumeAmountInteracted = false) => { + let newValidatedValue = true; + let errorAmountMessage; + const showEmptyFieldErrors = assumeAmountInteracted || amountTouched; - if (noAccount) { - newValidatedValue = false; - errorAmountMessage = 'You need to acquire NYMs before sending. Try using the Buy section in the app.'; - setIsValid(newValidatedValue); - setErrorAmount(errorAmountMessage); - return newValidatedValue; - } - - if (!value?.amount || String(value.amount).trim() === '') { - newValidatedValue = false; - errorAmountMessage = 'Amount is required'; - } else { - // Skip validation for partial decimal inputs during typing - if (value.amount === '.' || value.amount.endsWith('.')) { + if (noAccount) { newValidatedValue = false; + errorAmountMessage = 'You need to acquire NYMs before sending. Try using the Buy section in the app.'; setIsValid(newValidatedValue); - setErrorAmount(undefined); + setErrorAmount(errorAmountMessage); return newValidatedValue; } - if (!(await validateAmount(value.amount, '0'))) { + if (!value?.amount || String(value.amount).trim() === '') { newValidatedValue = false; - errorAmountMessage = 'Please enter a valid amount'; - } else if (Number(value.amount) < Number(MIN_AMOUNT_TO_SEND)) { - newValidatedValue = false; - errorAmountMessage = `Min. send amount: ${MIN_AMOUNT_TO_SEND} ${denom?.toUpperCase()}`; - } else if (balance && value.amount) { - try { - const amountBig = new Big(value.amount); - const balanceBig = new Big(balance); + errorAmountMessage = showEmptyFieldErrors ? 'Amount is required' : undefined; + } else { + // Skip validation for partial decimal inputs during typing + if (value.amount === '.' || value.amount.endsWith('.')) { + newValidatedValue = false; + setIsValid(newValidatedValue); + setErrorAmount(undefined); + return newValidatedValue; + } - if (amountBig.gt(balanceBig)) { - newValidatedValue = false; - errorAmountMessage = `Amount exceeds your available balance. Available: ${balance} ${denom?.toUpperCase()}`; - } - } catch (err) { - if (!/^\d*\.?\d*$/.test(value.amount)) { - newValidatedValue = false; - errorAmountMessage = 'Invalid number format'; + if (!(await validateAmount(value.amount, '0'))) { + newValidatedValue = false; + errorAmountMessage = 'Please enter a valid amount'; + } else if (Number(value.amount) < Number(MIN_AMOUNT_TO_SEND)) { + newValidatedValue = false; + errorAmountMessage = `Min. send amount: ${MIN_AMOUNT_TO_SEND} ${denom?.toUpperCase()}`; + } else if (balance && value.amount) { + try { + const amountBig = new Big(value.amount); + const balanceBig = new Big(balance); + + if (amountBig.gt(balanceBig)) { + newValidatedValue = false; + errorAmountMessage = `Amount exceeds your available balance. Available: ${balance} ${denom?.toUpperCase()}`; + } + } catch (err) { + if (!/^\d*\.?\d*$/.test(value.amount)) { + newValidatedValue = false; + errorAmountMessage = 'Invalid number format'; + } } } } - } - setIsValid(newValidatedValue); - setErrorAmount(errorAmountMessage); - return newValidatedValue; - }; + setIsValid(newValidatedValue); + setErrorAmount(errorAmountMessage); + return newValidatedValue; + }, + [amountTouched, noAccount, balance, denom], + ); - const validateUserFees = (fees: DecCoin) => { - let feeValid = true; - let errorFeeMessage; + const validateUserFees = useCallback( + (fees: DecCoin) => { + let feeValid = true; + let errorFeeMessage; + + if (noAccount) { + feeValid = false; + errorFeeMessage = 'You need to acquire NYMs before setting fees.'; + setFeeAmountIsValid(feeValid); + setErrorFee(errorFeeMessage); + return feeValid; + } + + // Skip validation for partial decimal inputs during typing + if (fees.amount === '.' || fees.amount.endsWith('.')) { + setFeeAmountIsValid(false); + setErrorFee(undefined); + return false; + } + + if (!isValidRawCoin(fees.amount) || !Number(fees.amount)) { + feeValid = false; + errorFeeMessage = 'Please enter a valid fee amount'; + } else { + try { + const f = Big(fees.amount); + if (f.gt(maxUserFees)) { + feeValid = false; + errorFeeMessage = `Max fee: ${maxUserFees} ${denom?.toUpperCase()}`; + } else if (f.lt(minUserFees)) { + feeValid = false; + errorFeeMessage = `Min. fee: ${minUserFees} ${denom?.toUpperCase()}`; + } + } catch (err) { + if (!/^\d*\.?\d*$/.test(fees.amount)) { + feeValid = false; + errorFeeMessage = 'Invalid fee format'; + } + } + } - if (noAccount) { - feeValid = false; - errorFeeMessage = 'You need to acquire NYMs before setting fees.'; setFeeAmountIsValid(feeValid); setErrorFee(errorFeeMessage); return feeValid; - } + }, + [noAccount, denom], + ); - // Skip validation for partial decimal inputs during typing - if (fees.amount === '.' || fees.amount.endsWith('.')) { - setFeeAmountIsValid(false); - setErrorFee(undefined); - return false; - } - - if (!isValidRawCoin(fees.amount) || !Number(fees.amount)) { - feeValid = false; - errorFeeMessage = 'Please enter a valid fee amount'; - } else { - try { - const f = Big(fees.amount); - if (f.gt(maxUserFees)) { - feeValid = false; - errorFeeMessage = `Max fee: ${maxUserFees} ${denom?.toUpperCase()}`; - } else if (f.lt(minUserFees)) { - feeValid = false; - errorFeeMessage = `Min. fee: ${minUserFees} ${denom?.toUpperCase()}`; - } - } catch (err) { - if (!/^\d*\.?\d*$/.test(fees.amount)) { - feeValid = false; - errorFeeMessage = 'Invalid fee format'; - } - } - } - - setFeeAmountIsValid(feeValid); - setErrorFee(errorFeeMessage); - return feeValid; - }; + 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 ( 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} diff --git a/nym-wallet/src/layouts/AuthLayout.tsx b/nym-wallet/src/layouts/AuthLayout.tsx index a198db8e06..010c303a94 100644 --- a/nym-wallet/src/layouts/AuthLayout.tsx +++ b/nym-wallet/src/layouts/AuthLayout.tsx @@ -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,85 +24,37 @@ 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', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + px: { xs: 2, md: 4 }, + py: { xs: 4, md: 6 }, bgcolor: 'background.default', }} > - `1px solid ${t.palette.divider}`, - background: isDark ? alpha(theme.palette.common.black, 0.2) : theme.palette.nym.nymWallet.background.subtle, - }} - > - - - - - Secure wallet access - - - Access tokens, staking, and node operations in one place. - - - The wallet keeps the same trusted Nym visual language while making key tasks easier to scan and complete. - + + + + + `1px solid ${t.palette.divider}`, + boxShadow: (t) => t.palette.nym.nymWallet.shadows.strong, + px: { xs: 3, sm: 4 }, + py: { xs: 3, sm: 4 }, + boxSizing: 'border-box', + }} + > + + + {children} - - - - - - - - - `1px solid ${t.palette.divider}`, - boxShadow: (t) => t.palette.nym.nymWallet.shadows.strong, - px: { xs: 3, sm: 4 }, - py: { xs: 3, sm: 4 }, - boxSizing: 'border-box', - }} - > - - - {children} - - - - + + ); }; diff --git a/nym-wallet/src/pages/bonding/Bonding.tsx b/nym-wallet/src/pages/bonding/Bonding.tsx index f4765fa5dc..2235ae500e 100644 --- a/nym-wallet/src/pages/bonding/Bonding.tsx +++ b/nym-wallet/src/pages/bonding/Bonding.tsx @@ -201,7 +201,7 @@ export const Bonding = () => { return ( refresh()} /> diff --git a/nym-wallet/src/theme/NymWalletTheme.tsx b/nym-wallet/src/theme/NymWalletTheme.tsx index aa408ef129..1d6ecf5aab 100644 --- a/nym-wallet/src/theme/NymWalletTheme.tsx +++ b/nym-wallet/src/theme/NymWalletTheme.tsx @@ -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 ( - - - - {children} - + + + + + {children} + + ); }; diff --git a/nym-wallet/src/theme/emotionCache.ts b/nym-wallet/src/theme/emotionCache.ts new file mode 100644 index 0000000000..be053b48f7 --- /dev/null +++ b/nym-wallet/src/theme/emotionCache.ts @@ -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 `