mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Srinivas Vaddi
Steven Enamakel
sanil-23
sanil-23
parent
4c911240df
commit
e6bd1bd7b8
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />)} />
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}"#,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user