From 32120fdea8fd130298e45f5cce4f3c130adaf031 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 26 May 2026 13:05:16 +0530 Subject: [PATCH 1/7] feat(wallet): add fetchWalletBalances RPC client + i18n keys Extend walletApi.ts with BalanceInfo type and fetchWalletBalances() calling openhuman.wallet_balances. Add 3 Vitest tests (happy path, error propagation, empty array). Add walletBalances.* and pages.settings.account.walletBalances* keys to en.ts, en-4.ts, and all 12 non-English chunk-4 locale files. Update wallet.execution how_to in catalog.rs to reference Settings > Wallet Balances. --- app/src/lib/i18n/chunks/ar-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/bn-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/de-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/en-4.ts | 13 +++++++++ app/src/lib/i18n/chunks/es-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/fr-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/hi-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/id-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/it-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/ko-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/pt-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/ru-4.ts | 12 ++++++++ app/src/lib/i18n/chunks/zh-CN-4.ts | 12 ++++++++ app/src/lib/i18n/en.ts | 14 ++++++++++ app/src/services/walletApi.test.ts | 45 ++++++++++++++++++++++++++++++ app/src/services/walletApi.ts | 33 ++++++++++++++++++++++ src/openhuman/about_app/catalog.rs | 2 +- 17 files changed, 250 insertions(+), 1 deletion(-) diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index 9beae8a104..80bad8c1cb 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -413,6 +413,18 @@ const ar4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'التوجيه والمشغلات وسجل عمليات التكامل المدعومة بواسطة Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default ar4; diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index 045b02eb0f..ae27a29f8a 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -416,6 +416,18 @@ const bn4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Composio দ্বারা চালিত ইন্টিগ্রেশনের জন্য রাউটিং, ট্রিগার এবং ইতিহাস।', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default bn4; diff --git a/app/src/lib/i18n/chunks/de-4.ts b/app/src/lib/i18n/chunks/de-4.ts index 458c8b2ec5..c62a8673b8 100644 --- a/app/src/lib/i18n/chunks/de-4.ts +++ b/app/src/lib/i18n/chunks/de-4.ts @@ -422,6 +422,18 @@ const de4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Routing, Trigger und Verlauf für Integrationen, die von Composio unterstützt werden.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default de4; diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index 3f1eb85f43..77a1b235ba 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -192,9 +192,22 @@ const en4: TranslationMap = { 'pages.settings.account.migration': 'Import from another assistant', 'pages.settings.account.migrationDesc': 'Migrate memory and notes from OpenClaw (or, soon, Hermes) into this workspace.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', 'pages.settings.accountSection.description': 'Recovery phrase, team, connections, and privacy settings.', 'pages.settings.accountSection.title': 'Account', + // WalletBalancesPanel strings + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', 'pages.settings.ai.llm': 'Llm', 'pages.settings.ai.llmDesc': 'Llm desc', 'pages.settings.ai.voice': 'Voice', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index 28aa71730d..849521dcd7 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -420,6 +420,18 @@ const es4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Enrutamiento, activadores e historial para integraciones impulsadas por Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default es4; diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index 457b38e9fc..5be81f08d9 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -419,6 +419,18 @@ const fr4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Routage, déclencheurs et historique pour les intégrations optimisées par Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default fr4; diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 973102cfe4..8b85fb7338 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -417,6 +417,18 @@ const hi4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Composio द्वारा संचालित एकीकरण के लिए रूटिंग, ट्रिगर और इतिहास।', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default hi4; diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index f520d4bb88..230a409291 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -418,6 +418,18 @@ const id4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Perutean, pemicu, dan riwayat untuk integrasi yang didukung oleh Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default id4; diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index 0179e47748..2e55dd682b 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -421,6 +421,18 @@ const it4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Routing, trigger e cronologia per le integrazioni fornite da Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default it4; diff --git a/app/src/lib/i18n/chunks/ko-4.ts b/app/src/lib/i18n/chunks/ko-4.ts index a11cc601a0..d4b8ece994 100644 --- a/app/src/lib/i18n/chunks/ko-4.ts +++ b/app/src/lib/i18n/chunks/ko-4.ts @@ -419,6 +419,18 @@ const ko4: TranslationMap = { 'settings.ai.openAiCompat.rotateKey': '키 회전', 'settings.ai.openAiCompat.setKey': '키 설정', 'settings.ai.openAiCompat.title': 'OpenAI 호환 엔드포인트', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default ko4; diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index 30af40275b..c73ad36ff3 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -419,6 +419,18 @@ const pt4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Roteamento, gatilhos e histórico para integrações desenvolvidas por Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default pt4; diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index 4775c17d1a..00aa02a748 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -416,6 +416,18 @@ const ru4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': 'Маршрутизация, триггеры и история интеграций на базе Composio.', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default ru4; diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index 21a742ce9d..d41e75fced 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -408,6 +408,18 @@ const zhCN4: TranslationMap = { 'pages.settings.composioSection.title': 'Composio', 'pages.settings.composioSection.description': '由 Composio 提供支持的集成的路由、触发器和历史记录。', + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default zhCN4; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a191acebaa..d08fccff5a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3548,6 +3548,20 @@ const en: TranslationMap = { 'settings.appearanceDesc': 'Pick light, dark, or match your system theme', 'settings.mascot': 'Mascot', 'settings.mascotDesc': 'Pick the mascot color used across the app', + // Settings > Account > Wallet Balances + 'pages.settings.account.walletBalances': 'Wallet Balances', + 'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet', + // WalletBalancesPanel strings + 'walletBalances.title': 'Wallet Balances', + 'walletBalances.refresh': 'Refresh', + 'walletBalances.loading': 'Loading balances…', + 'walletBalances.retry': 'Retry', + 'walletBalances.emptyState': + 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.copyAddress': 'Copy address', + 'walletBalances.addressCopied': 'Copied', + 'walletBalances.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', }; export default en; diff --git a/app/src/services/walletApi.test.ts b/app/src/services/walletApi.test.ts index 230c3855fd..626de24576 100644 --- a/app/src/services/walletApi.test.ts +++ b/app/src/services/walletApi.test.ts @@ -50,4 +50,49 @@ describe('walletApi', () => { params: payload, }); }); + + // fetchWalletBalances tests + it('fetchWalletBalances calls wallet.balances via openhuman.wallet_balances and returns the array', async () => { + const rows = [ + { + chain: 'evm', + evmNetwork: 'ethereum_mainnet', + address: '0xABCD', + assetSymbol: 'ETH', + decimals: 18, + raw: '1000000000000000000', + formatted: '1.000000000000000000', + providerStatus: 'ready', + }, + ]; + mockCallCoreRpc.mockResolvedValueOnce({ result: rows }); + + const { fetchWalletBalances } = await import('./walletApi'); + const result = await fetchWalletBalances(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.wallet_balances' }); + expect(result).toHaveLength(1); + expect(result[0].assetSymbol).toBe('ETH'); + expect(result[0].providerStatus).toBe('ready'); + }); + + it('fetchWalletBalances propagates RPC errors to the caller', async () => { + mockCallCoreRpc.mockRejectedValueOnce( + new Error('wallet is not configured; run wallet setup first') + ); + + const { fetchWalletBalances } = await import('./walletApi'); + await expect(fetchWalletBalances()).rejects.toThrow( + 'wallet is not configured; run wallet setup first' + ); + }); + + it('fetchWalletBalances maps an empty result array to an empty array', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ result: [] }); + + const { fetchWalletBalances } = await import('./walletApi'); + const result = await fetchWalletBalances(); + + expect(result).toEqual([]); + }); }); diff --git a/app/src/services/walletApi.ts b/app/src/services/walletApi.ts index 30fe3129e5..aedec1a37b 100644 --- a/app/src/services/walletApi.ts +++ b/app/src/services/walletApi.ts @@ -3,6 +3,26 @@ import { callCoreRpc } from './coreRpcClient'; export type WalletChain = 'evm' | 'btc' | 'solana' | 'tron'; export type WalletSetupSource = 'generated' | 'imported'; +/** + * A single balance row returned by wallet.balances. + * Field names match the camelCase serde output of BalanceInfo in + * src/openhuman/wallet/execution.rs. + */ +export interface BalanceInfo { + chain: WalletChain; + /** Present only when chain === 'evm'. */ + evmNetwork?: string; + address: string; + assetSymbol: string; + decimals: number; + /** Raw balance in the chain's smallest unit (wei / sat / lamport / sun). */ + raw: string; + /** Human-readable formatted balance (e.g. "1.234"). */ + formatted: string; + /** "ready" when the RPC provider responded; "missing" when it fell back to zero. */ + providerStatus: 'ready' | 'missing'; +} + export interface WalletAccount { chain: WalletChain; address: string; @@ -42,3 +62,16 @@ export const setupLocalWallet = async (params: SetupWalletParams): Promise => { + const response = await callCoreRpc<{ result: BalanceInfo[] }>({ + method: 'openhuman.wallet_balances', + }); + return response.result; +}; diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index 0d4986b0e3..6141e95301 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -548,7 +548,7 @@ const CAPABILITIES: &[Capability] = &[ domain: "wallet", category: CapabilityCategory::Skills, description: "Read balances and prepare/confirm/execute transfers, swaps, and contract calls across the connected wallet (EVM, BTC, Solana, Tron). Quote-first; signing stays local.", - how_to: "Use wallet.* RPC methods (balances, prepare_transfer, prepare_swap, prepare_contract_call, execute_prepared) via the agent or core_rpc_relay.", + how_to: "Use wallet.* RPC methods (balances, prepare_transfer, prepare_swap, prepare_contract_call, execute_prepared) via the agent or core_rpc_relay, or via Settings > Wallet Balances.", status: CapabilityStatus::Beta, privacy: LOCAL_CREDENTIALS, }, From 9fe2111cedbf1eed40d933d7624a85564bc486fe Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 26 May 2026 13:05:22 +0530 Subject: [PATCH 2/7] feat(wallet): add WalletBalancesPanel settings panel New panel under Settings > Account with loading/error/empty/loaded states. Chain badges (EVM/BTC/SOL/TRX) with color-coded design tokens; truncated address (first 6 + last 4) with copy-to-clipboard; formatted balance + symbol; providerStatus chip for missing providers; Refresh button. Vitest+RTL tests cover all five UI state describe-blocks including Retry re-invocation. --- .../settings/panels/WalletBalancesPanel.tsx | 245 ++++++++++++++++++ .../__tests__/WalletBalancesPanel.test.tsx | 222 ++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 app/src/components/settings/panels/WalletBalancesPanel.tsx create mode 100644 app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx diff --git a/app/src/components/settings/panels/WalletBalancesPanel.tsx b/app/src/components/settings/panels/WalletBalancesPanel.tsx new file mode 100644 index 0000000000..46a1c6657f --- /dev/null +++ b/app/src/components/settings/panels/WalletBalancesPanel.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import type { BalanceInfo } from '../../../services/walletApi'; +import { fetchWalletBalances } from '../../../services/walletApi'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +// --------------------------------------------------------------------------- +// Chain badge colours — each chain gets a distinct palette token combination +// that maps to the project's sage / amber / coral / ocean (primary) design +// language. Tailwind class strings are kept literal so the build can detect +// them via static analysis. +// --------------------------------------------------------------------------- + +const CHAIN_BADGE_CLASS: Record = { + evm: 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300', + btc: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + solana: 'bg-sage-100 text-sage-700 dark:bg-sage-900/30 dark:text-sage-300', + tron: 'bg-coral-100 text-coral-700 dark:bg-coral-900/30 dark:text-coral-300', +}; + +const CHAIN_LABEL: Record = { + evm: 'EVM', + btc: 'BTC', + solana: 'SOL', + tron: 'TRX', +}; + +/** Shorten an address to first 6 + last 4 characters: `0x1234…abcd`. */ +function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +// --------------------------------------------------------------------------- +// BalanceRow — a single chain entry +// --------------------------------------------------------------------------- + +interface BalanceRowProps { + balance: BalanceInfo; +} + +const BalanceRow = ({ balance }: BalanceRowProps) => { + const { t } = useT(); + const [copied, setCopied] = useState(false); + + const handleCopyAddress = useCallback(async () => { + try { + await navigator.clipboard.writeText(balance.address); + setCopied(true); + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + } catch { + // Clipboard unavailable (no permissions); silently skip. + } + }, [balance.address]); + + const badgeClass = + CHAIN_BADGE_CLASS[balance.chain] ?? + 'bg-stone-100 text-stone-700 dark:bg-neutral-800 dark:text-neutral-300'; + const chainLabel = CHAIN_LABEL[balance.chain] ?? balance.chain.toUpperCase(); + + return ( +
+ {/* Chain badge */} + + {chainLabel} + + + {/* Address + copy button */} +
+ + {truncateAddress(balance.address)} + + +
+ + {/* Spacer */} +
+ + {/* Amount + provider status */} +
+
+ + {balance.formatted} + + + {balance.assetSymbol} + +
+ {balance.providerStatus !== 'ready' && ( + + {t('walletBalances.providerMissing')} + + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// WalletBalancesPanel — main panel +// --------------------------------------------------------------------------- + +const WalletBalancesPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + const [balances, setBalances] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadBalances = useCallback(async () => { + setLoading(true); + setError(null); + try { + const rows = await fetchWalletBalances(); + setBalances(rows); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadBalances(); + }, [loadBalances]); + + const renderContent = () => { + if (loading) { + return ( +
+ + + + + {t('walletBalances.loading')} +
+ ); + } + + if (error) { + return ( +
+
+ + + +

{error}

+
+ +
+ ); + } + + if (balances !== null && balances.length === 0) { + return ( +
+
+ + + +
+

+ {t('walletBalances.emptyState')} +

+
+ ); + } + + if (balances && balances.length > 0) { + return ( +
+ {balances.map((balance, index) => ( + + ))} +
+ ); + } + + return null; + }; + + return ( +
+
+ + +
+ +
+ {renderContent()} +
+
+ ); +}; + +export default WalletBalancesPanel; diff --git a/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx new file mode 100644 index 0000000000..8561c21a62 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx @@ -0,0 +1,222 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import type { BalanceInfo } from '../../../../services/walletApi'; + +// --------------------------------------------------------------------------- +// Module-level mock: replace fetchWalletBalances before the panel loads. +// --------------------------------------------------------------------------- + +const mockFetchWalletBalances = vi.fn<() => Promise>(); + +vi.mock('../../../../services/walletApi', () => ({ + fetchWalletBalances: (...args: unknown[]) => mockFetchWalletBalances(...(args as [])), +})); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const EVM_BALANCE: BalanceInfo = { + chain: 'evm', + evmNetwork: 'ethereum_mainnet', + address: '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + assetSymbol: 'ETH', + decimals: 18, + raw: '1000000000000000000', + formatted: '1.000000000000000000', + providerStatus: 'ready', +}; + +const BTC_BALANCE: BalanceInfo = { + chain: 'btc', + address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', + assetSymbol: 'BTC', + decimals: 8, + raw: '100000000', + formatted: '1.00000000', + providerStatus: 'ready', +}; + +const MISSING_PROVIDER_BALANCE: BalanceInfo = { + chain: 'solana', + address: 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', + assetSymbol: 'SOL', + decimals: 9, + raw: '0', + formatted: '0.000000000', + providerStatus: 'missing', +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function renderPanel() { + const { container } = renderWithProviders( + (await import('../WalletBalancesPanel')).default as React.ComponentType + ); + return container; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('WalletBalancesPanel — loading state', () => { + it('shows a loading spinner while the fetch is in progress', async () => { + let resolve!: (value: BalanceInfo[]) => void; + mockFetchWalletBalances.mockReturnValueOnce( + new Promise(res => { + resolve = res; + }) + ); + + await renderPanel(); + + expect(screen.getByText(/loading balances/i)).toBeInTheDocument(); + + // Resolve so React can clean up. + resolve([]); + await waitFor(() => expect(screen.queryByText(/loading balances/i)).not.toBeInTheDocument()); + }); +}); + +describe('WalletBalancesPanel — error state', () => { + beforeEach(() => { + mockFetchWalletBalances.mockReset(); + }); + + it('renders the error message when the fetch rejects', async () => { + mockFetchWalletBalances.mockRejectedValueOnce( + new Error('wallet is not configured; run wallet setup first') + ); + + await renderPanel(); + + await waitFor(() => { + expect( + screen.getByText(/wallet is not configured; run wallet setup first/i) + ).toBeInTheDocument(); + }); + }); + + it('re-invokes fetchWalletBalances when the Retry button is clicked', async () => { + mockFetchWalletBalances + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce([]); + + await renderPanel(); + + await waitFor(() => expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => expect(mockFetchWalletBalances).toHaveBeenCalledTimes(2)); + // After the second call (empty) the error clears and empty state appears. + await waitFor(() => expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument()); + }); +}); + +describe('WalletBalancesPanel — empty state', () => { + beforeEach(() => { + mockFetchWalletBalances.mockReset(); + }); + + it('renders the Recovery Phrase hint when no balances are returned', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([]); + + await renderPanel(); + + await waitFor(() => { + expect(screen.getByText(/No wallet accounts yet/i)).toBeInTheDocument(); + expect(screen.getByText(/Recovery Phrase/i)).toBeInTheDocument(); + }); + }); +}); + +describe('WalletBalancesPanel — loaded state', () => { + beforeEach(() => { + mockFetchWalletBalances.mockReset(); + }); + + it('renders chain badge, formatted amount, and symbol for each row', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE, BTC_BALANCE]); + + await renderPanel(); + + await waitFor(() => { + // Chain badges + expect(screen.getByText('EVM')).toBeInTheDocument(); + expect(screen.getByText('BTC')).toBeInTheDocument(); + // Formatted balances + expect(screen.getByText('1.000000000000000000')).toBeInTheDocument(); + expect(screen.getByText('1.00000000')).toBeInTheDocument(); + // Symbols + expect(screen.getByText('ETH')).toBeInTheDocument(); + expect(screen.getByText('BTC')).toBeInTheDocument(); + }); + }); + + it('truncates addresses to first 6 + last 4 chars', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); + + await renderPanel(); + + // address: 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 + // truncated: 0x9858E…Da94 + await waitFor(() => { + expect(screen.getByText('0x9858E…Da94')).toBeInTheDocument(); + }); + }); + + it('shows the "provider unavailable" chip for balances with missing provider status', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([MISSING_PROVIDER_BALANCE]); + + await renderPanel(); + + await waitFor(() => { + expect(screen.getByText(/provider unavailable/i)).toBeInTheDocument(); + }); + }); + + it('does NOT show the provider chip for balances with ready status', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); + + await renderPanel(); + + await waitFor(() => { + expect(screen.queryByText(/provider unavailable/i)).not.toBeInTheDocument(); + }); + }); +}); + +describe('WalletBalancesPanel — refresh', () => { + beforeEach(() => { + mockFetchWalletBalances.mockReset(); + }); + + it('re-invokes fetchWalletBalances when Refresh is clicked', async () => { + mockFetchWalletBalances + .mockResolvedValueOnce([EVM_BALANCE]) + .mockResolvedValueOnce([EVM_BALANCE, BTC_BALANCE]); + + await renderPanel(); + + await waitFor(() => expect(screen.getByText('EVM')).toBeInTheDocument()); + + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + fireEvent.click(refreshButton); + + await waitFor(() => expect(mockFetchWalletBalances).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByText('BTC')).toBeInTheDocument()); + }); +}); + +// React import needed by JSX +import React from 'react'; From e50b7a74d2dfe501957d552e438a54cb18e7f5cd Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 26 May 2026 13:05:27 +0530 Subject: [PATCH 3/7] chore(settings): wire WalletBalancesPanel into nav + update test matrix Add wallet-balances route and accountSettingsItems entry in Settings.tsx so the panel is reachable at Settings > Account > Wallet Balances. Update TEST-COVERAGE-MATRIX.md row 13.1.4 and bump covered/total counts. --- app/src/pages/Settings.tsx | 20 ++++++++++++++++++++ docs/TEST-COVERAGE-MATRIX.md | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 6b66ab5f8f..ba5c0e0e98 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -40,6 +40,7 @@ import TeamPanel from '../components/settings/panels/TeamPanel'; import ToolsPanel from '../components/settings/panels/ToolsPanel'; import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel'; import VoicePanel from '../components/settings/panels/VoicePanel'; +import WalletBalancesPanel from '../components/settings/panels/WalletBalancesPanel'; import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel'; import SettingsHome from '../components/settings/SettingsHome'; import SettingsSectionPage from '../components/settings/SettingsSectionPage'; @@ -166,6 +167,17 @@ const VoiceIcon = ( ); +const WalletIcon = ( + + + +); + const WrappedSettingsPage = ({ children, maxWidthClass = 'max-w-lg', @@ -224,6 +236,13 @@ const Settings = () => { route: 'migration', icon: MigrationIcon, }, + { + id: 'wallet-balances', + title: t('pages.settings.account.walletBalances'), + description: t('pages.settings.account.walletBalancesDesc'), + route: 'wallet-balances', + icon: WalletIcon, + }, ]; const featuresSettingsItems = [ @@ -404,6 +423,7 @@ const Settings = () => { } /> )} /> )} /> + )} /> {/* Features leaf panels */} )} /> )} /> diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index b46efb7cfc..cba812d796 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -465,6 +465,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 13.1.1 | Profile Management | VU | `app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx` | 🟡 | | | 13.1.2 | Linked Accounts | WD | `auth-access-control.spec.ts` | 🟡 | UI surface unasserted | | 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` (this PR) | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `` delimiters (#1920) | +| 13.1.4 | Wallet Balances Panel | VU | `app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx`, `app/src/services/walletApi.test.ts` | ✅ | Loading/error/empty/loaded states; Retry + Refresh re-invocation; chain badges; truncated address; providerStatus chip | ### 13.2 Automation & Channels @@ -503,11 +504,11 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | Status | Count | | ---------------- | ------------------------------------------------ | -| ✅ Covered | 69 | +| ✅ Covered | 70 | | 🟡 Partial | 27 | | ❌ Missing | 26 | | 🚫 Manual smoke | 11 | -| **Total leaves** | **134 explicit + nested = 205 product features** | +| **Total leaves** | **135 explicit + nested = 206 product features** | PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test. Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke). From 98d2a86efa3b0dbf9adc17f1cb2e6250b1ef122b Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 26 May 2026 14:17:46 +0530 Subject: [PATCH 4/7] chore(wallet): satisfy quality gate Apply Prettier auto-format, collapse duplicate-named imports into a single type+value import, switch the panel test to a static import + JSX render (no dynamic import per the app/src ban), and correct the truncated-address expectation to match the implementation's first-6 + last-4 character form. Vitest 14/14 pass; pnpm compile, lint, rust:check all green. --- .../settings/panels/WalletBalancesPanel.tsx | 77 ++++++++++++++----- .../__tests__/WalletBalancesPanel.test.tsx | 51 ++++++------ app/src/lib/i18n/chunks/ar-4.ts | 3 +- app/src/lib/i18n/chunks/bn-4.ts | 3 +- app/src/lib/i18n/chunks/de-4.ts | 3 +- app/src/lib/i18n/chunks/en-4.ts | 3 +- app/src/lib/i18n/chunks/es-4.ts | 3 +- app/src/lib/i18n/chunks/fr-4.ts | 3 +- app/src/lib/i18n/chunks/hi-4.ts | 3 +- app/src/lib/i18n/chunks/id-4.ts | 3 +- app/src/lib/i18n/chunks/it-4.ts | 3 +- app/src/lib/i18n/chunks/ko-4.ts | 3 +- app/src/lib/i18n/chunks/pt-4.ts | 3 +- app/src/lib/i18n/chunks/ru-4.ts | 3 +- app/src/lib/i18n/chunks/zh-CN-4.ts | 3 +- app/src/lib/i18n/en.ts | 3 +- 16 files changed, 98 insertions(+), 72 deletions(-) diff --git a/app/src/components/settings/panels/WalletBalancesPanel.tsx b/app/src/components/settings/panels/WalletBalancesPanel.tsx index 46a1c6657f..fb795bd83f 100644 --- a/app/src/components/settings/panels/WalletBalancesPanel.tsx +++ b/app/src/components/settings/panels/WalletBalancesPanel.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; -import type { BalanceInfo } from '../../../services/walletApi'; -import { fetchWalletBalances } from '../../../services/walletApi'; +import { type BalanceInfo, fetchWalletBalances } from '../../../services/walletApi'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -20,12 +19,7 @@ const CHAIN_BADGE_CLASS: Record = { tron: 'bg-coral-100 text-coral-700 dark:bg-coral-900/30 dark:text-coral-300', }; -const CHAIN_LABEL: Record = { - evm: 'EVM', - btc: 'BTC', - solana: 'SOL', - tron: 'TRX', -}; +const CHAIN_LABEL: Record = { evm: 'EVM', btc: 'BTC', solana: 'SOL', tron: 'TRX' }; /** Shorten an address to first 6 + last 4 characters: `0x1234…abcd`. */ function truncateAddress(address: string): string { @@ -80,12 +74,26 @@ const BalanceRow = ({ balance }: BalanceRowProps) => { aria-label={t('walletBalances.copyAddress')} className="shrink-0 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors"> {copied ? ( - + ) : ( - - + + )} @@ -151,8 +159,19 @@ const WalletBalancesPanel = () => { return (
- - + + {t('walletBalances.loading')}
@@ -171,7 +190,11 @@ const WalletBalancesPanel = () => { viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - +

{error}

@@ -189,8 +212,17 @@ const WalletBalancesPanel = () => { return (
- - + +

@@ -228,8 +260,17 @@ const WalletBalancesPanel = () => { disabled={loading} aria-label={t('walletBalances.refresh')} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 disabled:opacity-50 transition-colors"> - - + + {t('walletBalances.refresh')} diff --git a/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx index 8561c21a62..4836441104 100644 --- a/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx @@ -1,8 +1,9 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderWithProviders } from '../../../../test/test-utils'; import type { BalanceInfo } from '../../../../services/walletApi'; +import { renderWithProviders } from '../../../../test/test-utils'; +import WalletBalancesPanel from '../WalletBalancesPanel'; // --------------------------------------------------------------------------- // Module-level mock: replace fetchWalletBalances before the panel loads. @@ -57,10 +58,8 @@ const MISSING_PROVIDER_BALANCE: BalanceInfo = { // Helpers // --------------------------------------------------------------------------- -async function renderPanel() { - const { container } = renderWithProviders( - (await import('../WalletBalancesPanel')).default as React.ComponentType - ); +function renderPanel() { + const { container } = renderWithProviders(); return container; } @@ -77,7 +76,7 @@ describe('WalletBalancesPanel — loading state', () => { }) ); - await renderPanel(); + renderPanel(); expect(screen.getByText(/loading balances/i)).toBeInTheDocument(); @@ -97,7 +96,7 @@ describe('WalletBalancesPanel — error state', () => { new Error('wallet is not configured; run wallet setup first') ); - await renderPanel(); + renderPanel(); await waitFor(() => { expect( @@ -111,7 +110,7 @@ describe('WalletBalancesPanel — error state', () => { .mockRejectedValueOnce(new Error('network error')) .mockResolvedValueOnce([]); - await renderPanel(); + renderPanel(); await waitFor(() => expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()); @@ -119,7 +118,9 @@ describe('WalletBalancesPanel — error state', () => { await waitFor(() => expect(mockFetchWalletBalances).toHaveBeenCalledTimes(2)); // After the second call (empty) the error clears and empty state appears. - await waitFor(() => expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument()); + await waitFor(() => + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument() + ); }); }); @@ -131,7 +132,7 @@ describe('WalletBalancesPanel — empty state', () => { it('renders the Recovery Phrase hint when no balances are returned', async () => { mockFetchWalletBalances.mockResolvedValueOnce([]); - await renderPanel(); + renderPanel(); await waitFor(() => { expect(screen.getByText(/No wallet accounts yet/i)).toBeInTheDocument(); @@ -148,37 +149,37 @@ describe('WalletBalancesPanel — loaded state', () => { it('renders chain badge, formatted amount, and symbol for each row', async () => { mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE, BTC_BALANCE]); - await renderPanel(); + renderPanel(); await waitFor(() => { - // Chain badges + // Chain badge — appears once (EVM has no asset symbol collision with chain label) expect(screen.getByText('EVM')).toBeInTheDocument(); - expect(screen.getByText('BTC')).toBeInTheDocument(); - // Formatted balances + // Formatted balances (unique per row) expect(screen.getByText('1.000000000000000000')).toBeInTheDocument(); expect(screen.getByText('1.00000000')).toBeInTheDocument(); - // Symbols + // Symbols — ETH appears only as the asset symbol; BTC appears twice + // (chain badge + asset symbol) so we assert via getAllByText length. expect(screen.getByText('ETH')).toBeInTheDocument(); - expect(screen.getByText('BTC')).toBeInTheDocument(); + expect(screen.getAllByText('BTC').length).toBeGreaterThanOrEqual(2); }); }); it('truncates addresses to first 6 + last 4 chars', async () => { mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); - await renderPanel(); + renderPanel(); // address: 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 - // truncated: 0x9858E…Da94 + // truncated: 0x9858…da94 (first 6 + last 4 chars, original case preserved) await waitFor(() => { - expect(screen.getByText('0x9858E…Da94')).toBeInTheDocument(); + expect(screen.getByText('0x9858…da94')).toBeInTheDocument(); }); }); it('shows the "provider unavailable" chip for balances with missing provider status', async () => { mockFetchWalletBalances.mockResolvedValueOnce([MISSING_PROVIDER_BALANCE]); - await renderPanel(); + renderPanel(); await waitFor(() => { expect(screen.getByText(/provider unavailable/i)).toBeInTheDocument(); @@ -188,7 +189,7 @@ describe('WalletBalancesPanel — loaded state', () => { it('does NOT show the provider chip for balances with ready status', async () => { mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); - await renderPanel(); + renderPanel(); await waitFor(() => { expect(screen.queryByText(/provider unavailable/i)).not.toBeInTheDocument(); @@ -206,7 +207,7 @@ describe('WalletBalancesPanel — refresh', () => { .mockResolvedValueOnce([EVM_BALANCE]) .mockResolvedValueOnce([EVM_BALANCE, BTC_BALANCE]); - await renderPanel(); + renderPanel(); await waitFor(() => expect(screen.getByText('EVM')).toBeInTheDocument()); @@ -214,9 +215,7 @@ describe('WalletBalancesPanel — refresh', () => { fireEvent.click(refreshButton); await waitFor(() => expect(mockFetchWalletBalances).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(screen.getByText('BTC')).toBeInTheDocument()); + // After refresh, the BTC row is added — BTC appears twice (chain badge + symbol). + await waitFor(() => expect(screen.getAllByText('BTC').length).toBeGreaterThanOrEqual(2)); }); }); - -// React import needed by JSX -import React from 'react'; diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index 80bad8c1cb..438372e5e2 100644 --- a/app/src/lib/i18n/chunks/ar-4.ts +++ b/app/src/lib/i18n/chunks/ar-4.ts @@ -419,8 +419,7 @@ const ar4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/bn-4.ts b/app/src/lib/i18n/chunks/bn-4.ts index ae27a29f8a..d7757388cf 100644 --- a/app/src/lib/i18n/chunks/bn-4.ts +++ b/app/src/lib/i18n/chunks/bn-4.ts @@ -422,8 +422,7 @@ const bn4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/de-4.ts b/app/src/lib/i18n/chunks/de-4.ts index c62a8673b8..db51fc9d63 100644 --- a/app/src/lib/i18n/chunks/de-4.ts +++ b/app/src/lib/i18n/chunks/de-4.ts @@ -428,8 +428,7 @@ const de4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/en-4.ts b/app/src/lib/i18n/chunks/en-4.ts index 77a1b235ba..39b467f297 100644 --- a/app/src/lib/i18n/chunks/en-4.ts +++ b/app/src/lib/i18n/chunks/en-4.ts @@ -202,8 +202,7 @@ const en4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/es-4.ts b/app/src/lib/i18n/chunks/es-4.ts index 849521dcd7..f933537425 100644 --- a/app/src/lib/i18n/chunks/es-4.ts +++ b/app/src/lib/i18n/chunks/es-4.ts @@ -426,8 +426,7 @@ const es4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/fr-4.ts b/app/src/lib/i18n/chunks/fr-4.ts index 5be81f08d9..c9010537cc 100644 --- a/app/src/lib/i18n/chunks/fr-4.ts +++ b/app/src/lib/i18n/chunks/fr-4.ts @@ -425,8 +425,7 @@ const fr4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/hi-4.ts b/app/src/lib/i18n/chunks/hi-4.ts index 8b85fb7338..d3cfecee42 100644 --- a/app/src/lib/i18n/chunks/hi-4.ts +++ b/app/src/lib/i18n/chunks/hi-4.ts @@ -423,8 +423,7 @@ const hi4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/id-4.ts b/app/src/lib/i18n/chunks/id-4.ts index 230a409291..354c24a715 100644 --- a/app/src/lib/i18n/chunks/id-4.ts +++ b/app/src/lib/i18n/chunks/id-4.ts @@ -424,8 +424,7 @@ const id4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/it-4.ts b/app/src/lib/i18n/chunks/it-4.ts index 2e55dd682b..8870d676b1 100644 --- a/app/src/lib/i18n/chunks/it-4.ts +++ b/app/src/lib/i18n/chunks/it-4.ts @@ -427,8 +427,7 @@ const it4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/ko-4.ts b/app/src/lib/i18n/chunks/ko-4.ts index d4b8ece994..1097f1fecf 100644 --- a/app/src/lib/i18n/chunks/ko-4.ts +++ b/app/src/lib/i18n/chunks/ko-4.ts @@ -425,8 +425,7 @@ const ko4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/pt-4.ts b/app/src/lib/i18n/chunks/pt-4.ts index c73ad36ff3..eb0937ee71 100644 --- a/app/src/lib/i18n/chunks/pt-4.ts +++ b/app/src/lib/i18n/chunks/pt-4.ts @@ -425,8 +425,7 @@ const pt4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/ru-4.ts b/app/src/lib/i18n/chunks/ru-4.ts index 00aa02a748..fe1b7f5e49 100644 --- a/app/src/lib/i18n/chunks/ru-4.ts +++ b/app/src/lib/i18n/chunks/ru-4.ts @@ -422,8 +422,7 @@ const ru4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/chunks/zh-CN-4.ts b/app/src/lib/i18n/chunks/zh-CN-4.ts index d41e75fced..b69b0df061 100644 --- a/app/src/lib/i18n/chunks/zh-CN-4.ts +++ b/app/src/lib/i18n/chunks/zh-CN-4.ts @@ -414,8 +414,7 @@ const zhCN4: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d08fccff5a..1748b9d157 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3556,8 +3556,7 @@ const en: TranslationMap = { 'walletBalances.refresh': 'Refresh', 'walletBalances.loading': 'Loading balances…', 'walletBalances.retry': 'Retry', - 'walletBalances.emptyState': - 'No wallet accounts yet — set up a wallet in Recovery Phrase.', + 'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.', 'walletBalances.copyAddress': 'Copy address', 'walletBalances.addressCopied': 'Copied', 'walletBalances.providerMissing': 'provider unavailable', From 9096c6bf3a708d64c904284444f9847a45b1d475 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 26 May 2026 16:23:28 +0530 Subject: [PATCH 5/7] fix(wallet): address CR feedback on WalletBalancesPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard `loadBalances` with a monotonic request id so a slow earlier fetch can no longer overwrite a newer Refresh/Retry result. - Render a translated, user-facing error string (`walletBalances.errorGeneric`) instead of leaking raw backend phrasing to the UI; raw error stays in `console.debug` for diagnostics. - Wire `wallet-balances` into `useSettingsNavigation` (route union, path matcher, breadcrumb mapping) so breadcrumbs + back navigation behave. - Clarify the `fetchWalletBalances` contract — empty array vs reject when the wallet is not configured. --- .../settings/hooks/useSettingsNavigation.ts | 3 +++ .../settings/panels/WalletBalancesPanel.tsx | 22 ++++++++++++++++--- .../__tests__/WalletBalancesPanel.test.tsx | 9 +++++--- app/src/lib/i18n/chunks/ar-4.ts | 2 ++ app/src/lib/i18n/chunks/bn-4.ts | 2 ++ app/src/lib/i18n/chunks/de-4.ts | 2 ++ app/src/lib/i18n/chunks/en-4.ts | 2 ++ app/src/lib/i18n/chunks/es-4.ts | 2 ++ app/src/lib/i18n/chunks/fr-4.ts | 2 ++ app/src/lib/i18n/chunks/hi-4.ts | 2 ++ app/src/lib/i18n/chunks/id-4.ts | 2 ++ app/src/lib/i18n/chunks/it-4.ts | 2 ++ app/src/lib/i18n/chunks/ko-4.ts | 2 ++ app/src/lib/i18n/chunks/pt-4.ts | 2 ++ app/src/lib/i18n/chunks/ru-4.ts | 2 ++ app/src/lib/i18n/chunks/zh-CN-4.ts | 2 ++ app/src/lib/i18n/en.ts | 2 ++ app/src/services/walletApi.ts | 10 ++++++--- 18 files changed, 63 insertions(+), 9 deletions(-) diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index e38049b53b..ae9f0bb570 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -23,6 +23,7 @@ export type SettingsRoute = | 'memory-data' | 'memory-debug' | 'recovery-phrase' + | 'wallet-balances' | 'webhooks-debug' | 'agent-chat' | 'screen-awareness-debug' @@ -108,6 +109,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/composio-routing')) return 'composio-routing'; if (path.includes('/settings/intelligence')) return 'intelligence'; if (path.includes('/settings/recovery-phrase')) return 'recovery-phrase'; + if (path.includes('/settings/wallet-balances')) return 'wallet-balances'; if (path.includes('/settings/agent-chat')) return 'agent-chat'; // Notification routes must be checked in specificity order so the more // specific `notification-routing` path doesn't get swallowed by the @@ -185,6 +187,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { // Leaf panels under account case 'recovery-phrase': + case 'wallet-balances': case 'team': case 'privacy': return [settingsCrumb, accountCrumb]; diff --git a/app/src/components/settings/panels/WalletBalancesPanel.tsx b/app/src/components/settings/panels/WalletBalancesPanel.tsx index fb795bd83f..1638d9bb7b 100644 --- a/app/src/components/settings/panels/WalletBalancesPanel.tsx +++ b/app/src/components/settings/panels/WalletBalancesPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { type BalanceInfo, fetchWalletBalances } from '../../../services/walletApi'; @@ -136,17 +136,31 @@ const WalletBalancesPanel = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Request-sequencing guard: a slower earlier request must not overwrite a + // newer one. `loadBalances` can fire concurrently (mount + Refresh + Retry), + // so we tag each call with a monotonic id and drop any response whose id no + // longer matches the latest dispatched call. + const latestRequestIdRef = useRef(0); + const loadBalances = useCallback(async () => { + const requestId = ++latestRequestIdRef.current; setLoading(true); setError(null); try { const rows = await fetchWalletBalances(); + if (requestId !== latestRequestIdRef.current) return; setBalances(rows); } catch (err) { + if (requestId !== latestRequestIdRef.current) return; const message = err instanceof Error ? err.message : String(err); + // Log the raw backend phrasing for diagnostics; the UI surfaces a + // translated, user-facing copy via `walletBalances.errorGeneric`. + console.debug('[walletBalances] fetch failed:', message); setError(message); } finally { - setLoading(false); + if (requestId === latestRequestIdRef.current) { + setLoading(false); + } } }, []); @@ -196,7 +210,9 @@ const WalletBalancesPanel = () => { d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> -

{error}

+

+ {t('walletBalances.errorGeneric')} +