From 29f8806fdbf5aafeaa6ba1058fa9a5a076b894e5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 30 May 2026 09:34:09 -0700 Subject: [PATCH] feat(settings): Crypto + Notifications hubs, wallet send/receive & multi-network balances (#3027) --- app/src/components/settings/SettingsHome.tsx | 52 +-- .../settings/SettingsSectionPage.tsx | 11 +- .../settings/__tests__/SettingsHome.test.tsx | 25 +- .../__tests__/useSettingsNavigation.test.tsx | 26 +- .../settings/hooks/useSettingsNavigation.ts | 28 +- .../settings/panels/DeveloperOptionsPanel.tsx | 16 + .../settings/panels/WalletBalancesPanel.tsx | 299 +++++++++++++---- .../__tests__/WalletBalancesPanel.test.tsx | 133 +++++++- .../settings/panels/wallet/ReceiveModal.tsx | 91 +++++ .../panels/wallet/SendCryptoModal.tsx | 313 ++++++++++++++++++ .../wallet/__tests__/SendCryptoModal.test.tsx | 131 ++++++++ .../wallet/__tests__/walletDisplay.test.ts | 54 +++ app/src/features/wallet/walletDisplay.ts | 93 ++++++ app/src/lib/i18n/ar.ts | 37 +++ app/src/lib/i18n/bn.ts | 38 +++ app/src/lib/i18n/de.ts | 40 +++ app/src/lib/i18n/en.ts | 40 +++ app/src/lib/i18n/es.ts | 39 +++ app/src/lib/i18n/fr.ts | 40 +++ app/src/lib/i18n/hi.ts | 39 +++ app/src/lib/i18n/id.ts | 38 +++ app/src/lib/i18n/it.ts | 39 +++ app/src/lib/i18n/ko.ts | 37 +++ app/src/lib/i18n/pl.ts | 38 +++ app/src/lib/i18n/pt.ts | 38 +++ app/src/lib/i18n/ru.ts | 39 +++ app/src/lib/i18n/zh-CN.ts | 34 ++ app/src/pages/Settings.tsx | 71 +++- app/src/services/walletApi.ts | 94 +++++- .../settings-account-preferences.spec.ts | 14 +- src/openhuman/about_app/catalog_data.rs | 4 +- src/openhuman/wallet/execution.rs | 212 ++++++++---- src/openhuman/wallet/execution_tests.rs | 57 ++++ src/openhuman/wallet/schemas.rs | 4 +- tests/json_rpc_e2e.rs | 13 +- 35 files changed, 2066 insertions(+), 211 deletions(-) create mode 100644 app/src/components/settings/panels/wallet/ReceiveModal.tsx create mode 100644 app/src/components/settings/panels/wallet/SendCryptoModal.tsx create mode 100644 app/src/components/settings/panels/wallet/__tests__/SendCryptoModal.test.tsx create mode 100644 app/src/features/wallet/__tests__/walletDisplay.test.ts create mode 100644 app/src/features/wallet/walletDisplay.ts diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 687627526..38b691164 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from 'react'; -import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; import { useCoreState } from '../../providers/CoreStateProvider'; @@ -27,7 +26,6 @@ interface SettingsItem { } const SettingsHome = () => { - const navigate = useNavigate(); const { navigateToSettings } = useSettingsNavigation(); const { t } = useT(); const { snapshot } = useCoreState(); @@ -53,38 +51,8 @@ const SettingsHome = () => { ), onClick: () => navigateToSettings('account'), }, - { - id: 'alerts', - title: t('nav.alerts'), - description: t('settings.alertsDesc'), - icon: ( - - - - ), - onClick: () => navigate('/notifications'), - }, - { - id: 'notifications', - title: t('settings.notifications'), - description: t('settings.notificationsDesc'), - icon: ( - - - - ), - onClick: () => navigateToSettings('notifications'), - }, + // Alerts (inbox) + Notifications (preferences) now live together under + // the Advanced → Notifications hub (see DeveloperOptionsPanel). { id: 'devices', title: 'Devices', @@ -149,6 +117,22 @@ const SettingsHome = () => { ), onClick: () => navigateToSettings('agents-settings'), }, + { + id: 'crypto', + title: t('settings.cryptoSection.title'), + description: t('settings.cryptoSection.menuDesc'), + icon: ( + + + + ), + onClick: () => navigateToSettings('crypto'), + }, { id: 'mascot', title: t('settings.mascot.menuTitle'), diff --git a/app/src/components/settings/SettingsSectionPage.tsx b/app/src/components/settings/SettingsSectionPage.tsx index 7b71c60d6..854851d91 100644 --- a/app/src/components/settings/SettingsSectionPage.tsx +++ b/app/src/components/settings/SettingsSectionPage.tsx @@ -9,7 +9,14 @@ export interface SettingsSectionItem { title: string; description?: string; icon: ReactNode; - route: string; + /** + * Settings sub-route to navigate to (under `/settings/`). Optional when an + * explicit `onClick` is supplied — e.g. an item that links to a top-level + * route outside the settings tree (the Alerts inbox at `/notifications`). + */ + route?: string; + /** Overrides the default `navigateToSettings(route)` navigation when set. */ + onClick?: () => void; } interface SettingsSectionPageProps { @@ -46,7 +53,7 @@ const SettingsSectionPage = ({ title, description, items, footer }: SettingsSect icon={item.icon} title={item.title} description={item.description} - onClick={() => navigateToSettings(item.route)} + onClick={item.onClick ?? (() => item.route && navigateToSettings(item.route))} testId={`settings-nav-${item.id}`} isFirst={index === 0} isLast={index === items.length - 1} diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index 57cddfef8..0d9bb5264 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -97,12 +97,16 @@ describe('SettingsHome', () => { it('renders the core menu items in a single list', () => { renderSettingsHome(); expect(screen.getByText('Account')).toBeInTheDocument(); - expect(screen.getByText('Alerts')).toBeInTheDocument(); - expect(screen.getByText('Notifications')).toBeInTheDocument(); expect(screen.getByText('Billing & Usage')).toBeInTheDocument(); expect(screen.getByText('Advanced')).toBeInTheDocument(); expect(screen.getByTestId('settings-nav-account')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-notifications')).toBeInTheDocument(); + }); + + it('no longer renders Alerts / Notifications on the home screen', () => { + // Both moved into the Advanced → Notifications hub. + renderSettingsHome(); + expect(screen.queryByTestId('settings-nav-alerts')).not.toBeInTheDocument(); + expect(screen.queryByTestId('settings-nav-notifications')).not.toBeInTheDocument(); }); it('no longer renders destructive actions on the home screen', () => { @@ -148,14 +152,6 @@ describe('SettingsHome', () => { expect(mockNavigateToSettings).toHaveBeenCalledWith('account'); }); - it('navigates to notifications settings when Notifications is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByText('Notifications').closest('button')!); - expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications'); - }); - it('navigates to the Agents section when Agents is clicked', async () => { const user = userEvent.setup(); renderSettingsHome(); @@ -165,12 +161,13 @@ describe('SettingsHome', () => { expect(mockNavigateToSettings).toHaveBeenCalledWith('agents-settings'); }); - it('navigates to /notifications inbox when Alerts is clicked', async () => { + it('navigates to the Crypto section when Crypto is clicked', async () => { const user = userEvent.setup(); renderSettingsHome(); - await user.click(screen.getByText('Alerts').closest('button')!); - expect(mockNavigate).toHaveBeenCalledWith('/notifications'); + // Recovery phrase + wallet balances now live under the Crypto section page. + await user.click(screen.getByText('Crypto').closest('button')!); + expect(mockNavigateToSettings).toHaveBeenCalledWith('crypto'); }); it('opens billing URL when Billing & Usage is clicked', async () => { diff --git a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx index 61cb07f50..1a5c56d63 100644 --- a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx +++ b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx @@ -18,9 +18,16 @@ describe('useSettingsNavigation breadcrumbs', () => { expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options'); }); - test('notifications returns Settings (top-level)', () => { + test('notifications-hub returns Settings > Developer Options', () => { + renderWithProviders(, { initialEntries: ['/settings/notifications-hub'] }); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options'); + }); + + test('notifications panel nests under Settings > Developer Options > Notifications', () => { renderWithProviders(, { initialEntries: ['/settings/notifications'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent( + 'Settings > Developer Options > Notifications' + ); }); test('developer-options returns Settings (section page)', () => { @@ -32,4 +39,19 @@ describe('useSettingsNavigation breadcrumbs', () => { renderWithProviders(, { initialEntries: ['/settings/persona'] }); expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); }); + + test('crypto returns Settings (section page)', () => { + renderWithProviders(, { initialEntries: ['/settings/crypto'] }); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); + }); + + test('recovery-phrase returns Settings > Crypto', () => { + renderWithProviders(, { initialEntries: ['/settings/recovery-phrase'] }); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto'); + }); + + test('wallet-balances returns Settings > Crypto', () => { + renderWithProviders(, { initialEntries: ['/settings/wallet-balances'] }); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto'); + }); }); diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 4664f67bf..9b04ae3c8 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -25,6 +25,7 @@ export type SettingsRoute = | 'tools' | 'memory-data' | 'memory-debug' + | 'crypto' | 'recovery-phrase' | 'wallet-balances' | 'webhooks-debug' @@ -34,6 +35,7 @@ export type SettingsRoute = | 'voice-debug' | 'local-model-debug' | 'notifications' + | 'notifications-hub' | 'notification-routing' | 'mascot' | 'persona' @@ -113,6 +115,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { if (path.includes('/settings/composio-triggers')) return 'composio-triggers'; if (path.includes('/settings/composio-routing')) return 'composio-routing'; if (path.includes('/settings/intelligence')) return 'intelligence'; + if (path.includes('/settings/crypto')) return 'crypto'; 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'; @@ -120,6 +123,9 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { // specific `notification-routing` path doesn't get swallowed by the // shorter `notifications` prefix. if (path.includes('/settings/notification-routing')) return 'notification-routing'; + // `notifications-hub` must be checked before the shorter `notifications` + // prefix (the tabbed settings panel) so it isn't swallowed. + if (path.includes('/settings/notifications-hub')) return 'notifications-hub'; if (path.includes('/settings/notifications')) return 'notifications'; if (path.includes('/settings/devices')) return 'devices'; if (path.includes('/settings/mascot')) return 'mascot'; @@ -201,6 +207,16 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { onClick: () => navigate('/settings/agents-settings'), }; + const cryptoCrumb: BreadcrumbItem = { + label: 'Crypto', + onClick: () => navigate('/settings/crypto'), + }; + + const notificationsHubCrumb: BreadcrumbItem = { + label: 'Notifications', + onClick: () => navigate('/settings/notifications-hub'), + }; + const getBreadcrumbs = (): BreadcrumbItem[] => { switch (currentRoute) { // Section pages @@ -208,6 +224,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'features': case 'ai': case 'agents-settings': + case 'crypto': return [settingsCrumb]; // Leaf panels under the Agents section @@ -217,9 +234,12 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'persona': return [settingsCrumb, agentsCrumb]; - // Leaf panels under account + // Leaf panels under the Crypto section case 'recovery-phrase': case 'wallet-balances': + return [settingsCrumb, cryptoCrumb]; + + // Leaf panels under account case 'team': case 'privacy': return [settingsCrumb, accountCrumb]; @@ -261,15 +281,17 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { case 'notification-routing': case 'mcp-server': case 'dev-workflow': + case 'notifications-hub': // Notifications hub section page lives under Advanced. return [settingsCrumb, developerCrumb]; // Developer options section page case 'developer-options': return [settingsCrumb]; - // Notifications panel sits at the top level of Settings. + // Notification preferences panel is a leaf under the Advanced → + // Notifications hub. case 'notifications': - return [settingsCrumb]; + return [settingsCrumb, developerCrumb, notificationsHubCrumb]; case 'devices': return [settingsCrumb]; diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 9c74dacac..b5a567b92 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -36,6 +36,22 @@ const developerItems = [ ), }, + { + id: 'notifications-hub', + titleKey: 'settings.notificationsHub.title', + descriptionKey: 'settings.notificationsHub.menuDesc', + route: 'notifications-hub', + icon: ( + + + + ), + }, { id: 'screen-intelligence', titleKey: 'settings.developerMenu.screenAwareness.title', diff --git a/app/src/components/settings/panels/WalletBalancesPanel.tsx b/app/src/components/settings/panels/WalletBalancesPanel.tsx index e54963ad1..1d2a15785 100644 --- a/app/src/components/settings/panels/WalletBalancesPanel.tsx +++ b/app/src/components/settings/panels/WalletBalancesPanel.tsx @@ -1,9 +1,22 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { + balanceBadge, + balanceKey, + balanceNetworkLabel, +} from '../../../features/wallet/walletDisplay'; import { useT } from '../../../lib/i18n/I18nContext'; -import { type BalanceInfo, fetchWalletBalances } from '../../../services/walletApi'; +import { + type BalanceInfo, + type EvmNetwork, + fetchWalletBalances, + fetchWalletStatus, + type WalletChain, +} from '../../../services/walletApi'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import ReceiveModal from './wallet/ReceiveModal'; +import SendCryptoModal from './wallet/SendCryptoModal'; // --------------------------------------------------------------------------- // Chain badge colours — each chain gets a distinct palette token combination @@ -19,7 +32,21 @@ 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 badgeClassFor = (chain: WalletChain): string => + CHAIN_BADGE_CLASS[chain] ?? + 'bg-stone-100 text-stone-700 dark:bg-neutral-800 dark:text-neutral-300'; + +// The rows rendered as placeholders before the wallet is set up, mirroring the +// configured layout (one EVM row per displayed network + BTC/Solana/Tron) so +// the preview matches what appears once a recovery phrase exists. +const PLACEHOLDER_ROWS: Array<{ chain: WalletChain; evmNetwork?: EvmNetwork; symbol: string }> = [ + { chain: 'evm', evmNetwork: 'ethereum_mainnet', symbol: 'ETH' }, + { chain: 'evm', evmNetwork: 'base_mainnet', symbol: 'ETH' }, + { chain: 'evm', evmNetwork: 'bsc_mainnet', symbol: 'BNB' }, + { chain: 'btc', symbol: 'BTC' }, + { chain: 'solana', symbol: 'SOL' }, + { chain: 'tron', symbol: 'TRX' }, +]; /** Shorten an address to first 6 + last 4 characters: `0x1234…abcd`. */ function truncateAddress(address: string): string { @@ -28,14 +55,16 @@ function truncateAddress(address: string): string { } // --------------------------------------------------------------------------- -// BalanceRow — a single chain entry +// BalanceRow — a single chain/network entry with Send / Receive actions // --------------------------------------------------------------------------- interface BalanceRowProps { balance: BalanceInfo; + onSend: (balance: BalanceInfo) => void; + onReceive: (balance: BalanceInfo) => void; } -const BalanceRow = ({ balance }: BalanceRowProps) => { +const BalanceRow = ({ balance, onSend, onReceive }: BalanceRowProps) => { const { t } = useT(); const [copied, setCopied] = useState(false); // Tracks the most recent "Copied" timer so rapid re-clicks reset the 2s @@ -70,61 +99,68 @@ const BalanceRow = ({ balance }: BalanceRowProps) => { } }, [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(); + const badgeClass = badgeClassFor(balance.chain); + const networkLabel = balanceNetworkLabel(balance); return ( -
- {/* Chain badge */} - - {chainLabel} - - - {/* Address + copy button */} -
- - {truncateAddress(balance.address)} +
+
+ {/* Network badge */} + + {balanceBadge(balance)} - -
- {/* Spacer */} -
+
+
+ + {networkLabel} + + {balance.providerStatus !== 'ready' && ( + + {t('walletBalances.providerMissing')} + + )} +
+ {/* Address + copy button */} +
+ + {truncateAddress(balance.address)} + + +
+
- {/* Amount + provider status */} -
-
+ {/* Amount */} +
@@ -134,11 +170,68 @@ const BalanceRow = ({ balance }: BalanceRowProps) => { {balance.assetSymbol}
- {balance.providerStatus !== 'ready' && ( - - {t('walletBalances.providerMissing')} - - )} +
+ + {/* Send / Receive actions */} +
+ + +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// ChainPlaceholderRow — shown per chain before the wallet is configured. There +// is no derived address or balance yet, so we render a muted "not set up" row +// to convey the wallet layout without fabricating data. +// --------------------------------------------------------------------------- + +const ChainPlaceholderRow = ({ + chain, + evmNetwork, + symbol, +}: { + chain: WalletChain; + evmNetwork?: EvmNetwork; + symbol: string; +}) => { + const { t } = useT(); + const badgeClass = badgeClassFor(chain); + + return ( +
+ + {balanceBadge({ chain, evmNetwork })} + +
+ + {balanceNetworkLabel({ chain, evmNetwork })} + + + {t('walletBalances.notSetUp')} + +
+
+
+ {/* Em dash placeholder — punctuation, not translatable copy. */} + + — + + {symbol}
); @@ -150,11 +243,17 @@ const BalanceRow = ({ balance }: BalanceRowProps) => { const WalletBalancesPanel = () => { const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); const [balances, setBalances] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // null = unknown (not yet loaded); false = wallet has no recovery phrase set + // up yet, in which case we show a hint + placeholder rows instead of erroring. + const [walletConfigured, setWalletConfigured] = useState(null); + // The balance row a Send / Receive modal is currently open for (null = none). + const [sendTarget, setSendTarget] = useState(null); + const [receiveTarget, setReceiveTarget] = useState(null); // Request-sequencing guard: a slower earlier request must not overwrite a // newer one. `loadBalances` can fire concurrently (mount + Refresh + Retry), @@ -167,6 +266,18 @@ const WalletBalancesPanel = () => { setLoading(true); setError(null); try { + // Check setup state first: the core errors `wallet_balances` when no + // recovery phrase is configured. Rather than blocking the panel on that, + // detect it via the structured `configured` flag and fall through to the + // hint + placeholder rows. + const status = await fetchWalletStatus(); + if (requestId !== latestRequestIdRef.current) return; + if (!status.configured) { + setWalletConfigured(false); + setBalances([]); + return; + } + setWalletConfigured(true); const rows = await fetchWalletBalances(); if (requestId !== latestRequestIdRef.current) return; setBalances(rows); @@ -244,6 +355,54 @@ const WalletBalancesPanel = () => { ); } + // Wallet not set up yet: show a non-blocking hint plus placeholder rows so + // the wallet layout is visible even before a recovery phrase exists. + if (walletConfigured === false) { + return ( +
+
+
+ + + +
+

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

+ +
+
+
+
+ {PLACEHOLDER_ROWS.map(row => ( + + ))} +
+
+ ); + } + if (balances !== null && balances.length === 0) { return (
@@ -271,8 +430,13 @@ const WalletBalancesPanel = () => { if (balances && balances.length > 0) { return (
- {balances.map((balance, index) => ( - + {balances.map(balance => ( + ))}
); @@ -315,6 +479,17 @@ const WalletBalancesPanel = () => {
{renderContent()}
+ + {sendTarget && ( + setSendTarget(null)} + onSuccess={() => void loadBalances()} + /> + )} + {receiveTarget && ( + setReceiveTarget(null)} /> + )}
); }; diff --git a/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx index 7468b857d..36930d90c 100644 --- a/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx @@ -1,24 +1,72 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { BalanceInfo } from '../../../../services/walletApi'; +import type { BalanceInfo, WalletStatus } from '../../../../services/walletApi'; import { renderWithProviders } from '../../../../test/test-utils'; import WalletBalancesPanel from '../WalletBalancesPanel'; // --------------------------------------------------------------------------- -// Module-level mock: replace fetchWalletBalances before the panel loads. +// Module-level mock: replace the wallet API before the panel loads. The panel +// checks `fetchWalletStatus()` first (to detect the not-configured state) and +// only calls `fetchWalletBalances()` when the wallet is configured. // --------------------------------------------------------------------------- const mockFetchWalletBalances = vi.fn<() => Promise>(); +const mockFetchWalletStatus = vi.fn<() => Promise>(); vi.mock('../../../../services/walletApi', () => ({ fetchWalletBalances: (...args: unknown[]) => mockFetchWalletBalances(...(args as [])), + fetchWalletStatus: (...args: unknown[]) => mockFetchWalletStatus(...(args as [])), + // Send modal imports these; not exercised by the open-modal tests below. + prepareTransfer: vi.fn(), + executePrepared: vi.fn(), })); -vi.mock('../../hooks/useSettingsNavigation', () => ({ - useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }), +// The Receive modal renders a QR code; stub it to a lightweight element. +vi.mock('qrcode.react', () => ({ + QRCodeSVG: ({ value }: { value: string }) =>
, })); +const mockNavigateToSettings = vi.fn(); +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: mockNavigateToSettings, + breadcrumbs: [], + }), +})); + +const CONFIGURED_STATUS: WalletStatus = { + configured: true, + onboardingCompleted: true, + consentGranted: true, + secretStored: true, + source: 'generated', + mnemonicWordCount: 12, + accounts: [], + updatedAtMs: 1, +}; + +const UNCONFIGURED_STATUS: WalletStatus = { + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, +}; + +// Default every test to a configured wallet; not-configured tests opt in via +// `mockFetchWalletStatus.mockResolvedValueOnce(UNCONFIGURED_STATUS)`. +beforeEach(() => { + mockFetchWalletStatus.mockReset(); + mockFetchWalletStatus.mockResolvedValue(CONFIGURED_STATUS); + mockFetchWalletBalances.mockReset(); + mockNavigateToSettings.mockReset(); +}); + // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- @@ -144,6 +192,40 @@ describe('WalletBalancesPanel — empty state', () => { }); }); +describe('WalletBalancesPanel — wallet not configured', () => { + it('shows the setup hint + placeholder rows instead of a blocking error', async () => { + mockFetchWalletStatus.mockReset(); + mockFetchWalletStatus.mockResolvedValueOnce(UNCONFIGURED_STATUS); + + renderPanel(); + + await waitFor(() => { + expect(screen.getByText(/Set it up to enable your wallet/i)).toBeInTheDocument(); + }); + // Placeholder rows render per displayed network (Ethereum/Base/BNB Chain) + // plus Bitcoin/Solana/Tron — one "Not set up" each. + expect(screen.getByText('Ethereum')).toBeInTheDocument(); + expect(screen.getByText('Base')).toBeInTheDocument(); + expect(screen.getByText('BNB Chain')).toBeInTheDocument(); + expect(screen.getAllByText('Not set up')).toHaveLength(6); + // No balances fetch, no red error / retry button. + expect(mockFetchWalletBalances).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + }); + + it('routes to the recovery phrase panel from the setup CTA', async () => { + mockFetchWalletStatus.mockReset(); + mockFetchWalletStatus.mockResolvedValueOnce(UNCONFIGURED_STATUS); + + renderPanel(); + + const cta = await screen.findByRole('button', { name: /set up recovery phrase/i }); + fireEvent.click(cta); + + expect(mockNavigateToSettings).toHaveBeenCalledWith('recovery-phrase'); + }); +}); + describe('WalletBalancesPanel — loaded state', () => { beforeEach(() => { mockFetchWalletBalances.mockReset(); @@ -155,14 +237,14 @@ describe('WalletBalancesPanel — loaded state', () => { renderPanel(); await waitFor(() => { - // Chain badge — appears once (EVM has no asset symbol collision with chain label) - expect(screen.getByText('EVM')).toBeInTheDocument(); + // EVM rows now show the network label + a per-network badge. + expect(screen.getByText('Ethereum')).toBeInTheDocument(); + expect(screen.getByText('Bitcoin')).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(); + // ETH appears as the EVM badge + asset symbol; BTC as the badge + symbol. + expect(screen.getAllByText('ETH').length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText('BTC').length).toBeGreaterThanOrEqual(2); }); }); @@ -200,6 +282,37 @@ describe('WalletBalancesPanel — loaded state', () => { }); }); +describe('WalletBalancesPanel — send / receive actions', () => { + beforeEach(() => { + mockFetchWalletBalances.mockReset(); + }); + + it('opens the Receive modal with the row address + QR when Receive is clicked', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); + + renderPanel(); + + await waitFor(() => expect(screen.getByText('Receive')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Receive')); + + expect(screen.getByTestId('receive-address')).toHaveTextContent(EVM_BALANCE.address); + expect(screen.getByTestId('qr-code')).toHaveAttribute('data-value', EVM_BALANCE.address); + }); + + it('opens the Send modal with the recipient + amount fields when Send is clicked', async () => { + mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]); + + renderPanel(); + + await waitFor(() => expect(screen.getByText('Send')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Send')); + + expect(screen.getByTestId('send-recipient')).toBeInTheDocument(); + expect(screen.getByTestId('send-amount')).toBeInTheDocument(); + expect(screen.getByTestId('send-review')).toBeInTheDocument(); + }); +}); + describe('WalletBalancesPanel — refresh', () => { beforeEach(() => { mockFetchWalletBalances.mockReset(); @@ -212,7 +325,7 @@ describe('WalletBalancesPanel — refresh', () => { renderPanel(); - await waitFor(() => expect(screen.getByText('EVM')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Ethereum')).toBeInTheDocument()); const refreshButton = screen.getByRole('button', { name: /refresh/i }); fireEvent.click(refreshButton); diff --git a/app/src/components/settings/panels/wallet/ReceiveModal.tsx b/app/src/components/settings/panels/wallet/ReceiveModal.tsx new file mode 100644 index 000000000..cc40147ff --- /dev/null +++ b/app/src/components/settings/panels/wallet/ReceiveModal.tsx @@ -0,0 +1,91 @@ +import { QRCodeSVG } from 'qrcode.react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { balanceNetworkLabel } from '../../../../features/wallet/walletDisplay'; +import { useT } from '../../../../lib/i18n/I18nContext'; +import type { BalanceInfo } from '../../../../services/walletApi'; +import { ModalShell } from '../../../ui/ModalShell'; + +interface ReceiveModalProps { + balance: BalanceInfo; + onClose: () => void; +} + +/** + * Receive modal — renders the derived address for the selected chain/network as + * a QR code plus a copyable string. Receiving is read-only: no signing, no RPC. + * For EVM the same address works across every EVM network. + */ +const ReceiveModal = ({ balance, onClose }: ReceiveModalProps) => { + const { t } = useT(); + const networkLabel = balanceNetworkLabel(balance); + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef | null>(null); + + // Clear the "Copied" reset timer if the modal unmounts before it fires. + useEffect( + () => () => { + if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current); + }, + [] + ); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(balance.address); + setCopied(true); + if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard unavailable; ignore. + } + }, [balance.address]); + + return ( + +
+

