feat(wallet): surface multi-chain balances in Settings (#2686)

Co-authored-by: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: sanil-23 <sanil@vezures.xyz>
Co-authored-by: sanil-23 <sanil@alphahuman.xyz>
This commit is contained in:
obchain
2026-05-29 20:29:26 -07:00
committed by GitHub
co-authored by Srinivas Vaddi Steven Enamakel sanil-23 sanil-23
parent 4c911240df
commit e6bd1bd7b8
24 changed files with 845 additions and 9 deletions
@@ -24,6 +24,7 @@ export type SettingsRoute =
| 'memory-data'
| 'memory-debug'
| 'recovery-phrase'
| 'wallet-balances'
| 'webhooks-debug'
| 'agent-chat'
| 'screen-awareness-debug'
@@ -110,6 +111,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/composio-routing')) return 'composio-routing';
if (path.includes('/settings/intelligence')) return 'intelligence';
if (path.includes('/settings/recovery-phrase')) return 'recovery-phrase';
if (path.includes('/settings/wallet-balances')) return 'wallet-balances';
if (path.includes('/settings/agent-chat')) return 'agent-chat';
// Notification routes must be checked in specificity order so the more
// specific `notification-routing` path doesn't get swallowed by the
@@ -189,6 +191,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
// Leaf panels under account
case 'recovery-phrase':
case 'wallet-balances':
case 'team':
case 'privacy':
return [settingsCrumb, accountCrumb];
@@ -0,0 +1,322 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { type BalanceInfo, fetchWalletBalances } from '../../../services/walletApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// ---------------------------------------------------------------------------
// Chain badge colours — each chain gets a distinct palette token combination
// that maps to the project's sage / amber / coral / ocean (primary) design
// language. Tailwind class strings are kept literal so the build can detect
// them via static analysis.
// ---------------------------------------------------------------------------
const CHAIN_BADGE_CLASS: Record<string, string> = {
evm: 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300',
btc: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
solana: 'bg-sage-100 text-sage-700 dark:bg-sage-900/30 dark:text-sage-300',
tron: 'bg-coral-100 text-coral-700 dark:bg-coral-900/30 dark:text-coral-300',
};
const CHAIN_LABEL: Record<string, string> = { evm: 'EVM', btc: 'BTC', solana: 'SOL', tron: 'TRX' };
/** Shorten an address to first 6 + last 4 characters: `0x1234…abcd`. */
function truncateAddress(address: string): string {
if (address.length <= 12) return address;
return `${address.slice(0, 6)}${address.slice(-4)}`;
}
// ---------------------------------------------------------------------------
// BalanceRow — a single chain entry
// ---------------------------------------------------------------------------
interface BalanceRowProps {
balance: BalanceInfo;
}
const BalanceRow = ({ balance }: BalanceRowProps) => {
const { t } = useT();
const [copied, setCopied] = useState(false);
// Tracks the most recent "Copied" timer so rapid re-clicks reset the 2s
// window rather than stacking independent setTimeouts (the older one would
// otherwise flip `copied` back to false while the newest click still wants
// to show the checkmark).
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyResetTimerRef.current !== null) {
clearTimeout(copyResetTimerRef.current);
copyResetTimerRef.current = null;
}
},
[]
);
const handleCopyAddress = useCallback(async () => {
try {
await navigator.clipboard.writeText(balance.address);
setCopied(true);
if (copyResetTimerRef.current !== null) {
clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = setTimeout(() => {
setCopied(false);
copyResetTimerRef.current = null;
}, 2000);
} catch {
// Clipboard unavailable (no permissions); silently skip.
}
}, [balance.address]);
const badgeClass =
CHAIN_BADGE_CLASS[balance.chain] ??
'bg-stone-100 text-stone-700 dark:bg-neutral-800 dark:text-neutral-300';
const chainLabel = CHAIN_LABEL[balance.chain] ?? balance.chain.toUpperCase();
return (
<div className="flex items-center gap-3 px-4 py-3">
{/* Chain badge */}
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold font-mono min-w-[3rem] justify-center shrink-0 ${badgeClass}`}>
{chainLabel}
</span>
{/* Address + copy button */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono text-xs text-stone-600 dark:text-neutral-400 truncate">
{truncateAddress(balance.address)}
</span>
<button
type="button"
onClick={() => void handleCopyAddress()}
aria-label={t('walletBalances.copyAddress')}
className="shrink-0 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
{copied ? (
<svg
className="w-3.5 h-3.5 text-sage-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Amount + provider status */}
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<span
title={t('walletBalances.rawBalance').replace('{raw}', balance.raw)}
className="text-sm font-medium text-stone-800 dark:text-neutral-100 font-mono">
{balance.formatted}
</span>
<span className="ml-1 text-xs text-stone-500 dark:text-neutral-400">
{balance.assetSymbol}
</span>
</div>
{balance.providerStatus !== 'ready' && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{t('walletBalances.providerMissing')}
</span>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// WalletBalancesPanel — main panel
// ---------------------------------------------------------------------------
const WalletBalancesPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [balances, setBalances] = useState<BalanceInfo[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Request-sequencing guard: a slower earlier request must not overwrite a
// newer one. `loadBalances` can fire concurrently (mount + Refresh + Retry),
// so we tag each call with a monotonic id and drop any response whose id no
// longer matches the latest dispatched call.
const latestRequestIdRef = useRef(0);
const loadBalances = useCallback(async () => {
const requestId = ++latestRequestIdRef.current;
setLoading(true);
setError(null);
try {
const rows = await fetchWalletBalances();
if (requestId !== latestRequestIdRef.current) return;
setBalances(rows);
} catch (err) {
if (requestId !== latestRequestIdRef.current) return;
const message = err instanceof Error ? err.message : String(err);
// Log the raw backend phrasing for diagnostics; the UI surfaces a
// translated, user-facing copy via `walletBalances.errorGeneric`.
console.debug('[walletBalances] fetch failed:', message);
setError(message);
} finally {
if (requestId === latestRequestIdRef.current) {
setLoading(false);
}
}
}, []);
useEffect(() => {
void loadBalances();
}, [loadBalances]);
const renderContent = () => {
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-stone-500 dark:text-neutral-400">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-sm">{t('walletBalances.loading')}</span>
</div>
);
}
if (error) {
return (
<div className="px-4 py-4">
<div
role="alert"
className="flex items-start gap-2.5 p-3 mb-4 rounded-xl bg-coral-50 dark:bg-coral-500/10 border border-coral-200 dark:border-coral-500/30">
<svg
className="w-4 h-4 text-coral-500 flex-shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<p className="text-xs text-coral-700 dark:text-coral-300 leading-relaxed">
{t('walletBalances.errorGeneric')}
</p>
</div>
<button
type="button"
onClick={() => void loadBalances()}
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl">
{t('walletBalances.retry')}
</button>
</div>
);
}
if (balances !== null && balances.length === 0) {
return (
<div className="px-4 py-8 text-center">
<div className="w-12 h-12 rounded-full bg-stone-100 dark:bg-neutral-800 flex items-center justify-center mx-auto mb-3">
<svg
className="w-6 h-6 text-stone-400 dark:text-neutral-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18-3a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6m18 0V5.25A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25V6"
/>
</svg>
</div>
<p className="text-sm text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('walletBalances.emptyState')}
</p>
</div>
);
}
if (balances && balances.length > 0) {
return (
<div className="divide-y divide-stone-100 dark:divide-neutral-800">
{balances.map((balance, index) => (
<BalanceRow key={`${balance.chain}-${index}`} balance={balance} />
))}
</div>
);
}
return null;
};
return (
<div>
<div className="flex items-center justify-between pr-4">
<SettingsHeader
title={t('walletBalances.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<button
type="button"
onClick={() => void loadBalances()}
disabled={loading}
aria-label={t('walletBalances.refresh')}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 disabled:opacity-50 transition-colors">
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{t('walletBalances.refresh')}
</button>
</div>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-stone-200 dark:border-neutral-800 mx-4 mb-4 overflow-hidden">
{renderContent()}
</div>
</div>
);
};
export default WalletBalancesPanel;
@@ -0,0 +1,224 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BalanceInfo } from '../../../../services/walletApi';
import { renderWithProviders } from '../../../../test/test-utils';
import WalletBalancesPanel from '../WalletBalancesPanel';
// ---------------------------------------------------------------------------
// Module-level mock: replace fetchWalletBalances before the panel loads.
// ---------------------------------------------------------------------------
const mockFetchWalletBalances = vi.fn<() => Promise<BalanceInfo[]>>();
vi.mock('../../../../services/walletApi', () => ({
fetchWalletBalances: (...args: unknown[]) => mockFetchWalletBalances(...(args as [])),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const EVM_BALANCE: BalanceInfo = {
chain: 'evm',
evmNetwork: 'ethereum_mainnet',
address: '0x9858EfFD232B4033E47d90003D41EC34EcaEda94',
assetSymbol: 'ETH',
decimals: 18,
raw: '1000000000000000000',
formatted: '1.000000000000000000',
providerStatus: 'ready',
};
const BTC_BALANCE: BalanceInfo = {
chain: 'btc',
address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu',
assetSymbol: 'BTC',
decimals: 8,
raw: '100000000',
formatted: '1.00000000',
providerStatus: 'ready',
};
const MISSING_PROVIDER_BALANCE: BalanceInfo = {
chain: 'solana',
address: 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk',
assetSymbol: 'SOL',
decimals: 9,
raw: '0',
formatted: '0.000000000',
providerStatus: 'missing',
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderPanel() {
const { container } = renderWithProviders(<WalletBalancesPanel />);
return container;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('WalletBalancesPanel — loading state', () => {
it('shows a loading spinner while the fetch is in progress', async () => {
let resolve!: (value: BalanceInfo[]) => void;
mockFetchWalletBalances.mockReturnValueOnce(
new Promise<BalanceInfo[]>(res => {
resolve = res;
})
);
renderPanel();
expect(screen.getByText(/loading balances/i)).toBeInTheDocument();
// Resolve so React can clean up.
resolve([]);
await waitFor(() => expect(screen.queryByText(/loading balances/i)).not.toBeInTheDocument());
});
});
describe('WalletBalancesPanel — error state', () => {
beforeEach(() => {
mockFetchWalletBalances.mockReset();
});
it('renders a translated, user-facing error message when the fetch rejects', async () => {
mockFetchWalletBalances.mockRejectedValueOnce(
new Error('wallet is not configured; run wallet setup first')
);
renderPanel();
// UI must not leak raw backend phrasing — it should render the
// translated `walletBalances.errorGeneric` copy instead.
await waitFor(() => {
expect(screen.getByText(/Unable to load wallet balances/i)).toBeInTheDocument();
expect(
screen.queryByText(/wallet is not configured; run wallet setup first/i)
).not.toBeInTheDocument();
});
});
it('re-invokes fetchWalletBalances when the Retry button is clicked', async () => {
mockFetchWalletBalances
.mockRejectedValueOnce(new Error('network error'))
.mockResolvedValueOnce([]);
renderPanel();
await waitFor(() => expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await waitFor(() => expect(mockFetchWalletBalances).toHaveBeenCalledTimes(2));
// After the second call (empty) the error clears and empty state appears.
await waitFor(() =>
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument()
);
});
});
describe('WalletBalancesPanel — empty state', () => {
beforeEach(() => {
mockFetchWalletBalances.mockReset();
});
it('renders the Recovery Phrase hint when no balances are returned', async () => {
mockFetchWalletBalances.mockResolvedValueOnce([]);
renderPanel();
await waitFor(() => {
expect(screen.getByText(/No wallet accounts yet/i)).toBeInTheDocument();
expect(screen.getByText(/Recovery Phrase/i)).toBeInTheDocument();
});
});
});
describe('WalletBalancesPanel — loaded state', () => {
beforeEach(() => {
mockFetchWalletBalances.mockReset();
});
it('renders chain badge, formatted amount, and symbol for each row', async () => {
mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE, BTC_BALANCE]);
renderPanel();
await waitFor(() => {
// Chain badge — appears once (EVM has no asset symbol collision with chain label)
expect(screen.getByText('EVM')).toBeInTheDocument();
// Formatted balances (unique per row)
expect(screen.getByText('1.000000000000000000')).toBeInTheDocument();
expect(screen.getByText('1.00000000')).toBeInTheDocument();
// Symbols — ETH appears only as the asset symbol; BTC appears twice
// (chain badge + asset symbol) so we assert via getAllByText length.
expect(screen.getByText('ETH')).toBeInTheDocument();
expect(screen.getAllByText('BTC').length).toBeGreaterThanOrEqual(2);
});
});
it('truncates addresses to first 6 + last 4 chars', async () => {
mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]);
renderPanel();
// address: 0x9858EfFD232B4033E47d90003D41EC34EcaEda94
// truncated: 0x9858…da94 (first 6 + last 4 chars, original case preserved)
await waitFor(() => {
expect(screen.getByText('0x9858…da94')).toBeInTheDocument();
});
});
it('shows the "provider unavailable" chip for balances with missing provider status', async () => {
mockFetchWalletBalances.mockResolvedValueOnce([MISSING_PROVIDER_BALANCE]);
renderPanel();
await waitFor(() => {
expect(screen.getByText(/provider unavailable/i)).toBeInTheDocument();
});
});
it('does NOT show the provider chip for balances with ready status', async () => {
mockFetchWalletBalances.mockResolvedValueOnce([EVM_BALANCE]);
renderPanel();
await waitFor(() => {
expect(screen.queryByText(/provider unavailable/i)).not.toBeInTheDocument();
});
});
});
describe('WalletBalancesPanel — refresh', () => {
beforeEach(() => {
mockFetchWalletBalances.mockReset();
});
it('re-invokes fetchWalletBalances when Refresh is clicked', async () => {
mockFetchWalletBalances
.mockResolvedValueOnce([EVM_BALANCE])
.mockResolvedValueOnce([EVM_BALANCE, BTC_BALANCE]);
renderPanel();
await waitFor(() => expect(screen.getByText('EVM')).toBeInTheDocument());
const refreshButton = screen.getByRole('button', { name: /refresh/i });
fireEvent.click(refreshButton);
await waitFor(() => expect(mockFetchWalletBalances).toHaveBeenCalledTimes(2));
// After refresh, the BTC row is added — BTC appears twice (chain badge + symbol).
await waitFor(() => expect(screen.getAllByText('BTC').length).toBeGreaterThanOrEqual(2));
});
});
+12
View File
@@ -445,6 +445,18 @@ const ar4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'التوجيه والمشغلات وسجل عمليات التكامل المدعومة بواسطة Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -448,6 +448,18 @@ const bn4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Composio দ্বারা চালিত ইন্টিগ্রেশনের জন্য রাউটিং, ট্রিগার এবং ইতিহাস।',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -454,6 +454,18 @@ const de4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Routing, Trigger und Verlauf für Integrationen, die von Composio unterstützt werden.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+13
View File
@@ -247,9 +247,22 @@ const en4: TranslationMap = {
'pages.settings.account.migration': 'Import from another assistant',
'pages.settings.account.migrationDesc':
'Migrate memory and notes from OpenClaw (or, soon, Hermes) into this workspace.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'pages.settings.accountSection.description':
'Recovery phrase, team, connections, and privacy settings.',
'pages.settings.accountSection.title': 'Account',
// WalletBalancesPanel strings
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'pages.settings.ai.llm': 'Llm',
'pages.settings.ai.llmDesc': 'Llm desc',
'pages.settings.ai.voice': 'Voice',
+12
View File
@@ -452,6 +452,18 @@ const es4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Enrutamiento, activadores e historial para integraciones impulsadas por Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -451,6 +451,18 @@ const fr4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Routage, déclencheurs et historique pour les intégrations optimisées par Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -449,6 +449,18 @@ const hi4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Composio द्वारा संचालित एकीकरण के लिए रूटिंग, ट्रिगर और इतिहास।',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -450,6 +450,18 @@ const id4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Perutean, pemicu, dan riwayat untuk integrasi yang didukung oleh Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -453,6 +453,18 @@ const it4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Routing, trigger e cronologia per le integrazioni fornite da Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -451,6 +451,18 @@ const ko4: TranslationMap = {
'settings.ai.openAiCompat.rotateKey': '키 회전',
'settings.ai.openAiCompat.setKey': '키 설정',
'settings.ai.openAiCompat.title': 'OpenAI 호환 엔드포인트',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -451,6 +451,18 @@ const pt4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Roteamento, gatilhos e histórico para integrações desenvolvidas por Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -448,6 +448,18 @@ const ru4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'Маршрутизация, триггеры и история интеграций на базе Composio.',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+12
View File
@@ -439,6 +439,18 @@ const zhCN4: TranslationMap = {
'pages.settings.composioSection.title': 'Composio',
'pages.settings.composioSection.description':
'由 Composio 提供支持的集成的路由、触发器和历史记录。',
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
'conversations.taskKanban.approval.default': 'Default',
'conversations.taskKanban.approval.notRequired': 'Not required',
'conversations.taskKanban.approval.notRequiredBadge': 'no approval',
+14
View File
@@ -4159,6 +4159,20 @@ const en: TranslationMap = {
'settings.appearanceDesc': 'Pick light, dark, or match your system theme',
'settings.mascot': 'Mascot',
'settings.mascotDesc': 'Pick the mascot color used across the app',
// Settings > Account > Wallet Balances
'pages.settings.account.walletBalances': 'Wallet Balances',
'pages.settings.account.walletBalancesDesc': 'View multi-chain balances for your local wallet',
// WalletBalancesPanel strings
'walletBalances.title': 'Wallet Balances',
'walletBalances.refresh': 'Refresh',
'walletBalances.loading': 'Loading balances…',
'walletBalances.retry': 'Retry',
'walletBalances.emptyState': 'No wallet accounts yet — set up a wallet in Recovery Phrase.',
'walletBalances.copyAddress': 'Copy address',
'walletBalances.providerMissing': 'provider unavailable',
'walletBalances.rawBalance': 'Raw: {raw}',
'walletBalances.errorGeneric':
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
// Task sources (#task-sources)
'settings.taskSources.title': 'Task Sources',
'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board',
+20
View File
@@ -48,6 +48,7 @@ import ToolPolicyDiagnosticsPanel from '../components/settings/panels/ToolPolicy
import ToolsPanel from '../components/settings/panels/ToolsPanel';
import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel';
import VoicePanel from '../components/settings/panels/VoicePanel';
import WalletBalancesPanel from '../components/settings/panels/WalletBalancesPanel';
import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel';
import SettingsHome from '../components/settings/SettingsHome';
import SettingsSectionPage from '../components/settings/SettingsSectionPage';
@@ -174,6 +175,17 @@ const VoiceIcon = (
</svg>
);
const WalletIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
);
const WrappedSettingsPage = ({
children,
maxWidthClass = 'max-w-lg',
@@ -232,6 +244,13 @@ const Settings = () => {
route: 'migration',
icon: MigrationIcon,
},
{
id: 'wallet-balances',
title: t('pages.settings.account.walletBalances'),
description: t('pages.settings.account.walletBalancesDesc'),
route: 'wallet-balances',
icon: WalletIcon,
},
];
const featuresSettingsItems = [
@@ -426,6 +445,7 @@ const Settings = () => {
<Route path="billing" element={<BillingPanel />} />
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
<Route path="wallet-balances" element={wrapSettingsPage(<WalletBalancesPanel />)} />
{/* Features leaf panels */}
<Route path="screen-intelligence" element={wrapSettingsPage(<ScreenIntelligencePanel />)} />
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
+45
View File
@@ -50,4 +50,49 @@ describe('walletApi', () => {
params: payload,
});
});
// fetchWalletBalances tests
it('fetchWalletBalances calls wallet.balances via openhuman.wallet_balances and returns the array', async () => {
const rows = [
{
chain: 'evm',
evmNetwork: 'ethereum_mainnet',
address: '0xABCD',
assetSymbol: 'ETH',
decimals: 18,
raw: '1000000000000000000',
formatted: '1.000000000000000000',
providerStatus: 'ready',
},
];
mockCallCoreRpc.mockResolvedValueOnce({ result: rows });
const { fetchWalletBalances } = await import('./walletApi');
const result = await fetchWalletBalances();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.wallet_balances' });
expect(result).toHaveLength(1);
expect(result[0].assetSymbol).toBe('ETH');
expect(result[0].providerStatus).toBe('ready');
});
it('fetchWalletBalances propagates RPC errors to the caller', async () => {
mockCallCoreRpc.mockRejectedValueOnce(
new Error('wallet is not configured; run wallet setup first')
);
const { fetchWalletBalances } = await import('./walletApi');
await expect(fetchWalletBalances()).rejects.toThrow(
'wallet is not configured; run wallet setup first'
);
});
it('fetchWalletBalances maps an empty result array to an empty array', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [] });
const { fetchWalletBalances } = await import('./walletApi');
const result = await fetchWalletBalances();
expect(result).toEqual([]);
});
});
+37
View File
@@ -3,6 +3,26 @@ import { callCoreRpc } from './coreRpcClient';
export type WalletChain = 'evm' | 'btc' | 'solana' | 'tron';
export type WalletSetupSource = 'generated' | 'imported';
/**
* A single balance row returned by wallet.balances.
* Field names match the camelCase serde output of BalanceInfo in
* src/openhuman/wallet/execution.rs.
*/
export interface BalanceInfo {
chain: WalletChain;
/** Present only when chain === 'evm'. */
evmNetwork?: string;
address: string;
assetSymbol: string;
decimals: number;
/** Raw balance in the chain's smallest unit (wei / sat / lamport / sun). */
raw: string;
/** Human-readable formatted balance (e.g. "1.234"). */
formatted: string;
/** "ready" when the RPC provider responded; "missing" when it fell back to zero. */
providerStatus: 'ready' | 'missing';
}
export interface WalletAccount {
chain: WalletChain;
address: string;
@@ -42,3 +62,20 @@ export const setupLocalWallet = async (params: SetupWalletParams): Promise<Walle
});
return response.result;
};
/**
* Fetch native-asset balances for every derived wallet account.
*
* Calls `wallet.balances` via the core RPC relay. The contract:
* - When the wallet IS configured, the core returns one row per derived
* account (EVM/BTC/Solana/Tron) and this resolves to that array.
* - When the wallet IS NOT configured (no recovery phrase set up yet), the
* core returns an RPC error; this promise rejects so callers can surface
* the empty / setup-required state rather than silently rendering nothing.
*/
export const fetchWalletBalances = async (): Promise<BalanceInfo[]> => {
const response = await callCoreRpc<{ result: BalanceInfo[] }>({
method: 'openhuman.wallet_balances',
});
return response.result;
};
+3 -2
View File
@@ -468,6 +468,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| 13.1.1 | Profile Management | VU | `app/src/components/settings/panels/__tests__/PrivacyPanel.test.tsx` | 🟡 | |
| 13.1.2 | Linked Accounts | WD | `auth-access-control.spec.ts` | 🟡 | UI surface unasserted |
| 13.1.3 | Meet Handoff Prompt-Injection Guard | VU | `app/src/services/__tests__/webviewAccountService.meetPromptInjection.test.ts` (this PR) | ✅ | Was ❌ — guard blocks handoff on hostile transcripts and wraps non-blocked transcripts in `<meeting_transcript source="untrusted_external_audio">` delimiters (#1920) |
| 13.1.4 | Wallet Balances Panel | VU | `app/src/components/settings/panels/__tests__/WalletBalancesPanel.test.tsx`, `app/src/services/walletApi.test.ts` | ✅ | Loading/error/empty/loaded states; Retry + Refresh re-invocation; chain badges; truncated address; providerStatus chip |
### 13.2 Automation & Channels
@@ -506,11 +507,11 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil
| Status | Count |
| ---------------- | ------------------------------------------------ |
| ✅ Covered | 69 |
| ✅ Covered | 70 |
| 🟡 Partial | 27 |
| ❌ Missing | 26 |
| 🚫 Manual smoke | 11 |
| **Total leaves** | **134 explicit + nested = 205 product features** |
| **Total leaves** | **135 explicit + nested = 206 product features** |
PR-A delta: 13 leaves moved from ❌ → ✅ via 5 WDIO specs + 2 Vitest + 1 Rust integration test.
Remaining gaps tracked under sub-issues #965 (process), #966 (docs), #967 (tools), #968 (auth/perm), #969 (settings), #970 (rewards), #971 (manual smoke).
+16 -6
View File
@@ -510,6 +510,13 @@ fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool {
}
fn is_embedding_backend_auth_failure(lower: &str) -> bool {
// Skip the OpenHuman-backend envelope shape `{"success":false,"error":"invalid token"}`
// (TAURI-RUST-4K5) — that's a SessionExpired wire shape, not a BYO-key auth failure.
// `is_session_expired_message` claims it via the conjunctive
// `Embedding API error (401` + `"error":"Invalid token"` anchors.
if lower.contains("\"error\":\"invalid token\"") {
return false;
}
lower.contains("embedding api error")
&& lower.contains("401")
&& lower.contains("invalid token")
@@ -625,15 +632,18 @@ pub fn is_session_expired_message(msg: &str) -> bool {
// actionable in Sentry).
|| (msg.contains("OpenHuman API error (401")
&& msg.contains("\"error\":\"Invalid token\""))
// TAURI-RUST-4K5 — same OpenHuman backend "Invalid token" envelope
// TAURI-RUST-4K5 / -T — same OpenHuman backend "Invalid token" envelope
// wrapped by `src/openhuman/embeddings/openai.rs:139` with the
// `"Embedding API error"` prefix instead of `"OpenHuman API error"`.
// Same conjunctive-anchor pattern as 4P0: the embedding-scoped
// prefix gates the match so a third-party BYO-key embedding 401
// (e.g. OpenAI/Voyage/Cohere rejecting the user's own API key)
// stays actionable — guarded by
// Both parenthesized (`Embedding API error (401`) and bare-status
// (`Embedding API error 401`) wire shapes are observed in production
// and must classify identically. Same conjunctive-anchor pattern as
// 4P0: the embedding-scoped prefix gates the match so a third-party
// BYO-key embedding 401 (e.g. OpenAI/Voyage/Cohere rejecting the
// user's own API key) stays actionable — guarded by
// `does_not_classify_embedding_byo_key_401_as_session_expired`.
|| (msg.contains("Embedding API error (401")
|| ((msg.contains("Embedding API error (401")
|| msg.contains("Embedding API error 401"))
&& msg.contains("\"error\":\"Invalid token\""))
// TAURI-RUST-1EE — same OpenHuman backend "Invalid token" envelope
// wrapped by the streaming-chat path at
+3
View File
@@ -164,6 +164,9 @@ fn classifies_embedding_backend_auth_failure() {
// must classify as SessionExpired so the FE re-login prompt
// fires (matches the contract introduced by #2786 and
// exercised by classifies_embedding_api_invalid_token_401_as_session_expired).
// Kept as a regression guard against the broader
// `is_embedding_backend_auth_failure` matcher re-claiming this
// shape — see the guard in that function.
for raw in [
r#"Embedding API error 401 Unauthorized: {"success":false,"error":"Invalid token"}"#,
r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#,
+1 -1
View File
@@ -608,7 +608,7 @@ pub(super) const CAPABILITIES: &[Capability] = &[
domain: "wallet",
category: CapabilityCategory::Skills,
description: "Read balances and prepare/confirm/execute transfers, swaps, and contract calls across the connected wallet (EVM, BTC, Solana, Tron). Quote-first; signing stays local.",
how_to: "Use wallet.* RPC methods (balances, prepare_transfer, prepare_swap, prepare_contract_call, execute_prepared) via the agent or core_rpc_relay.",
how_to: "Use wallet.* RPC methods (balances, prepare_transfer, prepare_swap, prepare_contract_call, execute_prepared) via the agent or core_rpc_relay, or via Settings > Wallet Balances.",
status: CapabilityStatus::Beta,
privacy: LOCAL_CREDENTIALS,
},