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