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 new file mode 100644 index 0000000000..e54963ad11 --- /dev/null +++ b/app/src/components/settings/panels/WalletBalancesPanel.tsx @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { type BalanceInfo, 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); + // Tracks the most recent "Copied" timer so rapid re-clicks reset the 2s + // window rather than stacking independent setTimeouts (the older one would + // otherwise flip `copied` back to false while the newest click still wants + // to show the checkmark). + const copyResetTimerRef = useRef | null>(null); + + useEffect( + () => () => { + if (copyResetTimerRef.current !== null) { + clearTimeout(copyResetTimerRef.current); + copyResetTimerRef.current = null; + } + }, + [] + ); + + const handleCopyAddress = useCallback(async () => { + try { + await navigator.clipboard.writeText(balance.address); + setCopied(true); + if (copyResetTimerRef.current !== null) { + clearTimeout(copyResetTimerRef.current); + } + copyResetTimerRef.current = setTimeout(() => { + setCopied(false); + copyResetTimerRef.current = null; + }, 2000); + } 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); + + // 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 { + if (requestId === latestRequestIdRef.current) { + setLoading(false); + } + } + }, []); + + useEffect(() => { + void loadBalances(); + }, [loadBalances]); + + const renderContent = () => { + if (loading) { + return ( +
+ + + + + {t('walletBalances.loading')} +
+ ); + } + + if (error) { + return ( +
+
+ + + +

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

+
+ +
+ ); + } + + 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..7468b857db --- /dev/null +++ b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx @@ -0,0 +1,224 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +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. +// --------------------------------------------------------------------------- + +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 +// --------------------------------------------------------------------------- + +function renderPanel() { + const { container } = renderWithProviders(); + 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; + }) + ); + + 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 a translated, user-facing error message when the fetch rejects', async () => { + mockFetchWalletBalances.mockRejectedValueOnce( + new Error('wallet is not configured; run wallet setup first') + ); + + renderPanel(); + + // UI must not leak raw backend phrasing — it should render the + // translated `walletBalances.errorGeneric` copy instead. + await waitFor(() => { + expect(screen.getByText(/Unable to load wallet balances/i)).toBeInTheDocument(); + expect( + screen.queryByText(/wallet is not configured; run wallet setup first/i) + ).not.toBeInTheDocument(); + }); + }); + + it('re-invokes fetchWalletBalances when the Retry button is clicked', async () => { + mockFetchWalletBalances + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce([]); + + 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([]); + + 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]); + + renderPanel(); + + await waitFor(() => { + // Chain badge — appears once (EVM has no asset symbol collision with chain label) + expect(screen.getByText('EVM')).toBeInTheDocument(); + // Formatted balances (unique per row) + expect(screen.getByText('1.000000000000000000')).toBeInTheDocument(); + expect(screen.getByText('1.00000000')).toBeInTheDocument(); + // 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.getAllByText('BTC').length).toBeGreaterThanOrEqual(2); + }); + }); + + it('truncates addresses to first 6 + last 4 chars', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); + + renderPanel(); + + // address: 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 + // truncated: 0x9858…da94 (first 6 + last 4 chars, original case preserved) + await waitFor(() => { + expect(screen.getByText('0x9858…da94')).toBeInTheDocument(); + }); + }); + + it('shows the "provider unavailable" chip for balances with missing provider status', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([MISSING_PROVIDER_BALANCE]); + + 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]); + + 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]); + + 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)); + // After refresh, the BTC row is added — BTC appears twice (chain badge + symbol). + await waitFor(() => expect(screen.getAllByText('BTC').length).toBeGreaterThanOrEqual(2)); + }); +}); diff --git a/app/src/lib/i18n/chunks/ar-4.ts b/app/src/lib/i18n/chunks/ar-4.ts index 9beae8a104..7df5c48610 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..74506a21dc 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..017a639628 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..93f307316c 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', '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..c834eff40f 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..f039aca4c4 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..d37245d6ee 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..66660c7045 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..826423f3c9 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..f0980fb94e 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..d9561be9e0 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..35e395a316 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; 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..5ffd1a2382 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; export default zhCN4; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a191acebaa..1fe22080d6 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.providerMissing': 'provider unavailable', + 'walletBalances.rawBalance': 'Raw: {raw}', + 'walletBalances.errorGeneric': + 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', }; export default en; 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/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..a3ac4d9489 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,20 @@ export const setupLocalWallet = async (params: SetupWalletParams): Promise => { + const response = await callCoreRpc<{ result: BalanceInfo[] }>({ + method: 'openhuman.wallet_balances', + }); + return response.result; +}; 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). 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, },