+ {t('walletReceive.scanHint')} +

+
+ +
+
+ + {t('walletReceive.addressLabel').replace('{network}', networkLabel)} + +
+ + {balance.address} + + +
+
+
+

+ {t('walletReceive.onlyChainWarning').replace('{network}', networkLabel)} +

+
+
+
+ ); +}; + +export default ReceiveModal; diff --git a/app/src/components/settings/panels/wallet/SendCryptoModal.tsx b/app/src/components/settings/panels/wallet/SendCryptoModal.tsx new file mode 100644 index 000000000..9ddf2a5b8 --- /dev/null +++ b/app/src/components/settings/panels/wallet/SendCryptoModal.tsx @@ -0,0 +1,313 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { + balanceNetworkLabel, + fromSmallestUnit, + toSmallestUnit, +} from '../../../../features/wallet/walletDisplay'; +import { useT } from '../../../../lib/i18n/I18nContext'; +import { + type BalanceInfo, + executePrepared, + type ExecutionResult, + type PreparedTransaction, + prepareTransfer, +} from '../../../../services/walletApi'; +import { ModalShell } from '../../../ui/ModalShell'; + +interface SendCryptoModalProps { + balance: BalanceInfo; + onClose: () => void; + /** Called after a successful broadcast so the panel can refresh balances. */ + onSuccess: () => void; +} + +type Step = 'form' | 'review' | 'sending' | 'done'; + +/** Truncate a hash/address to `0x1234…abcd` for compact display. */ +function truncate(value: string): string { + if (value.length <= 14) return value; + return `${value.slice(0, 8)}…${value.slice(-6)}`; +} + +/** + * Send modal — drives the wallet's prepare → confirm → execute flow for the + * native asset of the selected balance row. `prepareTransfer` builds a quote + * (with the simulated fee) that the user reviews before `executePrepared` + * signs locally and broadcasts. Native asset only; token sends are a follow-up. + */ +const SendCryptoModal = ({ balance, onClose, onSuccess }: SendCryptoModalProps) => { + const { t } = useT(); + const networkLabel = balanceNetworkLabel(balance); + + const [step, setStep] = useState('form'); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [prepared, setPrepared] = useState(null); + const [result, setResult] = useState(null); + + const feeFormatted = useMemo(() => { + if (!prepared) return null; + return fromSmallestUnit(prepared.estimatedFeeRaw, balance.decimals); + }, [prepared, balance.decimals]); + + const handleReview = useCallback(async () => { + setError(null); + let amountRaw: string; + try { + amountRaw = toSmallestUnit(amount, balance.decimals); + } catch { + // toSmallestUnit throws dev-facing messages; surface a translated one. + setError(t('walletSend.invalidAmount')); + return; + } + if (amountRaw === '0') { + setError(t('walletSend.invalidAmount')); + return; + } + if (recipient.trim() === '') { + setError(t('walletSend.recipientRequired')); + return; + } + setBusy(true); + try { + const quote = await prepareTransfer({ + chain: balance.chain, + toAddress: recipient.trim(), + amountRaw, + evmNetwork: balance.evmNetwork, + }); + setPrepared(quote); + setStep('review'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.debug('[walletSend] prepare failed:', message); + setError(message || t('walletSend.genericError')); + } finally { + setBusy(false); + } + }, [amount, recipient, balance, t]); + + const handleConfirm = useCallback(async () => { + if (!prepared) return; + setError(null); + setBusy(true); + setStep('sending'); + try { + const executed = await executePrepared(prepared.quoteId); + setResult(executed); + setStep('done'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.debug('[walletSend] execute failed:', message); + setError(message || t('walletSend.genericError')); + setStep('review'); + } finally { + setBusy(false); + } + }, [prepared, t]); + + const handleDone = useCallback(() => { + onSuccess(); + onClose(); + }, [onSuccess, onClose]); + + const fieldClass = + 'w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500'; + + return ( + + {error && ( +
+ {error} +
+ )} + + {step === 'form' && ( +
+
+ + {t('walletSend.available')} + + + {balance.formatted} {balance.assetSymbol} + +
+ + + +
+ )} + + {step === 'review' && prepared && ( +
+

+ {t('walletSend.confirmHint')} +

+
+
+
{t('walletSend.amount')}
+
+ {prepared.amountFormatted} {prepared.assetSymbol} +
+
+
+
{t('walletSend.recipient')}
+
+ {truncate(prepared.toAddress)} +
+
+
+
+ {t('walletSend.estimatedFee')} +
+
+ {feeFormatted} {balance.assetSymbol} +
+
+
+ {prepared.notes.length > 0 && ( +
    + {prepared.notes.map((note, i) => ( +
  • {note}
  • + ))} +
+ )} +
+ + +
+
+ )} + + {step === 'sending' && ( +
+ + + + + {t('walletSend.sending')} +
+ )} + + {step === 'done' && result && ( +
+
+ + + +
+

+ {t('walletSend.sent')} +

+
+ + {t('walletSend.txHash')} + + + {result.transactionHash} + +
+ {result.explorerUrl && ( + + {t('walletSend.viewExplorer')} + + )} + +
+ )} +
+ ); +}; + +export default SendCryptoModal; diff --git a/app/src/components/settings/panels/wallet/__tests__/SendCryptoModal.test.tsx b/app/src/components/settings/panels/wallet/__tests__/SendCryptoModal.test.tsx new file mode 100644 index 000000000..4459d65c6 --- /dev/null +++ b/app/src/components/settings/panels/wallet/__tests__/SendCryptoModal.test.tsx @@ -0,0 +1,131 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + BalanceInfo, + ExecutionResult, + PreparedTransaction, +} from '../../../../../services/walletApi'; +import { renderWithProviders } from '../../../../../test/test-utils'; +import SendCryptoModal from '../SendCryptoModal'; + +const mockPrepareTransfer = vi.fn<() => Promise>(); +const mockExecutePrepared = vi.fn<() => Promise>(); + +vi.mock('../../../../../services/walletApi', () => ({ + prepareTransfer: (...args: unknown[]) => mockPrepareTransfer(...(args as [])), + executePrepared: (...args: unknown[]) => mockExecutePrepared(...(args as [])), +})); + +const EVM_BALANCE: BalanceInfo = { + chain: 'evm', + evmNetwork: 'base_mainnet', + address: '0x9858EfFD232B4033E47d90003D41EC34EcaEda94', + assetSymbol: 'ETH', + decimals: 18, + raw: '2000000000000000000', + formatted: '2.000000000000000000', + providerStatus: 'ready', +}; + +const PREPARED: PreparedTransaction = { + quoteId: 'quote-1', + kind: 'native_transfer', + chain: 'evm', + evmNetwork: 'base_mainnet', + fromAddress: EVM_BALANCE.address, + toAddress: '0x1111111111111111111111111111111111111111', + assetSymbol: 'ETH', + amountRaw: '1000000000000000000', + amountFormatted: '1', + estimatedFeeRaw: '21000000000000', + status: 'awaiting_confirmation', + createdAtMs: 1, + expiresAtMs: 2, + notes: ['Simulated fee only'], +}; + +const EXECUTED: ExecutionResult = { + quoteId: 'quote-1', + status: 'broadcasted', + chain: 'evm', + evmNetwork: 'base_mainnet', + transactionHash: '0xabc123def456', + explorerUrl: 'https://basescan.org/tx/0xabc123def456', + transaction: PREPARED, +}; + +beforeEach(() => { + mockPrepareTransfer.mockReset(); + mockExecutePrepared.mockReset(); +}); + +function renderModal() { + const onClose = vi.fn(); + const onSuccess = vi.fn(); + renderWithProviders( + + ); + return { onClose, onSuccess }; +} + +describe('SendCryptoModal', () => { + it('drives prepare → review → execute and shows the tx hash', async () => { + mockPrepareTransfer.mockResolvedValueOnce(PREPARED); + mockExecutePrepared.mockResolvedValueOnce(EXECUTED); + const { onSuccess } = renderModal(); + + fireEvent.change(screen.getByTestId('send-recipient'), { + target: { value: '0x1111111111111111111111111111111111111111' }, + }); + fireEvent.change(screen.getByTestId('send-amount'), { target: { value: '1' } }); + fireEvent.click(screen.getByTestId('send-review')); + + // Prepare is called with the amount converted to wei + the row's network. + await waitFor(() => expect(mockPrepareTransfer).toHaveBeenCalledTimes(1)); + expect(mockPrepareTransfer).toHaveBeenCalledWith({ + chain: 'evm', + toAddress: '0x1111111111111111111111111111111111111111', + amountRaw: '1000000000000000000', + evmNetwork: 'base_mainnet', + }); + + // Review step shows the simulated fee. + await waitFor(() => expect(screen.getByTestId('send-fee')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('send-confirm')); + + await waitFor(() => expect(mockExecutePrepared).toHaveBeenCalledWith('quote-1')); + await waitFor(() => + expect(screen.getByTestId('send-tx-hash')).toHaveTextContent('0xabc123def456') + ); + + // Completing the flow refreshes the parent. + fireEvent.click(screen.getByRole('button', { name: /done/i })); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('blocks an invalid amount before calling prepare', async () => { + renderModal(); + + fireEvent.change(screen.getByTestId('send-recipient'), { target: { value: '0xabc' } }); + fireEvent.change(screen.getByTestId('send-amount'), { target: { value: 'not-a-number' } }); + fireEvent.click(screen.getByTestId('send-review')); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(mockPrepareTransfer).not.toHaveBeenCalled(); + }); + + it('surfaces a prepare failure as an alert', async () => { + mockPrepareTransfer.mockRejectedValueOnce(new Error('insufficient funds')); + renderModal(); + + fireEvent.change(screen.getByTestId('send-recipient'), { + target: { value: '0x1111111111111111111111111111111111111111' }, + }); + fireEvent.change(screen.getByTestId('send-amount'), { target: { value: '1' } }); + fireEvent.click(screen.getByTestId('send-review')); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/insufficient funds/i)); + }); +}); diff --git a/app/src/features/wallet/__tests__/walletDisplay.test.ts b/app/src/features/wallet/__tests__/walletDisplay.test.ts new file mode 100644 index 000000000..26b3ecf63 --- /dev/null +++ b/app/src/features/wallet/__tests__/walletDisplay.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + balanceBadge, + balanceKey, + balanceNetworkLabel, + fromSmallestUnit, + toSmallestUnit, +} from '../walletDisplay'; + +describe('walletDisplay — labels', () => { + it('labels EVM rows by network and others by chain', () => { + expect(balanceNetworkLabel({ chain: 'evm', evmNetwork: 'ethereum_mainnet' })).toBe('Ethereum'); + expect(balanceNetworkLabel({ chain: 'evm', evmNetwork: 'bsc_mainnet' })).toBe('BNB Chain'); + expect(balanceNetworkLabel({ chain: 'btc' })).toBe('Bitcoin'); + }); + + it('badges EVM rows by network short code', () => { + expect(balanceBadge({ chain: 'evm', evmNetwork: 'base_mainnet' })).toBe('BASE'); + expect(balanceBadge({ chain: 'evm', evmNetwork: 'bsc_mainnet' })).toBe('BSC'); + expect(balanceBadge({ chain: 'tron' })).toBe('TRX'); + }); + + it('produces a stable key incorporating the network', () => { + expect(balanceKey({ chain: 'evm', evmNetwork: 'base_mainnet', assetSymbol: 'ETH' })).toBe( + 'evm-base_mainnet-ETH' + ); + expect(balanceKey({ chain: 'btc', assetSymbol: 'BTC' })).toBe('btc-native-BTC'); + }); +}); + +describe('walletDisplay — amount conversion', () => { + it('converts human amounts to the smallest unit', () => { + expect(toSmallestUnit('1', 18)).toBe('1000000000000000000'); + expect(toSmallestUnit('1.5', 18)).toBe('1500000000000000000'); + expect(toSmallestUnit('0.0001', 8)).toBe('10000'); + expect(toSmallestUnit('0', 6)).toBe('0'); + expect(toSmallestUnit('12', 0)).toBe('12'); + }); + + it('rejects malformed amounts and excess precision', () => { + expect(() => toSmallestUnit('', 18)).toThrow(); + expect(() => toSmallestUnit('abc', 18)).toThrow(); + expect(() => toSmallestUnit('.', 18)).toThrow(); + expect(() => toSmallestUnit('1.234', 2)).toThrow(); + }); + + it('round-trips through fromSmallestUnit', () => { + expect(fromSmallestUnit('1500000000000000000', 18)).toBe('1.5'); + expect(fromSmallestUnit('1000000000000000000', 18)).toBe('1'); + expect(fromSmallestUnit('10000', 8)).toBe('0.0001'); + expect(fromSmallestUnit('0', 9)).toBe('0'); + }); +}); diff --git a/app/src/features/wallet/walletDisplay.ts b/app/src/features/wallet/walletDisplay.ts new file mode 100644 index 000000000..55ef1a263 --- /dev/null +++ b/app/src/features/wallet/walletDisplay.ts @@ -0,0 +1,93 @@ +// Display helpers for the wallet balances surface: human-readable EVM network +// labels and lossless conversion between human-entered amounts and the chain's +// smallest unit (wei / sat / lamport / sun) using BigInt (no float rounding). +// +// Chain / network proper names (Ethereum, Base, BNB Chain, …) are brand names +// rendered verbatim, so they intentionally bypass i18n. +import type { BalanceInfo, EvmNetwork, WalletChain } from '../../services/walletApi'; + +/** Full display name per EVM network. */ +export const EVM_NETWORK_LABEL: Record = { + ethereum_mainnet: 'Ethereum', + base_mainnet: 'Base', + arbitrum_one: 'Arbitrum', + optimism_mainnet: 'Optimism', + polygon_mainnet: 'Polygon', + bsc_mainnet: 'BNB Chain', +}; + +/** Short badge label per EVM network. */ +export const EVM_NETWORK_BADGE: Record = { + ethereum_mainnet: 'ETH', + base_mainnet: 'BASE', + arbitrum_one: 'ARB', + optimism_mainnet: 'OP', + polygon_mainnet: 'POL', + bsc_mainnet: 'BSC', +}; + +const CHAIN_LABEL: Record = { + evm: 'EVM', + btc: 'Bitcoin', + solana: 'Solana', + tron: 'Tron', +}; + +/** Human network/chain label for a balance row (network name for EVM rows). */ +export function balanceNetworkLabel(balance: Pick): string { + if (balance.chain === 'evm' && balance.evmNetwork) { + return EVM_NETWORK_LABEL[balance.evmNetwork] ?? 'EVM'; + } + return CHAIN_LABEL[balance.chain] ?? balance.chain.toUpperCase(); +} + +/** Short badge text for a balance row. */ +export function balanceBadge(balance: Pick): string { + if (balance.chain === 'evm' && balance.evmNetwork) { + return EVM_NETWORK_BADGE[balance.evmNetwork] ?? 'EVM'; + } + return { evm: 'EVM', btc: 'BTC', solana: 'SOL', tron: 'TRX' }[balance.chain]; +} + +/** Stable React key for a balance row (chain + network + symbol). */ +export function balanceKey( + balance: Pick +): string { + return `${balance.chain}-${balance.evmNetwork ?? 'native'}-${balance.assetSymbol}`; +} + +/** + * Convert a human-entered decimal amount (e.g. "1.5") into the asset's smallest + * unit as a decimal string (e.g. "1500000000000000000" for 18 decimals). + * Throws on malformed input or more fractional digits than `decimals` allows. + * + * The thrown messages are internal sentinels (developer-facing only): callers + * catch them and surface a translated, user-facing message via `useT()` — + * never render `error.message` from here directly. + */ +export function toSmallestUnit(human: string, decimals: number): string { + const trimmed = human.trim(); + if (trimmed === '' || trimmed === '.' || !/^\d*\.?\d*$/.test(trimmed)) { + throw new Error('invalid_amount'); + } + const [whole, frac = ''] = trimmed.split('.'); + if (frac.length > decimals) { + throw new Error('too_many_decimals'); + } + const combined = `${whole || '0'}${frac.padEnd(decimals, '0')}`; + const normalized = combined.replace(/^0+(?=\d)/, ''); + return normalized === '' ? '0' : normalized; +} + +/** + * Format a smallest-unit decimal string back to a human amount, trimming + * trailing fractional zeros. Inverse of {@link toSmallestUnit}. + */ +export function fromSmallestUnit(raw: string, decimals: number): string { + if (!/^\d+$/.test(raw)) return raw; + if (decimals === 0) return raw; + const padded = raw.padStart(decimals + 1, '0'); + const whole = padded.slice(0, padded.length - decimals); + const frac = padded.slice(padded.length - decimals).replace(/0+$/, ''); + return frac.length > 0 ? `${whole}.${frac}` : whole; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index f97bf2300..437fe33bb 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4091,6 +4091,33 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'الخام: {raw}', 'walletBalances.errorGeneric': 'تعذّر تحميل أرصدة المحفظة. أعدّ محفظتك في عبارة الاسترداد وحاول مجدداً.', + 'walletBalances.setupHint': + 'لم تُعدّ عبارة الاسترداد بعد. أعدّها لتفعيل محفظتك وعرض الأرصدة المباشرة.', + 'walletBalances.setupCta': 'إعداد عبارة الاسترداد', + 'walletBalances.notSetUp': 'لم تُعد', + 'walletBalances.send': 'إرسال', + 'walletBalances.receive': 'استلام', + 'walletReceive.scanHint': 'امسح هذا الرمز أو انسخ العنوان أدناه لاستلام الأموال.', + 'walletReceive.addressLabel': 'عنوان {network}', + 'walletReceive.onlyChainWarning': + 'أرسل أصول {network} فقط إلى هذا العنوان. قد يؤدي إرسال أصول من شبكة أخرى إلى خسارة دائمة.', + 'walletSend.available': 'المتاح', + 'walletSend.recipient': 'عنوان المستلم', + 'walletSend.recipientPlaceholder': 'الصق عنوان الوجهة', + 'walletSend.recipientRequired': 'أدخل عنوان المستلم', + 'walletSend.amount': 'المبلغ', + 'walletSend.invalidAmount': 'أدخل مبلغاً صالحاً', + 'walletSend.review': 'مراجعة', + 'walletSend.preparing': 'جارٍ التحضير…', + 'walletSend.confirmHint': 'راجع التفاصيل أدناه. يتم التوقيع محلياً — لا يتم بث أي شيء حتى تؤكد.', + 'walletSend.estimatedFee': 'رسوم الشبكة المقدّرة', + 'walletSend.confirmSend': 'تأكيد وإرسال', + 'walletSend.sending': 'جارٍ الإرسال…', + 'walletSend.sent': 'تم إرسال المعاملة', + 'walletSend.txHash': 'تجزئة المعاملة', + 'walletSend.viewExplorer': 'العرض في المستكشف', + 'walletSend.done': 'تم', + 'walletSend.genericError': 'تعذّر إكمال التحويل. يرجى المحاولة مرة أخرى.', 'settings.taskSources.title': 'المصادر', 'settings.taskSources.subtitle': 'سحب المهام من أدواتك على لوحة العميل (تود)', 'settings.taskSources.description': @@ -4230,6 +4257,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'إدارة عواملك واستقلاليتها وما يمكنها الوصول إليه على هذا الجهاز.', 'settings.agentsSection.menuDesc': 'السجل، الاستقلالية ووصول نظام التشغيل', + 'settings.cryptoSection.title': 'العملات المشفرة', + 'settings.cryptoSection.description': + 'أدر عبارة الاسترداد الخاصة بك واعرض الأرصدة عبر حسابات محفظتك.', + 'settings.cryptoSection.menuDesc': 'عبارة الاسترداد وأرصدة المحفظة', + 'settings.notificationsHub.title': 'الإشعارات', + 'settings.notificationsHub.description': + 'اطّلع على صندوق التنبيهات وأدر تفضيلات الإشعارات والتوجيه.', + 'settings.notificationsHub.menuDesc': 'صندوق التنبيهات وتفضيلات الإشعارات', + 'settings.notificationsHub.settingsItem': 'إعدادات الإشعارات', + 'settings.notificationsHub.settingsItemDesc': 'التفضيلات والتوجيه', 'settings.agents.editor.notFound': 'العامل غير موجود.', 'settings.agents.editor.modelInherit': 'موروث (الافتراضي للمنصة)', 'settings.agents.editor.modelHints': 'تلميحات التوجيه', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4dfe3fda3..ec25d99ac 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4158,6 +4158,34 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'কাঁচা: {raw}', 'walletBalances.errorGeneric': 'ওয়ালেট ব্যালেন্স লোড করতে অক্ষম। Recovery Phrase-এ আপনার ওয়ালেট সেটআপ করুন এবং আবার চেষ্টা করুন।', + 'walletBalances.setupHint': + 'আপনার রিকভারি ফ্রেজ এখনও সেট আপ করা হয়নি। আপনার ওয়ালেট সক্রিয় করতে এবং লাইভ ব্যালেন্স দেখতে এটি সেট আপ করুন।', + 'walletBalances.setupCta': 'রিকভারি ফ্রেজ সেট আপ করুন', + 'walletBalances.notSetUp': 'সেট আপ করা হয়নি', + 'walletBalances.send': 'পাঠান', + 'walletBalances.receive': 'গ্রহণ করুন', + 'walletReceive.scanHint': 'তহবিল গ্রহণ করতে এই কোডটি স্ক্যান করুন বা নিচের ঠিকানাটি কপি করুন।', + 'walletReceive.addressLabel': '{network} ঠিকানা', + 'walletReceive.onlyChainWarning': + 'এই ঠিকানায় শুধুমাত্র {network} সম্পদ পাঠান। অন্য নেটওয়ার্কের সম্পদ পাঠালে স্থায়ীভাবে ক্ষতি হতে পারে।', + 'walletSend.available': 'উপলব্ধ', + 'walletSend.recipient': 'প্রাপকের ঠিকানা', + 'walletSend.recipientPlaceholder': 'গন্তব্য ঠিকানা পেস্ট করুন', + 'walletSend.recipientRequired': 'একটি প্রাপকের ঠিকানা লিখুন', + 'walletSend.amount': 'পরিমাণ', + 'walletSend.invalidAmount': 'একটি বৈধ পরিমাণ লিখুন', + 'walletSend.review': 'পর্যালোচনা', + 'walletSend.preparing': 'প্রস্তুত করা হচ্ছে…', + 'walletSend.confirmHint': + 'নিচের বিবরণ পর্যালোচনা করুন। স্বাক্ষর স্থানীয়ভাবে হয় — আপনি নিশ্চিত না করা পর্যন্ত কিছুই সম্প্রচার হয় না।', + 'walletSend.estimatedFee': 'আনুমানিক নেটওয়ার্ক ফি', + 'walletSend.confirmSend': 'নিশ্চিত করে পাঠান', + 'walletSend.sending': 'পাঠানো হচ্ছে…', + 'walletSend.sent': 'লেনদেন পাঠানো হয়েছে', + 'walletSend.txHash': 'লেনদেন হ্যাশ', + 'walletSend.viewExplorer': 'এক্সপ্লোরারে দেখুন', + 'walletSend.done': 'সম্পন্ন', + 'walletSend.genericError': 'স্থানান্তর সম্পূর্ণ করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।', 'settings.taskSources.title': 'কাজের উৎস', 'settings.taskSources.subtitle': 'আপনার টুল থেকে Tworet পরিচালনা করুন', 'settings.taskSources.description': @@ -4302,6 +4330,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'আপনার এজেন্ট, তাদের স্বায়ত্তশাসন এবং এই কম্পিউটারে তারা কী অ্যাক্সেস করতে পারে তা পরিচালনা করুন।', 'settings.agentsSection.menuDesc': 'রেজিস্ট্রি, স্বায়ত্তশাসন ও ওএস অ্যাক্সেস', + 'settings.cryptoSection.title': 'ক্রিপ্টো', + 'settings.cryptoSection.description': + 'আপনার রিকভারি ফ্রেজ পরিচালনা করুন এবং আপনার ওয়ালেট অ্যাকাউন্টগুলির ব্যালেন্স দেখুন।', + 'settings.cryptoSection.menuDesc': 'রিকভারি ফ্রেজ ও ওয়ালেট ব্যালেন্স', + 'settings.notificationsHub.title': 'বিজ্ঞপ্তি', + 'settings.notificationsHub.description': + 'আপনার অ্যালার্ট ইনবক্স দেখুন এবং বিজ্ঞপ্তির পছন্দ ও রাউটিং পরিচালনা করুন।', + 'settings.notificationsHub.menuDesc': 'অ্যালার্ট ইনবক্স ও বিজ্ঞপ্তির পছন্দ', + 'settings.notificationsHub.settingsItem': 'বিজ্ঞপ্তি সেটিংস', + 'settings.notificationsHub.settingsItemDesc': 'পছন্দ ও রাউটিং', 'settings.agents.editor.notFound': 'এজেন্ট পাওয়া যায়নি।', 'settings.agents.editor.modelInherit': 'উত্তরাধিকার (প্ল্যাটফর্ম ডিফল্ট)', 'settings.agents.editor.modelHints': 'রুট হিন্টস', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 9492944e3..4d948b779 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4272,6 +4272,36 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Roh: {raw}', 'walletBalances.errorGeneric': "Wallet-Guthaben konnten nicht geladen werden. Richte deine Wallet unter 'Wiederherstellungsphrase' ein und versuche es erneut.", + 'walletBalances.setupHint': + 'Deine Wiederherstellungsphrase ist noch nicht eingerichtet. Richte sie ein, um deine Wallet zu aktivieren und Live-Guthaben zu sehen.', + 'walletBalances.setupCta': 'Wiederherstellungsphrase einrichten', + 'walletBalances.notSetUp': 'Nicht eingerichtet', + 'walletBalances.send': 'Senden', + 'walletBalances.receive': 'Empfangen', + 'walletReceive.scanHint': + 'Scanne diesen Code oder kopiere die Adresse unten, um Gelder zu empfangen.', + 'walletReceive.addressLabel': '{network}-Adresse', + 'walletReceive.onlyChainWarning': + 'Sende nur {network}-Assets an diese Adresse. Das Senden von Assets aus einem anderen Netzwerk kann zu einem dauerhaften Verlust führen.', + 'walletSend.available': 'Verfügbar', + 'walletSend.recipient': 'Empfängeradresse', + 'walletSend.recipientPlaceholder': 'Zieladresse einfügen', + 'walletSend.recipientRequired': 'Gib eine Empfängeradresse ein', + 'walletSend.amount': 'Betrag', + 'walletSend.invalidAmount': 'Gib einen gültigen Betrag ein', + 'walletSend.review': 'Überprüfen', + 'walletSend.preparing': 'Wird vorbereitet…', + 'walletSend.confirmHint': + 'Überprüfe die Details unten. Die Signierung erfolgt lokal — nichts wird gesendet, bis du bestätigst.', + 'walletSend.estimatedFee': 'Geschätzte Netzwerkgebühr', + 'walletSend.confirmSend': 'Bestätigen und senden', + 'walletSend.sending': 'Wird gesendet…', + 'walletSend.sent': 'Transaktion gesendet', + 'walletSend.txHash': 'Transaktions-Hash', + 'walletSend.viewExplorer': 'Im Explorer ansehen', + 'walletSend.done': 'Fertig', + 'walletSend.genericError': + 'Überweisung konnte nicht abgeschlossen werden. Bitte versuche es erneut.', 'settings.taskSources.title': 'Aufgabenquellen', 'settings.taskSources.subtitle': 'Ziehen Sie Aufgaben aus Ihren Tools auf das Agenten-ToDo-Board', 'settings.taskSources.description': @@ -4416,6 +4446,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Verwalten Sie Ihre Agenten, deren Autonomie und worauf sie auf diesem Computer zugreifen dürfen.', 'settings.agentsSection.menuDesc': 'Registrierung, Autonomie & BS-Zugriff', + 'settings.cryptoSection.title': 'Krypto', + 'settings.cryptoSection.description': + 'Verwalte deine Wiederherstellungsphrase und sieh dir die Guthaben deiner Wallet-Konten an.', + 'settings.cryptoSection.menuDesc': 'Wiederherstellungsphrase & Wallet-Guthaben', + 'settings.notificationsHub.title': 'Benachrichtigungen', + 'settings.notificationsHub.description': + 'Sieh dir deinen Hinweise-Posteingang an und verwalte Benachrichtigungseinstellungen und Routing.', + 'settings.notificationsHub.menuDesc': 'Hinweise-Posteingang & Benachrichtigungseinstellungen', + 'settings.notificationsHub.settingsItem': 'Benachrichtigungseinstellungen', + 'settings.notificationsHub.settingsItemDesc': 'Einstellungen & Routing', 'settings.agents.editor.notFound': 'Agent nicht gefunden.', 'settings.agents.editor.modelInherit': 'Übernehmen (Plattformstandard)', 'settings.agents.editor.modelHints': 'Routing-Hinweise', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index f09217ba7..2ec4ef0ec 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4378,6 +4378,36 @@ const en: TranslationMap = { 'walletBalances.rawBalance': 'Raw: {raw}', 'walletBalances.errorGeneric': 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', + 'walletBalances.setupHint': + 'Your recovery phrase isn’t set up yet. Set it up to enable your wallet and see live balances.', + 'walletBalances.setupCta': 'Set up recovery phrase', + 'walletBalances.notSetUp': 'Not set up', + 'walletBalances.send': 'Send', + 'walletBalances.receive': 'Receive', + // Receive modal + 'walletReceive.scanHint': 'Scan this code or copy the address below to receive funds.', + 'walletReceive.addressLabel': '{network} address', + 'walletReceive.onlyChainWarning': + 'Only send {network} assets to this address. Sending assets from another network may result in permanent loss.', + // Send modal + 'walletSend.available': 'Available', + 'walletSend.recipient': 'Recipient address', + 'walletSend.recipientPlaceholder': 'Paste the destination address', + 'walletSend.recipientRequired': 'Enter a recipient address', + 'walletSend.amount': 'Amount', + 'walletSend.invalidAmount': 'Enter a valid amount', + 'walletSend.review': 'Review', + 'walletSend.preparing': 'Preparing…', + 'walletSend.confirmHint': + 'Review the details below. Signing happens locally — nothing is broadcast until you confirm.', + 'walletSend.estimatedFee': 'Estimated network fee', + 'walletSend.confirmSend': 'Confirm & send', + 'walletSend.sending': 'Sending…', + 'walletSend.sent': 'Transaction sent', + 'walletSend.txHash': 'Transaction hash', + 'walletSend.viewExplorer': 'View on explorer', + 'walletSend.done': 'Done', + 'walletSend.genericError': 'Could not complete the transfer. Please try again.', // Task sources (#task-sources) 'settings.taskSources.title': 'Task Sources', 'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board', @@ -4530,6 +4560,16 @@ const en: TranslationMap = { 'settings.agentsSection.description': 'Manage your agents, their autonomy, and what they can access on this computer.', 'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access', + 'settings.cryptoSection.title': 'Crypto', + 'settings.cryptoSection.description': + 'Manage your recovery phrase and view balances across your wallet accounts.', + 'settings.cryptoSection.menuDesc': 'Recovery phrase & wallet balances', + 'settings.notificationsHub.title': 'Notifications', + 'settings.notificationsHub.description': + 'View your alerts inbox and manage notification preferences and routing.', + 'settings.notificationsHub.menuDesc': 'Alerts inbox & notification preferences', + 'settings.notificationsHub.settingsItem': 'Notification settings', + 'settings.notificationsHub.settingsItemDesc': 'Preferences & routing', 'settings.agents.editor.notFound': 'Agent not found.', 'settings.agents.editor.modelInherit': 'Inherit (platform default)', 'settings.agents.editor.modelHints': 'Route hints', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6a202ab4b..611e7eb9a 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4239,6 +4239,35 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Bruto: {raw}', 'walletBalances.errorGeneric': 'No se pueden cargar los saldos de la billetera. Configura tu billetera en Frase de recuperación e inténtalo de nuevo.', + 'walletBalances.setupHint': + 'Tu frase de recuperación aún no está configurada. Configúrala para activar tu monedero y ver los saldos en vivo.', + 'walletBalances.setupCta': 'Configurar frase de recuperación', + 'walletBalances.notSetUp': 'Sin configurar', + 'walletBalances.send': 'Enviar', + 'walletBalances.receive': 'Recibir', + 'walletReceive.scanHint': + 'Escanea este código o copia la dirección de abajo para recibir fondos.', + 'walletReceive.addressLabel': 'Dirección de {network}', + 'walletReceive.onlyChainWarning': + 'Envía solo activos de {network} a esta dirección. Enviar activos de otra red puede provocar una pérdida permanente.', + 'walletSend.available': 'Disponible', + 'walletSend.recipient': 'Dirección del destinatario', + 'walletSend.recipientPlaceholder': 'Pega la dirección de destino', + 'walletSend.recipientRequired': 'Introduce una dirección de destinatario', + 'walletSend.amount': 'Importe', + 'walletSend.invalidAmount': 'Introduce un importe válido', + 'walletSend.review': 'Revisar', + 'walletSend.preparing': 'Preparando…', + 'walletSend.confirmHint': + 'Revisa los detalles a continuación. La firma se realiza localmente; no se transmite nada hasta que confirmes.', + 'walletSend.estimatedFee': 'Comisión de red estimada', + 'walletSend.confirmSend': 'Confirmar y enviar', + 'walletSend.sending': 'Enviando…', + 'walletSend.sent': 'Transacción enviada', + 'walletSend.txHash': 'Hash de la transacción', + 'walletSend.viewExplorer': 'Ver en el explorador', + 'walletSend.done': 'Hecho', + 'walletSend.genericError': 'No se pudo completar la transferencia. Inténtalo de nuevo.', 'settings.taskSources.title': 'Fuentes de tareas', 'settings.taskSources.subtitle': 'Extrae tareas de tus herramientas al tablero de tareas del agente', @@ -4385,6 +4414,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Administra tus agentes, su autonomía y a qué pueden acceder en este equipo.', 'settings.agentsSection.menuDesc': 'Registro, autonomía y acceso al SO', + 'settings.cryptoSection.title': 'Cripto', + 'settings.cryptoSection.description': + 'Gestiona tu frase de recuperación y consulta los saldos de tus cuentas de monedero.', + 'settings.cryptoSection.menuDesc': 'Frase de recuperación y saldos del monedero', + 'settings.notificationsHub.title': 'Notificaciones', + 'settings.notificationsHub.description': + 'Consulta tu bandeja de alertas y gestiona las preferencias de notificación y el enrutamiento.', + 'settings.notificationsHub.menuDesc': 'Bandeja de alertas y preferencias de notificación', + 'settings.notificationsHub.settingsItem': 'Ajustes de notificaciones', + 'settings.notificationsHub.settingsItemDesc': 'Preferencias y enrutamiento', 'settings.agents.editor.notFound': 'Agente no encontrado.', 'settings.agents.editor.modelInherit': 'Heredar (predeterminado de la plataforma)', 'settings.agents.editor.modelHints': 'Sugerencias de enrutamiento', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d3b9003a0..ba863a439 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4256,6 +4256,35 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Brut : {raw}', 'walletBalances.errorGeneric': 'Impossible de charger les soldes du portefeuille. Configurez votre portefeuille dans Phrase de récupération et réessayez.', + 'walletBalances.setupHint': + 'Votre phrase de récupération n’est pas encore configurée. Configurez-la pour activer votre portefeuille et voir les soldes en direct.', + 'walletBalances.setupCta': 'Configurer la phrase de récupération', + 'walletBalances.notSetUp': 'Non configuré', + 'walletBalances.send': 'Envoyer', + 'walletBalances.receive': 'Recevoir', + 'walletReceive.scanHint': + 'Scannez ce code ou copiez l’adresse ci-dessous pour recevoir des fonds.', + 'walletReceive.addressLabel': 'Adresse {network}', + 'walletReceive.onlyChainWarning': + 'N’envoyez que des actifs {network} à cette adresse. Envoyer des actifs d’un autre réseau peut entraîner une perte définitive.', + 'walletSend.available': 'Disponible', + 'walletSend.recipient': 'Adresse du destinataire', + 'walletSend.recipientPlaceholder': 'Collez l’adresse de destination', + 'walletSend.recipientRequired': 'Saisissez une adresse de destinataire', + 'walletSend.amount': 'Montant', + 'walletSend.invalidAmount': 'Saisissez un montant valide', + 'walletSend.review': 'Vérifier', + 'walletSend.preparing': 'Préparation…', + 'walletSend.confirmHint': + 'Vérifiez les détails ci-dessous. La signature a lieu localement ; rien n’est diffusé tant que vous ne confirmez pas.', + 'walletSend.estimatedFee': 'Frais de réseau estimés', + 'walletSend.confirmSend': 'Confirmer et envoyer', + 'walletSend.sending': 'Envoi…', + 'walletSend.sent': 'Transaction envoyée', + 'walletSend.txHash': 'Hash de la transaction', + 'walletSend.viewExplorer': 'Voir dans l’explorateur', + 'walletSend.done': 'Terminé', + 'walletSend.genericError': 'Impossible de finaliser le transfert. Veuillez réessayer.', 'settings.taskSources.title': 'Sources de tâches', 'settings.taskSources.subtitle': "Tirez les tâches de vos outils sur le tableau des tâches de l'agent", @@ -4401,6 +4430,17 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Gérez vos agents, leur autonomie et ce à quoi ils peuvent accéder sur cet ordinateur.', 'settings.agentsSection.menuDesc': 'Registre, autonomie et accès au système', + 'settings.cryptoSection.title': 'Crypto', + 'settings.cryptoSection.description': + 'Gérez votre phrase de récupération et consultez les soldes de vos comptes de portefeuille.', + 'settings.cryptoSection.menuDesc': 'Phrase de récupération et soldes du portefeuille', + 'settings.notificationsHub.title': 'Notifications', + 'settings.notificationsHub.description': + 'Consultez votre boîte de réception des alertes et gérez les préférences de notification et le routage.', + 'settings.notificationsHub.menuDesc': + 'Boîte de réception des alertes et préférences de notification', + 'settings.notificationsHub.settingsItem': 'Paramètres de notification', + 'settings.notificationsHub.settingsItemDesc': 'Préférences et routage', 'settings.agents.editor.notFound': 'Agent introuvable.', 'settings.agents.editor.modelInherit': 'Hériter (défaut de la plateforme)', 'settings.agents.editor.modelHints': 'Conseils de routage', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 63a303c4f..ac8a88881 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4165,6 +4165,35 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'रॉ: {raw}', 'walletBalances.errorGeneric': 'वॉलेट बैलेंस लोड नहीं हो सका। Recovery Phrase में अपना वॉलेट सेटअप करें और पुनः प्रयास करें।', + 'walletBalances.setupHint': + 'आपका रिकवरी फ्रेज़ अभी सेटअप नहीं है। अपना वॉलेट सक्रिय करने और लाइव बैलेंस देखने के लिए इसे सेटअप करें।', + 'walletBalances.setupCta': 'रिकवरी फ्रेज़ सेटअप करें', + 'walletBalances.notSetUp': 'सेटअप नहीं है', + 'walletBalances.send': 'भेजें', + 'walletBalances.receive': 'प्राप्त करें', + 'walletReceive.scanHint': + 'फंड प्राप्त करने के लिए इस कोड को स्कैन करें या नीचे दिया पता कॉपी करें।', + 'walletReceive.addressLabel': '{network} पता', + 'walletReceive.onlyChainWarning': + 'इस पते पर केवल {network} एसेट भेजें। किसी अन्य नेटवर्क के एसेट भेजने से स्थायी हानि हो सकती है।', + 'walletSend.available': 'उपलब्ध', + 'walletSend.recipient': 'प्राप्तकर्ता का पता', + 'walletSend.recipientPlaceholder': 'गंतव्य पता पेस्ट करें', + 'walletSend.recipientRequired': 'प्राप्तकर्ता का पता दर्ज करें', + 'walletSend.amount': 'राशि', + 'walletSend.invalidAmount': 'एक मान्य राशि दर्ज करें', + 'walletSend.review': 'समीक्षा करें', + 'walletSend.preparing': 'तैयार किया जा रहा है…', + 'walletSend.confirmHint': + 'नीचे दिए विवरण की समीक्षा करें। हस्ताक्षर स्थानीय रूप से होता है — जब तक आप पुष्टि नहीं करते, कुछ भी प्रसारित नहीं होता।', + 'walletSend.estimatedFee': 'अनुमानित नेटवर्क शुल्क', + 'walletSend.confirmSend': 'पुष्टि करें और भेजें', + 'walletSend.sending': 'भेजा जा रहा है…', + 'walletSend.sent': 'लेनदेन भेजा गया', + 'walletSend.txHash': 'लेनदेन हैश', + 'walletSend.viewExplorer': 'एक्सप्लोरर में देखें', + 'walletSend.done': 'हो गया', + 'walletSend.genericError': 'स्थानांतरण पूरा नहीं हो सका। कृपया पुनः प्रयास करें।', 'settings.taskSources.title': 'कार्य स्रोत', 'settings.taskSources.subtitle': 'एजेंट टोडो बोर्ड पर अपने उपकरणों से कार्य खींचें', 'settings.taskSources.description': @@ -4309,6 +4338,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'अपने एजेंट, उनकी स्वायत्तता और इस कंप्यूटर पर वे क्या एक्सेस कर सकते हैं, यह प्रबंधित करें।', 'settings.agentsSection.menuDesc': 'रजिस्ट्री, स्वायत्तता और OS एक्सेस', + 'settings.cryptoSection.title': 'क्रिप्टো', + 'settings.cryptoSection.description': + 'अपना रिकवरी फ्रेज़ प्रबंधित करें और अपने वॉलेट खातों के बैलेंस देखें।', + 'settings.cryptoSection.menuDesc': 'रिकवरी फ्रेज़ और वॉलेट बैलेंस', + 'settings.notificationsHub.title': 'सूचनाएं', + 'settings.notificationsHub.description': + 'अपना अलर्ट इनबॉक्स देखें और सूचना प्राथमिकताएं व रूटिंग प्रबंधित करें।', + 'settings.notificationsHub.menuDesc': 'अलर्ट इनबॉक्स और सूचना प्राथमिकताएं', + 'settings.notificationsHub.settingsItem': 'सूचना सेटिंग्स', + 'settings.notificationsHub.settingsItemDesc': 'प्राथमिकताएं और रूटिंग', 'settings.agents.editor.notFound': 'एजेंट नहीं मिला।', 'settings.agents.editor.modelInherit': 'इनहेरिट करें (प्लेटफ़ॉर्म डिफ़ॉल्ट)', 'settings.agents.editor.modelHints': 'रूट संकेत', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index fead3634c..e3b99b1f7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4175,6 +4175,34 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Mentah: {raw}', 'walletBalances.errorGeneric': 'Tidak dapat memuat saldo dompet. Atur dompet Anda di Frasa Pemulihan dan coba lagi.', + 'walletBalances.setupHint': + 'Frasa pemulihan Anda belum disiapkan. Siapkan untuk mengaktifkan dompet Anda dan melihat saldo langsung.', + 'walletBalances.setupCta': 'Siapkan frasa pemulihan', + 'walletBalances.notSetUp': 'Belum disiapkan', + 'walletBalances.send': 'Kirim', + 'walletBalances.receive': 'Terima', + 'walletReceive.scanHint': 'Pindai kode ini atau salin alamat di bawah untuk menerima dana.', + 'walletReceive.addressLabel': 'Alamat {network}', + 'walletReceive.onlyChainWarning': + 'Hanya kirim aset {network} ke alamat ini. Mengirim aset dari jaringan lain dapat mengakibatkan kehilangan permanen.', + 'walletSend.available': 'Tersedia', + 'walletSend.recipient': 'Alamat penerima', + 'walletSend.recipientPlaceholder': 'Tempel alamat tujuan', + 'walletSend.recipientRequired': 'Masukkan alamat penerima', + 'walletSend.amount': 'Jumlah', + 'walletSend.invalidAmount': 'Masukkan jumlah yang valid', + 'walletSend.review': 'Tinjau', + 'walletSend.preparing': 'Menyiapkan…', + 'walletSend.confirmHint': + 'Tinjau detail di bawah. Penandatanganan dilakukan secara lokal — tidak ada yang disiarkan sampai Anda mengonfirmasi.', + 'walletSend.estimatedFee': 'Estimasi biaya jaringan', + 'walletSend.confirmSend': 'Konfirmasi & kirim', + 'walletSend.sending': 'Mengirim…', + 'walletSend.sent': 'Transaksi terkirim', + 'walletSend.txHash': 'Hash transaksi', + 'walletSend.viewExplorer': 'Lihat di explorer', + 'walletSend.done': 'Selesai', + 'walletSend.genericError': 'Tidak dapat menyelesaikan transfer. Silakan coba lagi.', 'settings.taskSources.title': 'Sumber Tugas', 'settings.taskSources.subtitle': 'Tarik tugas dari alat Anda ke papan todo agen', 'settings.taskSources.description': @@ -4318,6 +4346,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Kelola agen Anda, otonomi mereka, dan apa yang dapat mereka akses di komputer ini.', 'settings.agentsSection.menuDesc': 'Registri, otonomi & akses OS', + 'settings.cryptoSection.title': 'Kripto', + 'settings.cryptoSection.description': + 'Kelola frasa pemulihan Anda dan lihat saldo di seluruh akun dompet Anda.', + 'settings.cryptoSection.menuDesc': 'Frasa pemulihan & saldo dompet', + 'settings.notificationsHub.title': 'Notifikasi', + 'settings.notificationsHub.description': + 'Lihat kotak masuk lansiran Anda dan kelola preferensi notifikasi serta perutean.', + 'settings.notificationsHub.menuDesc': 'Kotak masuk lansiran & preferensi notifikasi', + 'settings.notificationsHub.settingsItem': 'Pengaturan notifikasi', + 'settings.notificationsHub.settingsItemDesc': 'Preferensi & perutean', 'settings.agents.editor.notFound': 'Agen tidak ditemukan.', 'settings.agents.editor.modelInherit': 'Warisi (default platform)', 'settings.agents.editor.modelHints': 'Petunjuk rute', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 40f213b55..f1dfb0867 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4232,6 +4232,35 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Grezzo: {raw}', 'walletBalances.errorGeneric': 'Impossibile caricare i saldi del portafoglio. Configura il tuo portafoglio in Frase di recupero e riprova.', + 'walletBalances.setupHint': + 'La tua frase di recupero non è ancora configurata. Configurala per attivare il tuo wallet e vedere i saldi in tempo reale.', + 'walletBalances.setupCta': 'Configura la frase di recupero', + 'walletBalances.notSetUp': 'Non configurato', + 'walletBalances.send': 'Invia', + 'walletBalances.receive': 'Ricevi', + 'walletReceive.scanHint': + 'Scansiona questo codice o copia l’indirizzo qui sotto per ricevere fondi.', + 'walletReceive.addressLabel': 'Indirizzo {network}', + 'walletReceive.onlyChainWarning': + 'Invia solo asset {network} a questo indirizzo. Inviare asset da un’altra rete può causare una perdita permanente.', + 'walletSend.available': 'Disponibile', + 'walletSend.recipient': 'Indirizzo del destinatario', + 'walletSend.recipientPlaceholder': 'Incolla l’indirizzo di destinazione', + 'walletSend.recipientRequired': 'Inserisci un indirizzo del destinatario', + 'walletSend.amount': 'Importo', + 'walletSend.invalidAmount': 'Inserisci un importo valido', + 'walletSend.review': 'Rivedi', + 'walletSend.preparing': 'Preparazione…', + 'walletSend.confirmHint': + 'Rivedi i dettagli qui sotto. La firma avviene localmente: nulla viene trasmesso finché non confermi.', + 'walletSend.estimatedFee': 'Commissione di rete stimata', + 'walletSend.confirmSend': 'Conferma e invia', + 'walletSend.sending': 'Invio…', + 'walletSend.sent': 'Transazione inviata', + 'walletSend.txHash': 'Hash della transazione', + 'walletSend.viewExplorer': 'Vedi nell’explorer', + 'walletSend.done': 'Fatto', + 'walletSend.genericError': 'Impossibile completare il trasferimento. Riprova.', 'settings.taskSources.title': 'Fonti del compito', 'settings.taskSources.subtitle': "Estrai le attività dai tuoi strumenti sulla lavagna delle cose da fare dell'agente", @@ -4378,6 +4407,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Gestisci i tuoi agenti, la loro autonomia e a cosa possono accedere su questo computer.', 'settings.agentsSection.menuDesc': 'Registro, autonomia e accesso al sistema operativo', + 'settings.cryptoSection.title': 'Cripto', + 'settings.cryptoSection.description': + 'Gestisci la tua frase di recupero e visualizza i saldi dei tuoi account wallet.', + 'settings.cryptoSection.menuDesc': 'Frase di recupero e saldi del wallet', + 'settings.notificationsHub.title': 'Notifiche', + 'settings.notificationsHub.description': + 'Visualizza la tua casella degli avvisi e gestisci le preferenze di notifica e l’instradamento.', + 'settings.notificationsHub.menuDesc': 'Casella degli avvisi e preferenze di notifica', + 'settings.notificationsHub.settingsItem': 'Impostazioni di notifica', + 'settings.notificationsHub.settingsItemDesc': 'Preferenze e instradamento', 'settings.agents.editor.notFound': 'Agente non trovato.', 'settings.agents.editor.modelInherit': 'Eredita (predefinito della piattaforma)', 'settings.agents.editor.modelHints': 'Suggerimenti di instradamento', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 56ec9d933..21c9f06f7 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4125,6 +4125,34 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': '원본: {raw}', 'walletBalances.errorGeneric': '지갑 잔액을 불러올 수 없습니다. 복구 문구에서 지갑을 설정하고 다시 시도하세요.', + 'walletBalances.setupHint': + '복구 문구가 아직 설정되지 않았습니다. 지갑을 활성화하고 실시간 잔액을 보려면 설정하세요.', + 'walletBalances.setupCta': '복구 문구 설정', + 'walletBalances.notSetUp': '설정 안 됨', + 'walletBalances.send': '보내기', + 'walletBalances.receive': '받기', + 'walletReceive.scanHint': '자금을 받으려면 이 코드를 스캔하거나 아래 주소를 복사하세요.', + 'walletReceive.addressLabel': '{network} 주소', + 'walletReceive.onlyChainWarning': + '이 주소로는 {network} 자산만 보내세요. 다른 네트워크의 자산을 보내면 영구적으로 손실될 수 있습니다.', + 'walletSend.available': '사용 가능', + 'walletSend.recipient': '받는 사람 주소', + 'walletSend.recipientPlaceholder': '대상 주소 붙여넣기', + 'walletSend.recipientRequired': '받는 사람 주소를 입력하세요', + 'walletSend.amount': '금액', + 'walletSend.invalidAmount': '유효한 금액을 입력하세요', + 'walletSend.review': '검토', + 'walletSend.preparing': '준비 중…', + 'walletSend.confirmHint': + '아래 세부 정보를 확인하세요. 서명은 로컬에서 이루어지며 확인하기 전에는 아무것도 전송되지 않습니다.', + 'walletSend.estimatedFee': '예상 네트워크 수수료', + 'walletSend.confirmSend': '확인 후 보내기', + 'walletSend.sending': '보내는 중…', + 'walletSend.sent': '거래가 전송됨', + 'walletSend.txHash': '거래 해시', + 'walletSend.viewExplorer': '익스플로러에서 보기', + 'walletSend.done': '완료', + 'walletSend.genericError': '전송을 완료할 수 없습니다. 다시 시도하세요.', 'settings.taskSources.title': '작업 소스', 'settings.taskSources.subtitle': '도구의 작업을 에이전트 할 일 보드로 가져옵니다', 'settings.taskSources.description': @@ -4269,6 +4297,15 @@ const messages: TranslationMap = { 'settings.agentsSection.description': '에이전트와 그 자율성, 그리고 이 컴퓨터에서 액세스할 수 있는 항목을 관리하세요.', 'settings.agentsSection.menuDesc': '레지스트리, 자율성 및 OS 접근', + 'settings.cryptoSection.title': '암호화폐', + 'settings.cryptoSection.description': '복구 문구를 관리하고 지갑 계정 전반의 잔액을 확인하세요.', + 'settings.cryptoSection.menuDesc': '복구 문구 및 지갑 잔액', + 'settings.notificationsHub.title': '알림', + 'settings.notificationsHub.description': + '알림 받은 편지함을 확인하고 알림 환경설정과 라우팅을 관리하세요.', + 'settings.notificationsHub.menuDesc': '알림 받은 편지함 및 알림 환경설정', + 'settings.notificationsHub.settingsItem': '알림 설정', + 'settings.notificationsHub.settingsItemDesc': '환경설정 및 라우팅', 'settings.agents.editor.notFound': '에이전트를 찾을 수 없습니다.', 'settings.agents.editor.modelInherit': '상속 (플랫폼 기본값)', 'settings.agents.editor.modelHints': '라우트 힌트', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 212fd59d6..cc1d88b69 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4232,6 +4232,34 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Nieprzetworzone: {raw}', 'walletBalances.errorGeneric': 'Nie można załadować sald portfela. Skonfiguruj portfel w sekcji Fraza odzyskiwania i spróbuj ponownie.', + 'walletBalances.setupHint': + 'Twoja fraza odzyskiwania nie jest jeszcze skonfigurowana. Skonfiguruj ją, aby włączyć portfel i widzieć salda na żywo.', + 'walletBalances.setupCta': 'Skonfiguruj frazę odzyskiwania', + 'walletBalances.notSetUp': 'Nie skonfigurowano', + 'walletBalances.send': 'Wyślij', + 'walletBalances.receive': 'Odbierz', + 'walletReceive.scanHint': 'Zeskanuj ten kod lub skopiuj adres poniżej, aby otrzymać środki.', + 'walletReceive.addressLabel': 'Adres {network}', + 'walletReceive.onlyChainWarning': + 'Wysyłaj na ten adres wyłącznie aktywa {network}. Wysłanie aktywów z innej sieci może spowodować trwałą utratę.', + 'walletSend.available': 'Dostępne', + 'walletSend.recipient': 'Adres odbiorcy', + 'walletSend.recipientPlaceholder': 'Wklej adres docelowy', + 'walletSend.recipientRequired': 'Wprowadź adres odbiorcy', + 'walletSend.amount': 'Kwota', + 'walletSend.invalidAmount': 'Wprowadź prawidłową kwotę', + 'walletSend.review': 'Przejrzyj', + 'walletSend.preparing': 'Przygotowywanie…', + 'walletSend.confirmHint': + 'Sprawdź poniższe szczegóły. Podpisywanie odbywa się lokalnie — nic nie zostanie wysłane, dopóki nie potwierdzisz.', + 'walletSend.estimatedFee': 'Szacowana opłata sieciowa', + 'walletSend.confirmSend': 'Potwierdź i wyślij', + 'walletSend.sending': 'Wysyłanie…', + 'walletSend.sent': 'Transakcja wysłana', + 'walletSend.txHash': 'Hash transakcji', + 'walletSend.viewExplorer': 'Zobacz w eksploratorze', + 'walletSend.done': 'Gotowe', + 'walletSend.genericError': 'Nie udało się zrealizować transferu. Spróbuj ponownie.', 'settings.taskSources.title': 'Źródła zadań', 'settings.taskSources.subtitle': 'Pobieraj zadania z narzędzi na tablicę zadań agenta', 'settings.taskSources.description': @@ -4376,6 +4404,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Zarządzaj agentami, ich autonomią i tym, do czego mogą uzyskać dostęp na tym komputerze.', 'settings.agentsSection.menuDesc': 'Rejestr, autonomia i dostęp do systemu', + 'settings.cryptoSection.title': 'Krypto', + 'settings.cryptoSection.description': + 'Zarządzaj frazą odzyskiwania i sprawdzaj salda na kontach portfela.', + 'settings.cryptoSection.menuDesc': 'Fraza odzyskiwania i salda portfela', + 'settings.notificationsHub.title': 'Powiadomienia', + 'settings.notificationsHub.description': + 'Przeglądaj skrzynkę alertów oraz zarządzaj preferencjami powiadomień i routingiem.', + 'settings.notificationsHub.menuDesc': 'Skrzynka alertów i preferencje powiadomień', + 'settings.notificationsHub.settingsItem': 'Ustawienia powiadomień', + 'settings.notificationsHub.settingsItemDesc': 'Preferencje i routing', 'settings.agents.editor.notFound': 'Nie znaleziono agenta.', 'settings.agents.editor.modelInherit': 'Dziedzicz (domyślne platformy)', 'settings.agents.editor.modelHints': 'Wskazówki trasowania', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index c432dfc99..54ad3bf25 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4230,6 +4230,34 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Bruto: {raw}', 'walletBalances.errorGeneric': 'Não foi possível carregar os saldos da carteira. Configure sua carteira em Frase de Recuperação e tente novamente.', + 'walletBalances.setupHint': + 'A sua frase de recuperação ainda não está configurada. Configure-a para ativar a sua carteira e ver os saldos em tempo real.', + 'walletBalances.setupCta': 'Configurar frase de recuperação', + 'walletBalances.notSetUp': 'Não configurada', + 'walletBalances.send': 'Enviar', + 'walletBalances.receive': 'Receber', + 'walletReceive.scanHint': 'Leia este código ou copie o endereço abaixo para receber fundos.', + 'walletReceive.addressLabel': 'Endereço de {network}', + 'walletReceive.onlyChainWarning': + 'Envie apenas ativos de {network} para este endereço. Enviar ativos de outra rede pode resultar em perda permanente.', + 'walletSend.available': 'Disponível', + 'walletSend.recipient': 'Endereço do destinatário', + 'walletSend.recipientPlaceholder': 'Cole o endereço de destino', + 'walletSend.recipientRequired': 'Insira um endereço de destinatário', + 'walletSend.amount': 'Valor', + 'walletSend.invalidAmount': 'Insira um valor válido', + 'walletSend.review': 'Revisar', + 'walletSend.preparing': 'Preparando…', + 'walletSend.confirmHint': + 'Revise os detalhes abaixo. A assinatura acontece localmente — nada é transmitido até você confirmar.', + 'walletSend.estimatedFee': 'Taxa de rede estimada', + 'walletSend.confirmSend': 'Confirmar e enviar', + 'walletSend.sending': 'Enviando…', + 'walletSend.sent': 'Transação enviada', + 'walletSend.txHash': 'Hash da transação', + 'walletSend.viewExplorer': 'Ver no explorador', + 'walletSend.done': 'Concluído', + 'walletSend.genericError': 'Não foi possível concluir a transferência. Tente novamente.', 'settings.taskSources.title': 'Fontes da Tarefa', 'settings.taskSources.subtitle': 'Puxe tarefas de suas ferramentas para o quadro de tarefas do agente', @@ -4375,6 +4403,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Gerencie seus agentes, sua autonomia e o que eles podem acessar neste computador.', 'settings.agentsSection.menuDesc': 'Registro, autonomia e acesso ao SO', + 'settings.cryptoSection.title': 'Cripto', + 'settings.cryptoSection.description': + 'Gerencie sua frase de recuperação e veja os saldos das suas contas de carteira.', + 'settings.cryptoSection.menuDesc': 'Frase de recuperação e saldos da carteira', + 'settings.notificationsHub.title': 'Notificações', + 'settings.notificationsHub.description': + 'Veja a sua caixa de entrada de alertas e gerencie as preferências de notificação e o encaminhamento.', + 'settings.notificationsHub.menuDesc': 'Caixa de entrada de alertas e preferências de notificação', + 'settings.notificationsHub.settingsItem': 'Configurações de notificação', + 'settings.notificationsHub.settingsItemDesc': 'Preferências e encaminhamento', 'settings.agents.editor.notFound': 'Agente não encontrado.', 'settings.agents.editor.modelInherit': 'Herdar (padrão da plataforma)', 'settings.agents.editor.modelHints': 'Dicas de roteamento', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index ddf121f88..a3252110c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4199,6 +4199,35 @@ const messages: TranslationMap = { 'walletBalances.rawBalance': 'Исходный: {raw}', 'walletBalances.errorGeneric': 'Не удалось загрузить балансы кошелька. Настройте кошелёк в разделе «Фраза восстановления» и повторите попытку.', + 'walletBalances.setupHint': + 'Ваша фраза восстановления ещё не настроена. Настройте её, чтобы включить кошелёк и видеть актуальные балансы.', + 'walletBalances.setupCta': 'Настроить фразу восстановления', + 'walletBalances.notSetUp': 'Не настроено', + 'walletBalances.send': 'Отправить', + 'walletBalances.receive': 'Получить', + 'walletReceive.scanHint': + 'Отсканируйте этот код или скопируйте адрес ниже, чтобы получить средства.', + 'walletReceive.addressLabel': 'Адрес {network}', + 'walletReceive.onlyChainWarning': + 'Отправляйте на этот адрес только активы сети {network}. Отправка активов из другой сети может привести к безвозвратной потере.', + 'walletSend.available': 'Доступно', + 'walletSend.recipient': 'Адрес получателя', + 'walletSend.recipientPlaceholder': 'Вставьте адрес назначения', + 'walletSend.recipientRequired': 'Введите адрес получателя', + 'walletSend.amount': 'Сумма', + 'walletSend.invalidAmount': 'Введите корректную сумму', + 'walletSend.review': 'Проверить', + 'walletSend.preparing': 'Подготовка…', + 'walletSend.confirmHint': + 'Проверьте детали ниже. Подпись выполняется локально — ничего не отправляется, пока вы не подтвердите.', + 'walletSend.estimatedFee': 'Ориентировочная комиссия сети', + 'walletSend.confirmSend': 'Подтвердить и отправить', + 'walletSend.sending': 'Отправка…', + 'walletSend.sent': 'Транзакция отправлена', + 'walletSend.txHash': 'Хеш транзакции', + 'walletSend.viewExplorer': 'Открыть в обозревателе', + 'walletSend.done': 'Готово', + 'walletSend.genericError': 'Не удалось выполнить перевод. Повторите попытку.', 'settings.taskSources.title': 'Источники задач', 'settings.taskSources.subtitle': 'Переносите задачи из своих инструментов на доску задач агента.', 'settings.taskSources.description': @@ -4344,6 +4373,16 @@ const messages: TranslationMap = { 'settings.agentsSection.description': 'Управляйте агентами, их автономностью и доступом к ресурсам компьютера.', 'settings.agentsSection.menuDesc': 'Реестр, автономность и доступ к ОС', + 'settings.cryptoSection.title': 'Крипто', + 'settings.cryptoSection.description': + 'Управляйте секретной фразой восстановления и просматривайте балансы своих кошельковых счетов.', + 'settings.cryptoSection.menuDesc': 'Фраза восстановления и балансы кошелька', + 'settings.notificationsHub.title': 'Уведомления', + 'settings.notificationsHub.description': + 'Просматривайте папку оповещений и управляйте настройками уведомлений и маршрутизацией.', + 'settings.notificationsHub.menuDesc': 'Папка оповещений и настройки уведомлений', + 'settings.notificationsHub.settingsItem': 'Настройки уведомлений', + 'settings.notificationsHub.settingsItemDesc': 'Настройки и маршрутизация', 'settings.agents.editor.notFound': 'Агент не найден.', 'settings.agents.editor.modelInherit': 'Унаследовать (системное по умолчанию)', 'settings.agents.editor.modelHints': 'Подсказки маршрутизации', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 87e110b85..2cf5f916d 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3963,6 +3963,32 @@ const messages: TranslationMap = { 'walletBalances.providerMissing': '服务商不可用', 'walletBalances.rawBalance': '原始值:{raw}', 'walletBalances.errorGeneric': '无法加载钱包余额。请在恢复助记词中设置钱包后重试。', + 'walletBalances.setupHint': '你的恢复短语尚未设置。设置后即可启用钱包并查看实时余额。', + 'walletBalances.setupCta': '设置恢复短语', + 'walletBalances.notSetUp': '未设置', + 'walletBalances.send': '发送', + 'walletBalances.receive': '接收', + 'walletReceive.scanHint': '扫描此码或复制下方地址以接收资金。', + 'walletReceive.addressLabel': '{network} 地址', + 'walletReceive.onlyChainWarning': + '仅向此地址发送 {network} 资产。从其他网络发送资产可能导致永久损失。', + 'walletSend.available': '可用', + 'walletSend.recipient': '收款地址', + 'walletSend.recipientPlaceholder': '粘贴目标地址', + 'walletSend.recipientRequired': '请输入收款地址', + 'walletSend.amount': '金额', + 'walletSend.invalidAmount': '请输入有效金额', + 'walletSend.review': '审核', + 'walletSend.preparing': '准备中…', + 'walletSend.confirmHint': '请查看下方详情。签名在本地完成——在你确认之前不会广播任何内容。', + 'walletSend.estimatedFee': '预计网络费用', + 'walletSend.confirmSend': '确认并发送', + 'walletSend.sending': '发送中…', + 'walletSend.sent': '交易已发送', + 'walletSend.txHash': '交易哈希', + 'walletSend.viewExplorer': '在浏览器中查看', + 'walletSend.done': '完成', + 'walletSend.genericError': '无法完成转账。请重试。', 'settings.taskSources.title': '任务来源', 'settings.taskSources.subtitle': '从你的工具拉取任务到智能体待办板', 'settings.taskSources.description': @@ -4099,6 +4125,14 @@ const messages: TranslationMap = { 'settings.agentsSection.title': 'Agents', 'settings.agentsSection.description': '管理您的智能体、其自主权及其在本机上的访问权限。', 'settings.agentsSection.menuDesc': '注册表、自主权与系统访问', + 'settings.cryptoSection.title': '加密货币', + 'settings.cryptoSection.description': '管理你的恢复短语并查看各钱包账户的余额。', + 'settings.cryptoSection.menuDesc': '恢复短语和钱包余额', + 'settings.notificationsHub.title': '通知', + 'settings.notificationsHub.description': '查看你的提醒收件箱,并管理通知偏好与路由。', + 'settings.notificationsHub.menuDesc': '提醒收件箱与通知偏好', + 'settings.notificationsHub.settingsItem': '通知设置', + 'settings.notificationsHub.settingsItemDesc': '偏好与路由', 'settings.agents.editor.notFound': '未找到智能体。', 'settings.agents.editor.modelInherit': '继承(平台默认)', 'settings.agents.editor.modelHints': '路由提示', diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 370de0c84..4c0b5752a 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; import CostDashboardPanel from '../components/dashboard/CostDashboardPanel'; import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'; @@ -146,6 +146,16 @@ const ToolsIcon = ( /> ); +const NotificationSettingsIcon = ( + + + +); const LlmIcon = ( { const { t } = useT(); + const navigate = useNavigate(); const wrapSettingsPage = (element: ReactNode, opts?: { maxWidthClass?: string }) => ( @@ -240,13 +251,6 @@ const Settings = () => { ); const accountSettingsItems = [ - { - id: 'recovery-phrase', - title: t('pages.settings.account.recoveryPhrase'), - description: t('pages.settings.account.recoveryPhraseDesc'), - route: 'recovery-phrase', - icon: RecoveryPhraseIcon, - }, { id: 'team', title: t('pages.settings.account.team'), @@ -268,6 +272,37 @@ const Settings = () => { route: 'migration', icon: MigrationIcon, }, + ]; + + // Notifications hub (lives under Advanced) — gathers the Alerts inbox and the + // notification preferences/routing panel under one section page. + const notificationsHubItems = [ + { + id: 'alerts', + title: t('nav.alerts'), + description: t('settings.alertsDesc'), + // Alerts is the top-level inbox at `/notifications`, outside the settings + // tree, so navigate explicitly instead of via `navigateToSettings`. + onClick: () => navigate('/notifications'), + icon: NotificationsIcon, + }, + { + id: 'notification-settings', + title: t('settings.notificationsHub.settingsItem'), + description: t('settings.notificationsHub.settingsItemDesc'), + route: 'notifications', + icon: NotificationSettingsIcon, + }, + ]; + + const cryptoSettingsItems = [ + { + id: 'recovery-phrase', + title: t('pages.settings.account.recoveryPhrase'), + description: t('pages.settings.account.recoveryPhraseDesc'), + route: 'recovery-phrase', + icon: RecoveryPhraseIcon, + }, { id: 'wallet-balances', title: t('pages.settings.account.walletBalances'), @@ -485,6 +520,26 @@ const Settings = () => { /> )} /> + + )} + /> + + )} + /> {/* Account & Billing leaf panels */} )} /> )} /> diff --git a/app/src/services/walletApi.ts b/app/src/services/walletApi.ts index a3ac4d948..5b8f70d5b 100644 --- a/app/src/services/walletApi.ts +++ b/app/src/services/walletApi.ts @@ -10,8 +10,8 @@ export type WalletSetupSource = 'generated' | 'imported'; */ export interface BalanceInfo { chain: WalletChain; - /** Present only when chain === 'evm'. */ - evmNetwork?: string; + /** Present only when chain === 'evm'; identifies which EVM network the row is for. */ + evmNetwork?: EvmNetwork; address: string; assetSymbol: string; decimals: number; @@ -68,7 +68,8 @@ export const setupLocalWallet = async (params: SetupWalletParams): Promise => { }); return response.result; }; + +// --------------------------------------------------------------------------- +// Send / transfer surface +// +// The wallet uses a prepare-then-confirm-then-execute flow: `prepareTransfer` +// returns a quote (with the simulated fee) that must then be confirmed via +// `executePrepared`, which signs locally and broadcasts. Signing never leaves +// the core. Field names mirror the camelCase serde output in +// src/openhuman/wallet/execution.rs. +// --------------------------------------------------------------------------- + +/** EVM network selector accepted by prepare_transfer / tx queries. */ +export type EvmNetwork = + | 'ethereum_mainnet' + | 'base_mainnet' + | 'arbitrum_one' + | 'optimism_mainnet' + | 'polygon_mainnet' + | 'bsc_mainnet'; + +export type PreparedKind = 'native_transfer' | 'token_transfer'; +export type PreparedStatus = 'awaiting_confirmation' | 'broadcasted' | 'consumed'; + +export interface PreparedTransaction { + quoteId: string; + kind: PreparedKind; + chain: WalletChain; + evmNetwork?: EvmNetwork; + fromAddress: string; + toAddress: string; + assetSymbol: string; + amountRaw: string; + amountFormatted: string; + estimatedFeeRaw: string; + status: PreparedStatus; + createdAtMs: number; + expiresAtMs: number; + notes: string[]; +} + +export interface ExecutionResult { + quoteId: string; + status: PreparedStatus; + chain: WalletChain; + evmNetwork?: EvmNetwork; + transactionHash: string; + explorerUrl?: string; + transaction: PreparedTransaction; +} + +export interface PrepareTransferParams { + chain: WalletChain; + toAddress: string; + /** Amount in the asset's smallest unit (wei / sat / lamport / sun). */ + amountRaw: string; + /** Omit / undefined for the chain's native asset. */ + assetSymbol?: string; + /** Required only for chain === 'evm' to pick a network. */ + evmNetwork?: EvmNetwork; +} + +/** + * Build a transfer quote (simulated, not broadcast). Resolves to a + * PreparedTransaction carrying the quoteId + estimated fee; rejection surfaces + * a validation / provider error to show the user. + */ +export const prepareTransfer = async ( + params: PrepareTransferParams +): Promise => { + const response = await callCoreRpc<{ result: PreparedTransaction }>({ + method: 'openhuman.wallet_prepare_transfer', + params, + }); + return response.result; +}; + +/** + * Confirm and broadcast a previously prepared quote. `confirmed` is the + * explicit safety boundary between simulate and execute. + */ +export const executePrepared = async (quoteId: string): Promise => { + const response = await callCoreRpc<{ result: ExecutionResult }>({ + method: 'openhuman.wallet_execute_prepared', + params: { quoteId, confirmed: true }, + }); + return response.result; +}; diff --git a/app/test/playwright/specs/settings-account-preferences.spec.ts b/app/test/playwright/specs/settings-account-preferences.spec.ts index f5836cda2..aa6f7752b 100644 --- a/app/test/playwright/specs/settings-account-preferences.spec.ts +++ b/app/test/playwright/specs/settings-account-preferences.spec.ts @@ -35,9 +35,21 @@ test.describe('Settings - Account Preferences', () => { await gotoSettingsRoute(page, '/settings/account'); await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible(); - await expect(page.getByTestId('settings-nav-recovery-phrase')).toBeVisible(); await expect(page.getByTestId('settings-nav-team')).toBeVisible(); await expect(page.getByTestId('settings-nav-privacy')).toBeVisible(); + await expect(page.getByTestId('settings-nav-migration')).toBeVisible(); + // Recovery phrase + wallet balances moved out of Account into the Crypto hub. + await expect(page.getByTestId('settings-nav-recovery-phrase')).toHaveCount(0); + }); + + test('renders the crypto settings section route with recovery phrase + balances', async ({ + page, + }) => { + await gotoSettingsRoute(page, '/settings/crypto'); + + await expect(page.getByRole('heading', { name: 'Crypto' })).toBeVisible(); + await expect(page.getByTestId('settings-nav-recovery-phrase')).toBeVisible(); + await expect(page.getByTestId('settings-nav-wallet-balances')).toBeVisible(); }); test('saves a generated recovery phrase and exposes configured wallet state', async ({ diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 30ebe6762..33152e5b0 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -608,7 +608,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "skills", category: CapabilityCategory::Skills, description: "Set up local EVM, BTC, Solana, and Tron wallet identities from one recovery phrase.", - how_to: "Settings > Recovery Phrase or Settings > Connections", + how_to: "Settings > Crypto > Recovery Phrase or Settings > Connections", status: CapabilityStatus::Beta, privacy: LOCAL_CREDENTIALS, }, @@ -618,7 +618,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[ domain: "wallet", category: CapabilityCategory::Skills, description: "Read addresses and balances, prepare/confirm/execute native + token transfers (ERC20/SPL/TRC20/BEP20), and inspect transactions (status, receipt, lookup) across the connected wallet (EVM, BTC, Solana, Tron). Quote-first; signing stays local.", - how_to: "Use wallet.* RPC methods (balances, prepare_transfer, execute_prepared, tx_status, tx_receipt, lookup_tx) via the agent or core_rpc_relay, or via Settings > Wallet Balances.", + how_to: "Use wallet.* RPC methods (balances, prepare_transfer, execute_prepared, tx_status, tx_receipt, lookup_tx) via the agent or core_rpc_relay, or via Settings > Crypto > Wallet Balances.", status: CapabilityStatus::Beta, privacy: LOCAL_CREDENTIALS, }, diff --git a/src/openhuman/wallet/execution.rs b/src/openhuman/wallet/execution.rs index f7d1768cf..79aada85c 100644 --- a/src/openhuman/wallet/execution.rs +++ b/src/openhuman/wallet/execution.rs @@ -580,81 +580,161 @@ pub async fn chain_status() -> Result>, String> { )) } +/// EVM networks surfaced as their own native-balance rows. The single derived +/// EVM account address is shared across all of them, so `balances()` reads the +/// native asset (ETH / ETH / BNB) on each network independently. +pub const EVM_BALANCE_NETWORKS: [EvmNetwork; 3] = [ + EvmNetwork::EthereumMainnet, + EvmNetwork::BaseMainnet, + EvmNetwork::BscMainnet, +]; + +/// Build a single native-balance row, reading the live on-chain balance and +/// falling back to a zero/`Missing` row when the provider is unreachable. +fn balance_row( + chain: WalletChain, + evm_network: Option, + address: &str, + asset: WalletAssetDefinition, + raw: String, + provider_status: ProviderStatus, +) -> BalanceInfo { + let raw_u128 = raw.parse::().unwrap_or(0); + BalanceInfo { + chain, + evm_network, + address: address.to_string(), + asset_symbol: asset.symbol, + decimals: asset.decimals, + formatted: format_amount(raw_u128, asset.decimals), + raw, + provider_status, + } +} + +fn native_asset_for(chain: WalletChain) -> Result { + super::defaults::asset_catalog(chain) + .into_iter() + .find(|value| value.native) + .ok_or_else(|| format!("native asset metadata missing for '{}'", chain_str(chain))) +} + +fn evm_native_asset(network: EvmNetwork) -> Result { + evm_asset_catalog(network) + .into_iter() + .find(|value| value.native) + .ok_or_else(|| { + format!( + "native asset metadata missing for evm network '{}'", + network.as_str() + ) + }) +} + pub async fn balances() -> Result>, String> { let status = wallet_status().await?.value; if !status.configured { return Err("wallet is not configured; run wallet setup first".to_string()); } - let mut out = Vec::with_capacity(status.accounts.len()); + let mut out = Vec::with_capacity(status.accounts.len() + EVM_BALANCE_NETWORKS.len()); for account in &status.accounts { - let asset = super::defaults::asset_catalog(account.chain) - .into_iter() - .find(|value| value.native) - .ok_or_else(|| { - format!( - "native asset metadata missing for '{}'", - chain_str(account.chain) - ) - })?; - let (raw, provider_status) = match account.chain { + match account.chain { + // The EVM account fans out into one native-balance row per displayed + // network (Ethereum, Base, BNB Chain), all sharing the same address. WalletChain::Evm => { - match chain_evm::evm_balance(EvmNetwork::EthereumMainnet, &account.address).await { - Ok(balance) => (balance.to_string(), ProviderStatus::Ready), - Err(error) => { - warn!( - "{LOG_PREFIX} balances chain=evm address={} falling back to zero: {error}", - account.address - ); - ("0".to_string(), ProviderStatus::Missing) - } + for network in EVM_BALANCE_NETWORKS { + let asset = evm_native_asset(network)?; + let (raw, provider_status) = match chain_evm::evm_balance( + network, + &account.address, + ) + .await + { + Ok(balance) => (balance.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=evm network={} address={} falling back to zero: {error}", + network.as_str(), + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + }; + out.push(balance_row( + WalletChain::Evm, + Some(network), + &account.address, + asset, + raw, + provider_status, + )); } } - WalletChain::Btc => match chain_btc::native_balance(&account.address).await { - Ok(sats) => (sats.to_string(), ProviderStatus::Ready), - Err(error) => { - warn!( - "{LOG_PREFIX} balances chain=btc address={} falling back to zero: {error}", - account.address - ); - ("0".to_string(), ProviderStatus::Missing) - } - }, - WalletChain::Solana => match chain_sol::native_balance(&account.address).await { - Ok(lamports) => (lamports.to_string(), ProviderStatus::Ready), - Err(error) => { - warn!( - "{LOG_PREFIX} balances chain=solana address={} falling back to zero: {error}", - account.address - ); - ("0".to_string(), ProviderStatus::Missing) - } - }, - WalletChain::Tron => match chain_tron::native_balance(&account.address).await { - Ok(sun) => (sun.to_string(), ProviderStatus::Ready), - Err(error) => { - warn!( - "{LOG_PREFIX} balances chain=tron address={} falling back to zero: {error}", - account.address - ); - ("0".to_string(), ProviderStatus::Missing) - } - }, - }; - let raw_u128 = raw.parse::().unwrap_or(0); - out.push(BalanceInfo { - chain: account.chain, - evm_network: if account.chain == WalletChain::Evm { - Some(EvmNetwork::EthereumMainnet) - } else { - None - }, - address: account.address.clone(), - asset_symbol: asset.symbol, - decimals: asset.decimals, - raw, - formatted: format_amount(raw_u128, asset.decimals), - provider_status, - }); + WalletChain::Btc => { + let (raw, provider_status) = match chain_btc::native_balance(&account.address).await + { + Ok(sats) => (sats.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=btc address={} falling back to zero: {error}", + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + }; + out.push(balance_row( + WalletChain::Btc, + None, + &account.address, + native_asset_for(WalletChain::Btc)?, + raw, + provider_status, + )); + } + WalletChain::Solana => { + let (raw, provider_status) = match chain_sol::native_balance(&account.address).await + { + Ok(lamports) => (lamports.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=solana address={} falling back to zero: {error}", + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + }; + out.push(balance_row( + WalletChain::Solana, + None, + &account.address, + native_asset_for(WalletChain::Solana)?, + raw, + provider_status, + )); + } + WalletChain::Tron => { + let (raw, provider_status) = match chain_tron::native_balance(&account.address) + .await + { + Ok(sun) => (sun.to_string(), ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} balances chain=tron address={} falling back to zero: {error}", + account.address + ); + ("0".to_string(), ProviderStatus::Missing) + } + }; + out.push(balance_row( + WalletChain::Tron, + None, + &account.address, + native_asset_for(WalletChain::Tron)?, + raw, + provider_status, + )); + } + } } debug!("{LOG_PREFIX} balances returned rows={}", out.len()); Ok(RpcOutcome::new( diff --git a/src/openhuman/wallet/execution_tests.rs b/src/openhuman/wallet/execution_tests.rs index aa148bac7..10d3fff80 100644 --- a/src/openhuman/wallet/execution_tests.rs +++ b/src/openhuman/wallet/execution_tests.rs @@ -250,6 +250,63 @@ async fn prepare_transfer_rejects_unknown_asset_symbol() { assert!(err.contains("unsupported asset_symbol"), "got: {err}"); } +#[tokio::test] +async fn balances_fans_evm_account_into_eth_base_bsc_rows() { + let _guard = TEST_LOCK.lock(); + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let temp = TempDir::new().unwrap(); + setup_wallet_in(&temp).await.unwrap(); + // Point all three displayed EVM networks at a mock returning 1e18 wei. + let (addr, _estimate_calls, _raw_txs) = start_mock_rpc().await.unwrap(); + for var in [ + "OPENHUMAN_WALLET_RPC_EVM", + "OPENHUMAN_WALLET_RPC_BASE", + "OPENHUMAN_WALLET_RPC_BSC", + ] { + std::env::set_var(var, format!("http://{addr}")); + } + + let rows = balances().await.unwrap().value; + + // One row per displayed EVM network plus BTC / Solana / Tron. + let evm_rows: Vec<_> = rows + .iter() + .filter(|row| row.chain == WalletChain::Evm) + .collect(); + assert_eq!(evm_rows.len(), 3, "expected 3 EVM rows, got {evm_rows:?}"); + let networks: Vec = evm_rows.iter().filter_map(|row| row.evm_network).collect(); + assert!(networks.contains(&EvmNetwork::EthereumMainnet)); + assert!(networks.contains(&EvmNetwork::BaseMainnet)); + assert!(networks.contains(&EvmNetwork::BscMainnet)); + // Native symbols differ by network: ETH on Ethereum/Base, BNB on BNB Chain. + let bnb = evm_rows + .iter() + .find(|row| row.evm_network == Some(EvmNetwork::BscMainnet)) + .expect("bsc row present"); + assert_eq!(bnb.asset_symbol, "BNB"); + // Mock RPC returns 1e18 wei for eth_getBalance on every network. + assert_eq!(bnb.raw, "1000000000000000000"); + assert!(matches!(bnb.provider_status, ProviderStatus::Ready)); + // The non-EVM chains each still produce exactly one row. + for chain in [WalletChain::Btc, WalletChain::Solana, WalletChain::Tron] { + assert_eq!( + rows.iter().filter(|row| row.chain == chain).count(), + 1, + "expected one row for {chain:?}" + ); + } + + for var in [ + "OPENHUMAN_WALLET_RPC_EVM", + "OPENHUMAN_WALLET_RPC_BASE", + "OPENHUMAN_WALLET_RPC_BSC", + ] { + std::env::remove_var(var); + } +} + #[tokio::test] async fn tx_status_rejects_empty_hash() { let err = tx_status(WalletChain::Evm, None, " ").await.unwrap_err(); diff --git a/src/openhuman/wallet/schemas.rs b/src/openhuman/wallet/schemas.rs index abb48d996..1eec97f4f 100644 --- a/src/openhuman/wallet/schemas.rs +++ b/src/openhuman/wallet/schemas.rs @@ -169,12 +169,12 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { namespace: "wallet", function: "balances", description: - "List native-asset balances for every derived wallet account. EVM balances are read live from the configured/default RPC; other chains remain placeholder-zero until provider support lands.", + "List native-asset balances for every derived wallet account. The EVM account fans out into one row per displayed network (Ethereum, Base, BNB Chain), each read live from the configured/default RPC; BTC/Solana/Tron return one row each.", inputs: vec![], outputs: vec![FieldSchema { name: "result", ty: TypeSchema::Json, - comment: "Array of balance rows: {chain, address, assetSymbol, decimals, raw, formatted, providerStatus}.", + comment: "Array of balance rows: {chain, evmNetwork?, address, assetSymbol, decimals, raw, formatted, providerStatus}.", required: true, }], }, diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 99c05b276..35189b970 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -3996,12 +3996,21 @@ async fn json_rpc_wallet_execution_surface_round_trips() { "expected providerStatus=ready for configured chain rows: {result}" ); - // balances: zero placeholders for each derived account. + // balances: one row per native asset. The EVM account fans out into one + // row per displayed network (Ethereum, Base, BNB Chain), so 3 EVM rows + + // BTC + Solana + Tron = 6. let balances = post_json_rpc(&rpc_base, 2004, "openhuman.wallet_balances", json!({})).await; let body = assert_no_jsonrpc_error(&balances, "wallet_balances"); let result = body.get("result").unwrap_or(&body); let rows = result.as_array().expect("balances array"); - assert_eq!(rows.len(), 4); + assert_eq!(rows.len(), 6); + assert_eq!( + rows.iter() + .filter(|r| r.get("chain").and_then(Value::as_str) == Some("evm")) + .count(), + 3, + "expected 3 EVM network rows: {result}" + ); // Every row reports a raw integer string. Don't require zero — the // BTC/Solana/Tron default REST endpoints may have network access in CI // and return non-placeholder values for the deterministic test addresses.