feat(settings): Crypto + Notifications hubs, wallet send/receive & multi-network balances (#3027)

This commit is contained in:
Steven Enamakel
2026-05-30 09:34:09 -07:00
committed by GitHub
parent e30f9f0c73
commit 29f8806fdb
35 changed files with 2066 additions and 211 deletions
+18 -34
View File
@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
),
onClick: () => navigate('/notifications'),
},
{
id: 'notifications',
title: t('settings.notifications'),
description: t('settings.notificationsDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
),
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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V6m0 10c-1.11 0-2.08-.402-2.599-1M12 16v2m0-12a9 9 0 100 18 9 9 0 000-18z"
/>
</svg>
),
onClick: () => navigateToSettings('crypto'),
},
{
id: 'mascot',
title: t('settings.mascot.menuTitle'),
@@ -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}
@@ -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 () => {
@@ -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(<BreadcrumbProbe />, { initialEntries: ['/settings/notifications-hub'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('notifications panel nests under Settings > Developer Options > Notifications', () => {
renderWithProviders(<BreadcrumbProbe />, { 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(<BreadcrumbProbe />, { initialEntries: ['/settings/persona'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('crypto returns Settings (section page)', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/crypto'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('recovery-phrase returns Settings > Crypto', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/recovery-phrase'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
});
test('wallet-balances returns Settings > Crypto', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/wallet-balances'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
});
});
@@ -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];
@@ -36,6 +36,22 @@ const developerItems = [
</svg>
),
},
{
id: 'notifications-hub',
titleKey: 'settings.notificationsHub.title',
descriptionKey: 'settings.notificationsHub.menuDesc',
route: 'notifications-hub',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
),
},
{
id: 'screen-intelligence',
titleKey: 'settings.developerMenu.screenAwareness.title',
@@ -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<string, string> = {
tron: 'bg-coral-100 text-coral-700 dark:bg-coral-900/30 dark:text-coral-300',
};
const CHAIN_LABEL: Record<string, string> = { 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 (
<div className="flex items-center gap-3 px-4 py-3">
{/* Chain badge */}
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold font-mono min-w-[3rem] justify-center shrink-0 ${badgeClass}`}>
{chainLabel}
</span>
{/* Address + copy button */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono text-xs text-stone-600 dark:text-neutral-400 truncate">
{truncateAddress(balance.address)}
<div className="px-4 py-3">
<div className="flex items-center gap-3">
{/* Network badge */}
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold font-mono min-w-[3rem] justify-center shrink-0 ${badgeClass}`}>
{balanceBadge(balance)}
</span>
<button
type="button"
onClick={() => void handleCopyAddress()}
aria-label={t('walletBalances.copyAddress')}
className="shrink-0 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
{copied ? (
<svg
className="w-3.5 h-3.5 text-sage-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
</div>
{/* Spacer */}
<div className="flex-1" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200 truncate">
{networkLabel}
</span>
{balance.providerStatus !== 'ready' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{t('walletBalances.providerMissing')}
</span>
)}
</div>
{/* Address + copy button */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono text-[11px] text-stone-500 dark:text-neutral-400 truncate">
{truncateAddress(balance.address)}
</span>
<button
type="button"
onClick={() => void handleCopyAddress()}
aria-label={t('walletBalances.copyAddress')}
className="shrink-0 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
{copied ? (
<svg
className="w-3.5 h-3.5 text-sage-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
</div>
</div>
{/* Amount + provider status */}
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
{/* Amount */}
<div className="text-right shrink-0">
<span
title={t('walletBalances.rawBalance').replace('{raw}', balance.raw)}
className="text-sm font-medium text-stone-800 dark:text-neutral-100 font-mono">
@@ -134,11 +170,68 @@ const BalanceRow = ({ balance }: BalanceRowProps) => {
{balance.assetSymbol}
</span>
</div>
{balance.providerStatus !== 'ready' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{t('walletBalances.providerMissing')}
</span>
)}
</div>
{/* Send / Receive actions */}
<div className="flex gap-2 mt-2.5">
<button
type="button"
onClick={() => onSend(balance)}
className="flex-1 py-1.5 text-xs font-medium rounded-lg border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors"
data-testid={`wallet-send-${balanceKey(balance)}`}>
{t('walletBalances.send')}
</button>
<button
type="button"
onClick={() => onReceive(balance)}
className="flex-1 py-1.5 text-xs font-medium rounded-lg border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors"
data-testid={`wallet-receive-${balanceKey(balance)}`}>
{t('walletBalances.receive')}
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// 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 (
<div className="flex items-center gap-3 px-4 py-3 opacity-70">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold font-mono min-w-[3rem] justify-center shrink-0 ${badgeClass}`}>
{balanceBadge({ chain, evmNetwork })}
</span>
<div className="min-w-0">
<span className="block text-xs font-medium text-stone-400 dark:text-neutral-500 truncate">
{balanceNetworkLabel({ chain, evmNetwork })}
</span>
<span className="font-mono text-[11px] text-stone-400 dark:text-neutral-500 truncate">
{t('walletBalances.notSetUp')}
</span>
</div>
<div className="flex-1" />
<div className="text-right shrink-0">
{/* Em dash placeholder — punctuation, not translatable copy. */}
<span className="text-sm font-medium text-stone-400 dark:text-neutral-500 font-mono">
</span>
<span className="ml-1 text-xs text-stone-400 dark:text-neutral-500">{symbol}</span>
</div>
</div>
);
@@ -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<BalanceInfo[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<boolean | null>(null);
// The balance row a Send / Receive modal is currently open for (null = none).
const [sendTarget, setSendTarget] = useState<BalanceInfo | null>(null);
const [receiveTarget, setReceiveTarget] = useState<BalanceInfo | null>(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 (
<div>
<div className="px-4 pt-4 pb-3">
<div
role="status"
className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30">
<svg
className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-xs text-amber-700 dark:text-amber-300 leading-relaxed">
{t('walletBalances.setupHint')}
</p>
<button
type="button"
onClick={() => navigateToSettings('recovery-phrase')}
className="mt-2 text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
{t('walletBalances.setupCta')}
</button>
</div>
</div>
</div>
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{PLACEHOLDER_ROWS.map(row => (
<ChainPlaceholderRow
key={`${row.chain}-${row.evmNetwork ?? 'native'}`}
chain={row.chain}
evmNetwork={row.evmNetwork}
symbol={row.symbol}
/>
))}
</div>
</div>
);
}
if (balances !== null && balances.length === 0) {
return (
<div className="px-4 py-8 text-center">
@@ -271,8 +430,13 @@ const WalletBalancesPanel = () => {
if (balances && balances.length > 0) {
return (
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{balances.map((balance, index) => (
<BalanceRow key={`${balance.chain}-${index}`} balance={balance} />
{balances.map(balance => (
<BalanceRow
key={balanceKey(balance)}
balance={balance}
onSend={setSendTarget}
onReceive={setReceiveTarget}
/>
))}
</div>
);
@@ -315,6 +479,17 @@ const WalletBalancesPanel = () => {
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 mx-4 mb-4 overflow-hidden">
{renderContent()}
</div>
{sendTarget && (
<SendCryptoModal
balance={sendTarget}
onClose={() => setSendTarget(null)}
onSuccess={() => void loadBalances()}
/>
)}
{receiveTarget && (
<ReceiveModal balance={receiveTarget} onClose={() => setReceiveTarget(null)} />
)}
</div>
);
};
@@ -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<BalanceInfo[]>>();
const mockFetchWalletStatus = vi.fn<() => Promise<WalletStatus>>();
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 }) => <div data-testid="qr-code" data-value={value} />,
}));
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);
@@ -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<ReturnType<typeof setTimeout> | 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 (
<ModalShell
onClose={onClose}
titleId="wallet-receive-title"
title={t('walletBalances.receive')}
subtitle={networkLabel}>
<div className="flex flex-col items-center gap-4">
<p className="text-xs text-stone-500 dark:text-neutral-400 text-center leading-relaxed">
{t('walletReceive.scanHint')}
</p>
<div className="rounded-xl bg-white p-3 border border-stone-200" data-testid="receive-qr">
<QRCodeSVG
value={balance.address}
size={180}
level="M"
bgColor="#ffffff"
fgColor="#1c1917"
/>
</div>
<div className="w-full">
<span className="block text-[11px] font-medium text-stone-500 dark:text-neutral-400 mb-1">
{t('walletReceive.addressLabel').replace('{network}', networkLabel)}
</span>
<div className="flex items-center gap-2 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span
className="font-mono text-xs text-stone-700 dark:text-neutral-200 break-all"
data-testid="receive-address">
{balance.address}
</span>
<button
type="button"
onClick={() => void handleCopy()}
className="shrink-0 text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors">
{copied ? t('common.copied') : t('walletBalances.copyAddress')}
</button>
</div>
</div>
<div className="w-full rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 p-3">
<p className="text-[11px] text-amber-700 dark:text-amber-300 leading-relaxed">
{t('walletReceive.onlyChainWarning').replace('{network}', networkLabel)}
</p>
</div>
</div>
</ModalShell>
);
};
export default ReceiveModal;
@@ -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<Step>('form');
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [prepared, setPrepared] = useState<PreparedTransaction | null>(null);
const [result, setResult] = useState<ExecutionResult | null>(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 (
<ModalShell
onClose={onClose}
titleId="wallet-send-title"
title={t('walletBalances.send')}
subtitle={`${networkLabel} · ${balance.assetSymbol}`}>
{error && (
<div
role="alert"
className="mb-3 rounded-lg bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
{error}
</div>
)}
{step === 'form' && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between rounded-lg bg-stone-50 dark:bg-neutral-800/60 px-3 py-2 text-xs">
<span className="text-stone-500 dark:text-neutral-400">
{t('walletSend.available')}
</span>
<span className="font-mono font-medium text-stone-800 dark:text-neutral-100">
{balance.formatted} {balance.assetSymbol}
</span>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('walletSend.recipient')}
</span>
<input
type="text"
value={recipient}
onChange={e => setRecipient(e.target.value)}
placeholder={t('walletSend.recipientPlaceholder')}
spellCheck={false}
autoComplete="off"
className={`${fieldClass} font-mono`}
data-testid="send-recipient"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('walletSend.amount')}
</span>
<div className="relative">
<input
type="text"
inputMode="decimal"
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="0.0"
className={`${fieldClass} pr-16 font-mono`}
data-testid="send-amount"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-stone-400 dark:text-neutral-500">
{balance.assetSymbol}
</span>
</div>
</label>
<button
type="button"
onClick={() => void handleReview()}
disabled={busy}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60"
data-testid="send-review">
{busy ? t('walletSend.preparing') : t('walletSend.review')}
</button>
</div>
)}
{step === 'review' && prepared && (
<div className="flex flex-col gap-3">
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('walletSend.confirmHint')}
</p>
<dl className="rounded-xl border border-stone-200 dark:border-neutral-800 divide-y divide-stone-100 dark:divide-neutral-800 text-xs">
<div className="flex items-center justify-between px-3 py-2">
<dt className="text-stone-500 dark:text-neutral-400">{t('walletSend.amount')}</dt>
<dd className="font-mono font-medium text-stone-800 dark:text-neutral-100">
{prepared.amountFormatted} {prepared.assetSymbol}
</dd>
</div>
<div className="flex items-center justify-between px-3 py-2">
<dt className="text-stone-500 dark:text-neutral-400">{t('walletSend.recipient')}</dt>
<dd className="font-mono text-stone-800 dark:text-neutral-100">
{truncate(prepared.toAddress)}
</dd>
</div>
<div className="flex items-center justify-between px-3 py-2">
<dt className="text-stone-500 dark:text-neutral-400">
{t('walletSend.estimatedFee')}
</dt>
<dd className="font-mono text-stone-800 dark:text-neutral-100" data-testid="send-fee">
{feeFormatted} {balance.assetSymbol}
</dd>
</div>
</dl>
{prepared.notes.length > 0 && (
<ul className="list-disc pl-4 text-[11px] text-stone-500 dark:text-neutral-400 space-y-0.5">
{prepared.notes.map((note, i) => (
<li key={i}>{note}</li>
))}
</ul>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setStep('form');
setPrepared(null);
}}
disabled={busy}
className="flex-1 py-2.5 text-sm font-medium rounded-xl border border-stone-300 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('common.back')}
</button>
<button
type="button"
onClick={() => void handleConfirm()}
disabled={busy}
className="btn-primary flex-1 py-2.5 text-sm font-medium rounded-xl disabled:opacity-60"
data-testid="send-confirm">
{t('walletSend.confirmSend')}
</button>
</div>
</div>
)}
{step === 'sending' && (
<div className="flex flex-col items-center gap-3 py-8 text-stone-500 dark:text-neutral-400">
<svg className="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-sm">{t('walletSend.sending')}</span>
</div>
)}
{step === 'done' && result && (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<div className="w-12 h-12 rounded-full bg-sage-100 dark:bg-sage-500/15 flex items-center justify-center">
<svg
className="w-6 h-6 text-sage-600 dark:text-sage-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-stone-800 dark:text-neutral-100">
{t('walletSend.sent')}
</p>
<div className="w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<span className="block text-[11px] text-stone-500 dark:text-neutral-400 mb-0.5">
{t('walletSend.txHash')}
</span>
<span
className="font-mono text-xs text-stone-700 dark:text-neutral-200 break-all"
data-testid="send-tx-hash">
{result.transactionHash}
</span>
</div>
{result.explorerUrl && (
<a
href={result.explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300">
{t('walletSend.viewExplorer')}
</a>
)}
<button
type="button"
onClick={handleDone}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl mt-1">
{t('walletSend.done')}
</button>
</div>
)}
</ModalShell>
);
};
export default SendCryptoModal;
@@ -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<PreparedTransaction>>();
const mockExecutePrepared = vi.fn<() => Promise<ExecutionResult>>();
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(
<SendCryptoModal balance={EVM_BALANCE} onClose={onClose} onSuccess={onSuccess} />
);
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));
});
});
@@ -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');
});
});
+93
View File
@@ -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<EvmNetwork, string> = {
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<EvmNetwork, string> = {
ethereum_mainnet: 'ETH',
base_mainnet: 'BASE',
arbitrum_one: 'ARB',
optimism_mainnet: 'OP',
polygon_mainnet: 'POL',
bsc_mainnet: 'BSC',
};
const CHAIN_LABEL: Record<WalletChain, string> = {
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<BalanceInfo, 'chain' | 'evmNetwork'>): 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<BalanceInfo, 'chain' | 'evmNetwork'>): 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<BalanceInfo, 'chain' | 'evmNetwork' | 'assetSymbol'>
): 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;
}
+37
View File
@@ -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': 'تلميحات التوجيه',
+38
View File
@@ -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': 'রুট হিন্টস',
+40
View File
@@ -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',
+40
View File
@@ -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 isnt 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',
+39
View File
@@ -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',
+40
View File
@@ -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 nest 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 ladresse ci-dessous pour recevoir des fonds.',
'walletReceive.addressLabel': 'Adresse {network}',
'walletReceive.onlyChainWarning':
'Nenvoyez que des actifs {network} à cette adresse. Envoyer des actifs dun autre réseau peut entraîner une perte définitive.',
'walletSend.available': 'Disponible',
'walletSend.recipient': 'Adresse du destinataire',
'walletSend.recipientPlaceholder': 'Collez ladresse 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 nest 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 lexplorateur',
'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',
+39
View File
@@ -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': 'रूट संकेत',
+38
View File
@@ -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',
+39
View File
@@ -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 lindirizzo qui sotto per ricevere fondi.',
'walletReceive.addressLabel': 'Indirizzo {network}',
'walletReceive.onlyChainWarning':
'Invia solo asset {network} a questo indirizzo. Inviare asset da unaltra rete può causare una perdita permanente.',
'walletSend.available': 'Disponibile',
'walletSend.recipient': 'Indirizzo del destinatario',
'walletSend.recipientPlaceholder': 'Incolla lindirizzo 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 nellexplorer',
'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 linstradamento.',
'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',
+37
View File
@@ -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': '라우트 힌트',
+38
View File
@@ -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',
+38
View File
@@ -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',
+39
View File
@@ -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': 'Подсказки маршрутизации',
+34
View File
@@ -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': '路由提示',
+63 -8
View File
@@ -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 = (
/>
</svg>
);
const NotificationSettingsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
);
const LlmIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -229,6 +239,7 @@ const WrappedSettingsPage = ({
const Settings = () => {
const { t } = useT();
const navigate = useNavigate();
const wrapSettingsPage = (element: ReactNode, opts?: { maxWidthClass?: string }) => (
<WrappedSettingsPage maxWidthClass={opts?.maxWidthClass}>
@@ -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 = () => {
/>
)}
/>
<Route
path="crypto"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('settings.cryptoSection.title')}
description={t('settings.cryptoSection.description')}
items={cryptoSettingsItems}
/>
)}
/>
<Route
path="notifications-hub"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('settings.notificationsHub.title')}
description={t('settings.notificationsHub.description')}
items={notificationsHubItems}
/>
)}
/>
{/* Account & Billing leaf panels */}
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
+91 -3
View File
@@ -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<Walle
*
* Calls `wallet.balances` via the core RPC relay. The contract:
* - When the wallet IS configured, the core returns one row per derived
* account (EVM/BTC/Solana/Tron) and this resolves to that array.
* account. The EVM account fans out into one row per displayed network
* (Ethereum, Base, BNB Chain); BTC/Solana/Tron return a single row each.
* - When the wallet IS NOT configured (no recovery phrase set up yet), the
* core returns an RPC error; this promise rejects so callers can surface
* the empty / setup-required state rather than silently rendering nothing.
@@ -79,3 +80,90 @@ export const fetchWalletBalances = async (): Promise<BalanceInfo[]> => {
});
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<PreparedTransaction> => {
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<ExecutionResult> => {
const response = await callCoreRpc<{ result: ExecutionResult }>({
method: 'openhuman.wallet_execute_prepared',
params: { quoteId, confirmed: true },
});
return response.result;
};
@@ -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 ({
+2 -2
View File
@@ -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,
},
+146 -66
View File
@@ -580,81 +580,161 @@ pub async fn chain_status() -> Result<RpcOutcome<Vec<ChainStatus>>, 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<EvmNetwork>,
address: &str,
asset: WalletAssetDefinition,
raw: String,
provider_status: ProviderStatus,
) -> BalanceInfo {
let raw_u128 = raw.parse::<u128>().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<WalletAssetDefinition, String> {
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<WalletAssetDefinition, String> {
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<RpcOutcome<Vec<BalanceInfo>>, 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::<u128>().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(
+57
View File
@@ -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<EvmNetwork> = 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();
+2 -2
View File
@@ -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,
}],
},
+11 -2
View File
@@ -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.