mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(settings): Crypto + Notifications hubs, wallet send/receive & multi-network balances (#3027)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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': 'تلميحات التوجيه',
|
||||
|
||||
@@ -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': 'রুট হিন্টস',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4378,6 +4378,36 @@ const en: TranslationMap = {
|
||||
'walletBalances.rawBalance': 'Raw: {raw}',
|
||||
'walletBalances.errorGeneric':
|
||||
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
|
||||
'walletBalances.setupHint':
|
||||
'Your recovery phrase isn’t set up yet. Set it up to enable your wallet and see live balances.',
|
||||
'walletBalances.setupCta': 'Set up recovery phrase',
|
||||
'walletBalances.notSetUp': 'Not set up',
|
||||
'walletBalances.send': 'Send',
|
||||
'walletBalances.receive': 'Receive',
|
||||
// Receive modal
|
||||
'walletReceive.scanHint': 'Scan this code or copy the address below to receive funds.',
|
||||
'walletReceive.addressLabel': '{network} address',
|
||||
'walletReceive.onlyChainWarning':
|
||||
'Only send {network} assets to this address. Sending assets from another network may result in permanent loss.',
|
||||
// Send modal
|
||||
'walletSend.available': 'Available',
|
||||
'walletSend.recipient': 'Recipient address',
|
||||
'walletSend.recipientPlaceholder': 'Paste the destination address',
|
||||
'walletSend.recipientRequired': 'Enter a recipient address',
|
||||
'walletSend.amount': 'Amount',
|
||||
'walletSend.invalidAmount': 'Enter a valid amount',
|
||||
'walletSend.review': 'Review',
|
||||
'walletSend.preparing': 'Preparing…',
|
||||
'walletSend.confirmHint':
|
||||
'Review the details below. Signing happens locally — nothing is broadcast until you confirm.',
|
||||
'walletSend.estimatedFee': 'Estimated network fee',
|
||||
'walletSend.confirmSend': 'Confirm & send',
|
||||
'walletSend.sending': 'Sending…',
|
||||
'walletSend.sent': 'Transaction sent',
|
||||
'walletSend.txHash': 'Transaction hash',
|
||||
'walletSend.viewExplorer': 'View on explorer',
|
||||
'walletSend.done': 'Done',
|
||||
'walletSend.genericError': 'Could not complete the transfer. Please try again.',
|
||||
// Task sources (#task-sources)
|
||||
'settings.taskSources.title': 'Task Sources',
|
||||
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
|
||||
@@ -4530,6 +4560,16 @@ const en: TranslationMap = {
|
||||
'settings.agentsSection.description':
|
||||
'Manage your agents, their autonomy, and what they can access on this computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registry, autonomy & OS access',
|
||||
'settings.cryptoSection.title': 'Crypto',
|
||||
'settings.cryptoSection.description':
|
||||
'Manage your recovery phrase and view balances across your wallet accounts.',
|
||||
'settings.cryptoSection.menuDesc': 'Recovery phrase & wallet balances',
|
||||
'settings.notificationsHub.title': 'Notifications',
|
||||
'settings.notificationsHub.description':
|
||||
'View your alerts inbox and manage notification preferences and routing.',
|
||||
'settings.notificationsHub.menuDesc': 'Alerts inbox & notification preferences',
|
||||
'settings.notificationsHub.settingsItem': 'Notification settings',
|
||||
'settings.notificationsHub.settingsItemDesc': 'Preferences & routing',
|
||||
'settings.agents.editor.notFound': 'Agent not found.',
|
||||
'settings.agents.editor.modelInherit': 'Inherit (platform default)',
|
||||
'settings.agents.editor.modelHints': 'Route hints',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4256,6 +4256,35 @@ const messages: TranslationMap = {
|
||||
'walletBalances.rawBalance': 'Brut : {raw}',
|
||||
'walletBalances.errorGeneric':
|
||||
'Impossible de charger les soldes du portefeuille. Configurez votre portefeuille dans Phrase de récupération et réessayez.',
|
||||
'walletBalances.setupHint':
|
||||
'Votre phrase de récupération n’est pas encore configurée. Configurez-la pour activer votre portefeuille et voir les soldes en direct.',
|
||||
'walletBalances.setupCta': 'Configurer la phrase de récupération',
|
||||
'walletBalances.notSetUp': 'Non configuré',
|
||||
'walletBalances.send': 'Envoyer',
|
||||
'walletBalances.receive': 'Recevoir',
|
||||
'walletReceive.scanHint':
|
||||
'Scannez ce code ou copiez l’adresse ci-dessous pour recevoir des fonds.',
|
||||
'walletReceive.addressLabel': 'Adresse {network}',
|
||||
'walletReceive.onlyChainWarning':
|
||||
'N’envoyez que des actifs {network} à cette adresse. Envoyer des actifs d’un autre réseau peut entraîner une perte définitive.',
|
||||
'walletSend.available': 'Disponible',
|
||||
'walletSend.recipient': 'Adresse du destinataire',
|
||||
'walletSend.recipientPlaceholder': 'Collez l’adresse de destination',
|
||||
'walletSend.recipientRequired': 'Saisissez une adresse de destinataire',
|
||||
'walletSend.amount': 'Montant',
|
||||
'walletSend.invalidAmount': 'Saisissez un montant valide',
|
||||
'walletSend.review': 'Vérifier',
|
||||
'walletSend.preparing': 'Préparation…',
|
||||
'walletSend.confirmHint':
|
||||
'Vérifiez les détails ci-dessous. La signature a lieu localement ; rien n’est diffusé tant que vous ne confirmez pas.',
|
||||
'walletSend.estimatedFee': 'Frais de réseau estimés',
|
||||
'walletSend.confirmSend': 'Confirmer et envoyer',
|
||||
'walletSend.sending': 'Envoi…',
|
||||
'walletSend.sent': 'Transaction envoyée',
|
||||
'walletSend.txHash': 'Hash de la transaction',
|
||||
'walletSend.viewExplorer': 'Voir dans l’explorateur',
|
||||
'walletSend.done': 'Terminé',
|
||||
'walletSend.genericError': 'Impossible de finaliser le transfert. Veuillez réessayer.',
|
||||
'settings.taskSources.title': 'Sources de tâches',
|
||||
'settings.taskSources.subtitle':
|
||||
"Tirez les tâches de vos outils sur le tableau des tâches de l'agent",
|
||||
@@ -4401,6 +4430,17 @@ const messages: TranslationMap = {
|
||||
'settings.agentsSection.description':
|
||||
'Gérez vos agents, leur autonomie et ce à quoi ils peuvent accéder sur cet ordinateur.',
|
||||
'settings.agentsSection.menuDesc': 'Registre, autonomie et accès au système',
|
||||
'settings.cryptoSection.title': 'Crypto',
|
||||
'settings.cryptoSection.description':
|
||||
'Gérez votre phrase de récupération et consultez les soldes de vos comptes de portefeuille.',
|
||||
'settings.cryptoSection.menuDesc': 'Phrase de récupération et soldes du portefeuille',
|
||||
'settings.notificationsHub.title': 'Notifications',
|
||||
'settings.notificationsHub.description':
|
||||
'Consultez votre boîte de réception des alertes et gérez les préférences de notification et le routage.',
|
||||
'settings.notificationsHub.menuDesc':
|
||||
'Boîte de réception des alertes et préférences de notification',
|
||||
'settings.notificationsHub.settingsItem': 'Paramètres de notification',
|
||||
'settings.notificationsHub.settingsItemDesc': 'Préférences et routage',
|
||||
'settings.agents.editor.notFound': 'Agent introuvable.',
|
||||
'settings.agents.editor.modelInherit': 'Hériter (défaut de la plateforme)',
|
||||
'settings.agents.editor.modelHints': 'Conseils de routage',
|
||||
|
||||
@@ -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': 'रूट संकेत',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4232,6 +4232,35 @@ const messages: TranslationMap = {
|
||||
'walletBalances.rawBalance': 'Grezzo: {raw}',
|
||||
'walletBalances.errorGeneric':
|
||||
'Impossibile caricare i saldi del portafoglio. Configura il tuo portafoglio in Frase di recupero e riprova.',
|
||||
'walletBalances.setupHint':
|
||||
'La tua frase di recupero non è ancora configurata. Configurala per attivare il tuo wallet e vedere i saldi in tempo reale.',
|
||||
'walletBalances.setupCta': 'Configura la frase di recupero',
|
||||
'walletBalances.notSetUp': 'Non configurato',
|
||||
'walletBalances.send': 'Invia',
|
||||
'walletBalances.receive': 'Ricevi',
|
||||
'walletReceive.scanHint':
|
||||
'Scansiona questo codice o copia l’indirizzo qui sotto per ricevere fondi.',
|
||||
'walletReceive.addressLabel': 'Indirizzo {network}',
|
||||
'walletReceive.onlyChainWarning':
|
||||
'Invia solo asset {network} a questo indirizzo. Inviare asset da un’altra rete può causare una perdita permanente.',
|
||||
'walletSend.available': 'Disponibile',
|
||||
'walletSend.recipient': 'Indirizzo del destinatario',
|
||||
'walletSend.recipientPlaceholder': 'Incolla l’indirizzo di destinazione',
|
||||
'walletSend.recipientRequired': 'Inserisci un indirizzo del destinatario',
|
||||
'walletSend.amount': 'Importo',
|
||||
'walletSend.invalidAmount': 'Inserisci un importo valido',
|
||||
'walletSend.review': 'Rivedi',
|
||||
'walletSend.preparing': 'Preparazione…',
|
||||
'walletSend.confirmHint':
|
||||
'Rivedi i dettagli qui sotto. La firma avviene localmente: nulla viene trasmesso finché non confermi.',
|
||||
'walletSend.estimatedFee': 'Commissione di rete stimata',
|
||||
'walletSend.confirmSend': 'Conferma e invia',
|
||||
'walletSend.sending': 'Invio…',
|
||||
'walletSend.sent': 'Transazione inviata',
|
||||
'walletSend.txHash': 'Hash della transazione',
|
||||
'walletSend.viewExplorer': 'Vedi nell’explorer',
|
||||
'walletSend.done': 'Fatto',
|
||||
'walletSend.genericError': 'Impossibile completare il trasferimento. Riprova.',
|
||||
'settings.taskSources.title': 'Fonti del compito',
|
||||
'settings.taskSources.subtitle':
|
||||
"Estrai le attività dai tuoi strumenti sulla lavagna delle cose da fare dell'agente",
|
||||
@@ -4378,6 +4407,16 @@ const messages: TranslationMap = {
|
||||
'settings.agentsSection.description':
|
||||
'Gestisci i tuoi agenti, la loro autonomia e a cosa possono accedere su questo computer.',
|
||||
'settings.agentsSection.menuDesc': 'Registro, autonomia e accesso al sistema operativo',
|
||||
'settings.cryptoSection.title': 'Cripto',
|
||||
'settings.cryptoSection.description':
|
||||
'Gestisci la tua frase di recupero e visualizza i saldi dei tuoi account wallet.',
|
||||
'settings.cryptoSection.menuDesc': 'Frase di recupero e saldi del wallet',
|
||||
'settings.notificationsHub.title': 'Notifiche',
|
||||
'settings.notificationsHub.description':
|
||||
'Visualizza la tua casella degli avvisi e gestisci le preferenze di notifica e l’instradamento.',
|
||||
'settings.notificationsHub.menuDesc': 'Casella degli avvisi e preferenze di notifica',
|
||||
'settings.notificationsHub.settingsItem': 'Impostazioni di notifica',
|
||||
'settings.notificationsHub.settingsItemDesc': 'Preferenze e instradamento',
|
||||
'settings.agents.editor.notFound': 'Agente non trovato.',
|
||||
'settings.agents.editor.modelInherit': 'Eredita (predefinito della piattaforma)',
|
||||
'settings.agents.editor.modelHints': 'Suggerimenti di instradamento',
|
||||
|
||||
@@ -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': '라우트 힌트',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Подсказки маршрутизации',
|
||||
|
||||
@@ -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': '路由提示',
|
||||
|
||||
@@ -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 />)} />
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user