From 4dd8ec86662e9fa362b5011e522f78b839f929b7 Mon Sep 17 00:00:00 2001 From: Oluwaseyitan Animasaun Date: Thu, 27 Aug 2026 01:33:21 +0100 Subject: [PATCH 1/3] feat(profiles): add multi-profile switching within one wallet --- src/App.tsx | 2 + src/components/AutoSign.tsx | 66 ++- src/components/CkbReceive.tsx | 8 +- src/components/Header.tsx | 9 + src/components/HorizenReceive.tsx | 6 +- src/components/ProfileSwitcher.tsx | 327 +++++++++++++++ src/components/SolanaReceive.tsx | 8 +- src/components/StellarBatchWithdrawModal.tsx | 3 + src/components/StellarReceive.tsx | 12 +- src/components/StellarSend.tsx | 3 + src/context/StealthKeysContext.tsx | 257 +++++++++--- src/i18n/en.json | 3 +- src/i18n/es.json | 3 +- src/lib/portfolio.bench.test.ts | 97 +++++ src/lib/portfolio.test.ts | 312 ++++++++++++++ src/lib/portfolio.ts | 127 ++++++ src/lib/profileSigningMessage.ts | 27 ++ src/pages/Activity.tsx | 12 +- src/pages/Portfolio.tsx | 407 +++++++++++++++++++ src/store/profilesStore.ts | 116 ++++++ src/stores/activityStore.ts | 44 +- tests/profile-domain-separation.spec.ts | 289 +++++++++++++ 22 files changed, 2050 insertions(+), 88 deletions(-) create mode 100644 src/components/ProfileSwitcher.tsx create mode 100644 src/lib/portfolio.bench.test.ts create mode 100644 src/lib/portfolio.test.ts create mode 100644 src/lib/portfolio.ts create mode 100644 src/lib/profileSigningMessage.ts create mode 100644 src/pages/Portfolio.tsx create mode 100644 src/store/profilesStore.ts create mode 100644 tests/profile-domain-separation.spec.ts diff --git a/src/App.tsx b/src/App.tsx index 0a664f0..9211b9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import Schedule from '@/pages/Schedule'; import StellarSplit from '@/pages/StellarSplit'; import Names from '@/pages/Names'; import Activity from '@/pages/Activity'; +import Portfolio from '@/pages/Portfolio'; import Debug from '@/pages/Debug'; export function App() { @@ -39,6 +40,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/AutoSign.tsx b/src/components/AutoSign.tsx index 82222d8..3fc261a 100644 --- a/src/components/AutoSign.tsx +++ b/src/components/AutoSign.tsx @@ -27,12 +27,15 @@ import type { HexString as CkbHexString } from '@wraith-protocol/sdk/chains/ckb' import { useStealthKeys } from '@/context/StealthKeysContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { useChain } from '@/context/ChainContext'; +import { useProfilesStore } from '@/store/profilesStore'; +import { profileSigningMessage } from '@/lib/profileSigningMessage'; function HorizenAutoSign() { const { isConnected, address, connector } = useAccount(); const { data: connectorClient } = useConnectorClient(); const { signMessageAsync } = useSignMessage(); const { evmKeys, setEvmKeys, setEvmMetaAddress, clearEvm } = useStealthKeys(); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const prompted = useRef(null); const [ready, setReady] = useState(false); const isLoading = useRef(false); @@ -49,14 +52,16 @@ function HorizenAutoSign() { if (!ready || !address) return; if (evmKeys) return; if (isLoading.current) return; - if (prompted.current === address) return; + const promptKey = `${address}:${activeProfileId}`; + if (prompted.current === promptKey) return; - prompted.current = address; + prompted.current = promptKey; isLoading.current = true; (async () => { try { - const signature = await signMessageAsync({ message: STEALTH_SIGNING_MESSAGE }); + const message = profileSigningMessage(STEALTH_SIGNING_MESSAGE, activeProfileId); + const signature = await signMessageAsync({ message }); const keys = deriveStealthKeys(signature as HexString); const meta = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey); setEvmKeys(keys); @@ -67,7 +72,7 @@ function HorizenAutoSign() { isLoading.current = false; } })(); - }, [ready, address, evmKeys, signMessageAsync, setEvmKeys, setEvmMetaAddress]); + }, [ready, address, evmKeys, activeProfileId, signMessageAsync, setEvmKeys, setEvmMetaAddress]); useEffect(() => { if (!isConnected) { @@ -83,6 +88,7 @@ function HorizenAutoSign() { function StellarAutoSign() { const { isConnected, address, signMessage } = useStellarWallet(); const { stellarKeys, setStellarKeys, setStellarMetaAddress, clearStellar } = useStealthKeys(); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const prompted = useRef(null); const [ready, setReady] = useState(false); const isLoading = useRef(false); @@ -99,14 +105,16 @@ function StellarAutoSign() { if (!ready || !address) return; if (stellarKeys) return; if (isLoading.current) return; - if (prompted.current === address) return; + const promptKey = `${address}:${activeProfileId}`; + if (prompted.current === promptKey) return; - prompted.current = address; + prompted.current = promptKey; isLoading.current = true; (async () => { try { - const signature = await signMessage(STELLAR_SIGNING_MESSAGE); + const message = profileSigningMessage(STELLAR_SIGNING_MESSAGE, activeProfileId); + const signature = await signMessage(message); const keys = deriveStellarKeys(signature); const meta = encodeStellarMeta(keys.spendingPubKey, keys.viewingPubKey); setStellarKeys(keys); @@ -117,7 +125,15 @@ function StellarAutoSign() { isLoading.current = false; } })(); - }, [ready, address, stellarKeys, signMessage, setStellarKeys, setStellarMetaAddress]); + }, [ + ready, + address, + stellarKeys, + activeProfileId, + signMessage, + setStellarKeys, + setStellarMetaAddress, + ]); useEffect(() => { if (!isConnected) { @@ -133,6 +149,7 @@ function StellarAutoSign() { function SolanaAutoSign() { const { connected, publicKey, signMessage } = useWallet(); const { solanaKeys, setSolanaKeys, setSolanaMetaAddress, clearSolana } = useStealthKeys(); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const prompted = useRef(null); const [ready, setReady] = useState(false); const isLoading = useRef(false); @@ -150,14 +167,16 @@ function SolanaAutoSign() { if (solanaKeys) return; if (isLoading.current) return; const addr = publicKey.toBase58(); - if (prompted.current === addr) return; + const promptKey = `${addr}:${activeProfileId}`; + if (prompted.current === promptKey) return; - prompted.current = addr; + prompted.current = promptKey; isLoading.current = true; (async () => { try { - const msgBytes = new TextEncoder().encode(SOLANA_SIGNING_MESSAGE); + const message = profileSigningMessage(SOLANA_SIGNING_MESSAGE, activeProfileId); + const msgBytes = new TextEncoder().encode(message); const signature = await signMessage(msgBytes); const keys = deriveSolanaKeys(signature); const meta = encodeSolanaMeta(keys.spendingPubKey, keys.viewingPubKey); @@ -169,7 +188,15 @@ function SolanaAutoSign() { isLoading.current = false; } })(); - }, [ready, publicKey, solanaKeys, signMessage, setSolanaKeys, setSolanaMetaAddress]); + }, [ + ready, + publicKey, + solanaKeys, + activeProfileId, + signMessage, + setSolanaKeys, + setSolanaMetaAddress, + ]); useEffect(() => { if (!connected) { @@ -186,7 +213,8 @@ function CkbAutoSign() { const { wallet } = ccc.useCcc(); const signer = ccc.useSigner(); const { ckbKeys, setCkbKeys, setCkbMetaAddress, clearCkb } = useStealthKeys(); - const prompted = useRef(false); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); + const prompted = useRef(null); const [ready, setReady] = useState(false); const isLoading = useRef(false); @@ -202,14 +230,16 @@ function CkbAutoSign() { if (!ready || !signer) return; if (ckbKeys) return; if (isLoading.current) return; - if (prompted.current) return; + const promptKey = activeProfileId; + if (prompted.current === promptKey) return; - prompted.current = true; + prompted.current = promptKey; isLoading.current = true; (async () => { try { - const sig = await (signer as any).signMessageRaw(CKB_SIGNING_MESSAGE); + const message = profileSigningMessage(CKB_SIGNING_MESSAGE, activeProfileId); + const sig = await (signer as any).signMessageRaw(message); const sigStr = typeof sig === 'string' ? sig : `0x${Buffer.from(sig).toString('hex')}`; const sigHex = sigStr.startsWith('0x') ? sigStr : `0x${sigStr}`; const derived = deriveCkbKeys(sigHex as CkbHexString); @@ -222,11 +252,11 @@ function CkbAutoSign() { isLoading.current = false; } })(); - }, [ready, signer, ckbKeys, setCkbKeys, setCkbMetaAddress]); + }, [ready, signer, ckbKeys, activeProfileId, setCkbKeys, setCkbMetaAddress]); useEffect(() => { if (!wallet) { - prompted.current = false; + prompted.current = null; setReady(false); clearCkb(); } diff --git a/src/components/CkbReceive.tsx b/src/components/CkbReceive.tsx index 76e1ee4..8c3160b 100644 --- a/src/components/CkbReceive.tsx +++ b/src/components/CkbReceive.tsx @@ -15,6 +15,8 @@ import { useStealthKeys } from '@/context/StealthKeysContext'; import { EmptyState } from '@/components/EmptyState'; import { CopyButton } from '@/components/CopyButton'; import { trackEvent } from '@/lib/telemetry'; +import { useProfilesStore } from '@/store/profilesStore'; +import { profileSigningMessage } from '@/lib/profileSigningMessage'; function CkbStealthRow({ match }: { match: MatchedStealthCell }) { const { t } = useTranslation(); @@ -88,6 +90,7 @@ export function CkbReceive() { const { wallet } = ccc.useCcc(); const signer = ccc.useSigner(); const { ckbKeys, ckbMetaAddress, setCkbKeys, setCkbMetaAddress } = useStealthKeys(); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const [isDerivingKeys, setIsDerivingKeys] = useState(false); const [isScanning, setIsScanning] = useState(false); @@ -103,7 +106,8 @@ export function CkbReceive() { setIsDerivingKeys(true); setError(''); try { - const sig = await (signer as any).signMessageRaw(STEALTH_SIGNING_MESSAGE); + const message = profileSigningMessage(STEALTH_SIGNING_MESSAGE, activeProfileId); + const sig = await (signer as any).signMessageRaw(message); const sigStr = typeof sig === 'string' ? sig : `0x${Buffer.from(sig).toString('hex')}`; const sigHex = sigStr.startsWith('0x') ? sigStr : `0x${sigStr}`; @@ -122,7 +126,7 @@ export function CkbReceive() { } finally { setIsDerivingKeys(false); } - }, [signer, setCkbKeys, setCkbMetaAddress, t]); + }, [signer, activeProfileId, setCkbKeys, setCkbMetaAddress, t]); const scanPayments = useCallback(async () => { if (!ckbKeys) return; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 70d439a..54a22c7 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,6 +5,7 @@ import { ChainSwitcher } from './ChainSwitcher'; import { WalletConnect } from './WalletConnect'; import { LocaleSwitcher } from './LocaleSwitcher'; import { NetworkChip } from './NetworkChip'; +import { ProfileSwitcher } from './ProfileSwitcher'; import { useTheme } from '@/context/ThemeContext'; import { useNotificationsStore } from '@/stores/notificationsStore'; @@ -21,6 +22,7 @@ export function Header() { { to: '/schedule', label: t('nav.schedule') }, { to: '/names', label: t('nav.names') }, { to: '/activity', label: t('nav.activity') }, + { to: '/portfolio', label: t('nav.portfolio') }, ]; return ( @@ -92,6 +94,7 @@ export function Header() {
+
+ + + + ); +} + +// --------------------------------------------------------------------------- +// New-profile form (inline inside the dropdown) +// --------------------------------------------------------------------------- + +function NewProfileForm({ + onAdd, + onCancel, + existingProfiles: profiles, + chain, +}: { + onAdd: (label: string, chain: string, colorTag: string) => void; + onCancel: () => void; + existingProfiles: ReturnType['profiles']; + chain: string; +}) { + const [label, setLabel] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const colorTag = pickNextColor(profiles); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!label.trim()) return; + onAdd(label.trim(), chain, colorTag); + }; + + return ( +
+

+ New Profile +

+
+ + setLabel(e.target.value)} + placeholder="Profile name" + maxLength={32} + className="flex-1 border border-outline-variant bg-surface px-2 py-1 font-mono text-xs text-primary placeholder:text-outline focus:border-primary focus:outline-none" + /> +
+
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main ProfileSwitcher +// --------------------------------------------------------------------------- + +export function ProfileSwitcher() { + const { profiles, activeProfileId, addProfile, deleteProfile, setActiveProfile } = + useProfilesStore(); + const { chain } = useChain(); + + const [open, setOpen] = useState(false); + const [showNewForm, setShowNewForm] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const containerRef = useRef(null); + const activeProfile = profiles.find((p) => p.id === activeProfileId) ?? profiles[0]; + + // Close on outside click + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setShowNewForm(false); + setConfirmDeleteId(null); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + setShowNewForm(false); + setConfirmDeleteId(null); + } + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [open]); + + const handleAdd = (label: string, profileChain: string, colorTag: string) => { + addProfile(label, profileChain, colorTag); + setShowNewForm(false); + setOpen(false); + }; + + const handleDelete = (id: string) => { + deleteProfile(id); + setConfirmDeleteId(null); + }; + + const colorClasses = + PROFILE_COLOR_CLASSES[activeProfile.colorTag] ?? PROFILE_COLOR_CLASSES['cyan']; + + return ( +
+ {/* Trigger chip — mirrors NetworkChip's visual pattern */} + + + {/* Dropdown */} + {open && ( +
+ {/* Profile list */} +
    + {profiles.map((profile) => { + const isActive = profile.id === activeProfileId; + const pColors = + PROFILE_COLOR_CLASSES[profile.colorTag] ?? PROFILE_COLOR_CLASSES['cyan']; + + return ( +
  • +
    + + + {/* Delete button — hidden for default profile */} + {profile.id !== DEFAULT_PROFILE_ID && ( + + )} +
    + + {confirmDeleteId === profile.id && ( +
    + handleDelete(profile.id)} + onCancel={() => setConfirmDeleteId(null)} + /> +
    + )} +
  • + ); + })} +
+ + {/* Divider + New profile */} + {!showNewForm ? ( + + ) : ( + setShowNewForm(false)} + existingProfiles={profiles} + chain={chain} + /> + )} +
+ )} +
+ ); +} diff --git a/src/components/SolanaReceive.tsx b/src/components/SolanaReceive.tsx index 941cfc6..0901f89 100644 --- a/src/components/SolanaReceive.tsx +++ b/src/components/SolanaReceive.tsx @@ -18,6 +18,8 @@ import { CopyButton } from '@/components/CopyButton'; import { trackEvent } from '@/lib/telemetry'; import { solanaTxUrl, solanaAddrUrl } from '@/lib/explorer'; import { SOLANA_NETWORK } from '@/config'; +import { useProfilesStore } from '@/store/profilesStore'; +import { profileSigningMessage } from '@/lib/profileSigningMessage'; function SolanaStealthRow({ match, @@ -211,6 +213,7 @@ export function SolanaReceive() { const navigate = useNavigate(); const { connected, signMessage } = useWallet(); const { solanaKeys, solanaMetaAddress, setSolanaKeys, setSolanaMetaAddress } = useStealthKeys(); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const [isDerivingKeys, setIsDerivingKeys] = useState(false); const [isScanning, setIsScanning] = useState(false); @@ -226,7 +229,8 @@ export function SolanaReceive() { setIsDerivingKeys(true); setError(''); try { - const msgBytes = new TextEncoder().encode(STEALTH_SIGNING_MESSAGE); + const message = profileSigningMessage(STEALTH_SIGNING_MESSAGE, activeProfileId); + const msgBytes = new TextEncoder().encode(message); const signature = await signMessage(msgBytes); const derived = deriveStealthKeys(signature); setSolanaKeys(derived); @@ -237,7 +241,7 @@ export function SolanaReceive() { } finally { setIsDerivingKeys(false); } - }, [signMessage, setSolanaKeys, setSolanaMetaAddress, t]); + }, [signMessage, activeProfileId, setSolanaKeys, setSolanaMetaAddress, t]); const scanPayments = useCallback(async () => { if (!solanaKeys) return; diff --git a/src/components/StellarBatchWithdrawModal.tsx b/src/components/StellarBatchWithdrawModal.tsx index 0068e1b..3763def 100644 --- a/src/components/StellarBatchWithdrawModal.tsx +++ b/src/components/StellarBatchWithdrawModal.tsx @@ -12,6 +12,7 @@ import { CopyButton } from '@/components/CopyButton'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { useActivityStore } from '@/stores/activityStore'; import { useStellarWallet } from '@/context/StellarWalletContext'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; export interface StellarBatchWithdrawModalProps { isOpen: boolean; @@ -32,6 +33,7 @@ export function StellarBatchWithdrawModal({ const { address: walletAddress } = useStellarWallet(); const addActivity = useActivityStore((state) => state.addEntry); const updateActivity = useActivityStore((state) => state.updateStatus); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const [globalDestination, setGlobalDestination] = useState(''); const [assetKey] = useState('XLM'); @@ -76,6 +78,7 @@ export function StellarBatchWithdrawModal({ status: 'pending', amount: preview.totalAmountXLM, recipient: globalDestination, + profileId: activeProfileId, timestamp: Date.now(), }); diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index 5ac342e..7874fb0 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -42,6 +42,8 @@ import { NetworkMismatchModal } from '@/components/NetworkMismatchModal'; import { useStealthLabels } from '@/hooks/useStealthLabels'; import { StellarBatchWithdrawModal } from '@/components/StellarBatchWithdrawModal'; import { createStellarQrUri } from '@/utils/qr'; +import { useProfilesStore } from '@/store/profilesStore'; +import { profileSigningMessage } from '@/lib/profileSigningMessage'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; @@ -186,6 +188,7 @@ function StellarMatchCardContainer({ const [dest, setDest] = useState(''); const addActivity = useActivityStore((state) => state.addEntry); const updateActivity = useActivityStore((state) => state.updateStatus); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const [withdrawing, setWithdrawing] = useState(false); const [withdrawHash, setWithdrawHash] = useState(null); const [feeBumpHash, setFeeBumpHash] = useState(null); @@ -317,6 +320,7 @@ function StellarMatchCardContainer({ status: 'pending', amount: sendableAmount, recipient: dest, + profileId: activeProfileId, timestamp: Date.now(), }); @@ -368,6 +372,7 @@ function StellarMatchCardContainer({ status: 'pending', amount: withdrawBalance.toFixed(withdrawAssetInfo.decimals), recipient: dest, + profileId: activeProfileId, timestamp: Date.now(), }); @@ -492,6 +497,7 @@ function StellarMatchCardContainer({ direction: 'out', status: 'pending', recipient: dest, + profileId: activeProfileId, timestamp: Date.now(), }); @@ -675,6 +681,7 @@ export function StellarReceive() { useStealthKeys(); const addActivity = useActivityStore((state) => state.addEntry); const updateActivity = useActivityStore((state) => state.updateStatus); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const notifications = useStellarNotifications(); const [isDerivingKeys, setIsDerivingKeys] = useState(false); @@ -864,7 +871,8 @@ export function StellarReceive() { setIsDerivingKeys(true); setError(''); try { - const signature = await signMessage(STEALTH_SIGNING_MESSAGE); + const message = profileSigningMessage(STEALTH_SIGNING_MESSAGE, activeProfileId); + const signature = await signMessage(message); const derived = deriveStealthKeys(signature); setStellarKeys(derived); const meta = encodeStealthMetaAddress(derived.spendingPubKey, derived.viewingPubKey); @@ -885,6 +893,7 @@ export function StellarReceive() { } }, [ signMessage, + activeProfileId, setStellarKeys, setStellarMetaAddress, notifications.state.enabled, @@ -1135,6 +1144,7 @@ export function StellarReceive() { kind: 'name-registration', direction: 'out', status: 'pending', + profileId: activeProfileId, timestamp: Date.now(), }); diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index ec85144..13bd8b9 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -39,6 +39,7 @@ import { import { useActivityStore } from '@/stores/activityStore'; import { ExpiringNamesBanner } from '@/components/ExpiringNamesBanner'; import { decodeQrImage, isCameraUnavailableError, parseStellarQrPayload } from '@/utils/qr'; +import { useProfilesStore } from '@/store/profilesStore'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const STELLAR_BASE_FEE_XLM = 0.00001; @@ -107,6 +108,7 @@ export function StellarSend() { const { address, isConnected, signTransaction, isNetworkMismatch } = useStellarWallet(); const addActivity = useActivityStore((state) => state.addEntry); const updateActivity = useActivityStore((state) => state.updateStatus); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const [recipient, setRecipient] = useState(paramTo || ''); const [amount, setAmount] = useState(paramAmount || ''); const [assetKey, setAssetKey] = useState('XLM'); @@ -596,6 +598,7 @@ export function StellarSend() { status: 'pending', amount: amountValue, recipient: metaAddress, + profileId: activeProfileId, timestamp: Date.now(), }); diff --git a/src/context/StealthKeysContext.tsx b/src/context/StealthKeysContext.tsx index ba2a54a..9d5b17a 100644 --- a/src/context/StealthKeysContext.tsx +++ b/src/context/StealthKeysContext.tsx @@ -1,11 +1,48 @@ -import { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react'; import { StellarWalletContext } from '@/context/StellarWalletContext'; import type { StealthKeys as EVMStealthKeys } from '@wraith-protocol/sdk/chains/evm'; import type { StealthKeys as StellarStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; import type { StealthKeys as SolanaStealthKeys } from '@wraith-protocol/sdk/chains/solana'; import type { StealthKeys as CKBStealthKeys } from '@wraith-protocol/sdk/chains/ckb'; +import { useProfilesStore, DEFAULT_PROFILE_ID } from '@/store/profilesStore'; + +// --------------------------------------------------------------------------- +// Per-profile key cache shape +// --------------------------------------------------------------------------- + +interface ProfileKeySlot { + evmKeys: EVMStealthKeys | null; + evmMetaAddress: string | null; + stellarKeys: StellarStealthKeys | null; + stellarMetaAddress: string | null; + solanaKeys: SolanaStealthKeys | null; + solanaMetaAddress: string | null; + ckbKeys: CKBStealthKeys | null; + ckbMetaAddress: string | null; +} + +function emptySlot(): ProfileKeySlot { + return { + evmKeys: null, + evmMetaAddress: null, + stellarKeys: null, + stellarMetaAddress: null, + solanaKeys: null, + solanaMetaAddress: null, + ckbKeys: null, + ckbMetaAddress: null, + }; +} + +// --------------------------------------------------------------------------- +// Context value interface +// +// Public API is identical to before — callers read/write the active profile's +// keys transparently. The per-profile storage is internal to the provider. +// --------------------------------------------------------------------------- interface StealthKeysContextValue { + // Active-profile keys (read-only convenience accessors) evmKeys: EVMStealthKeys | null; evmMetaAddress: string | null; stellarKeys: StellarStealthKeys | null; @@ -14,6 +51,8 @@ interface StealthKeysContextValue { solanaMetaAddress: string | null; ckbKeys: CKBStealthKeys | null; ckbMetaAddress: string | null; + + // Setters (write into the active profile's slot) setEvmKeys: (keys: EVMStealthKeys) => void; setEvmMetaAddress: (metaAddress: string) => void; setStellarKeys: (keys: StellarStealthKeys) => void; @@ -22,16 +61,25 @@ interface StealthKeysContextValue { setSolanaMetaAddress: (metaAddress: string) => void; setCkbKeys: (keys: CKBStealthKeys) => void; setCkbMetaAddress: (metaAddress: string) => void; + + // Clears scoped to the active profile only clearEvm: () => void; clearStellar: () => void; clearSolana: () => void; clearCkb: () => void; + + // Read keys for any profile (used by ProfileSwitcher preview etc.) + getKeysForProfile: (profileId: string) => ProfileKeySlot; } export const StealthKeysContext = createContext(null); -// Subscribes clearStellar to StellarWalletContext's disconnect listeners. -// Rendered inside StealthKeysProvider so it can consume both contexts. +// --------------------------------------------------------------------------- +// Subscribes clearStellar (for the active profile) to StellarWalletContext's +// disconnect listener. Rendered inside StealthKeysProvider so it can consume +// both contexts. +// --------------------------------------------------------------------------- + function StealthKeysCleaner({ clearStellar }: { clearStellar: () => void }) { const stellar = useContext(StellarWalletContext); const subscribeToDisconnect = stellar?.subscribeToDisconnect; @@ -42,58 +90,165 @@ function StealthKeysCleaner({ clearStellar }: { clearStellar: () => void }) { return null; } +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + export function StealthKeysProvider({ children }: { children: React.ReactNode }) { - const [evmKeys, setEvmKeys] = useState(null); - const [evmMetaAddress, setEvmMetaAddress] = useState(null); - const [stellarKeys, setStellarKeys] = useState(null); - const [stellarMetaAddress, setStellarMetaAddress] = useState(null); - const [solanaKeys, setSolanaKeys] = useState(null); - const [solanaMetaAddress, setSolanaMetaAddress] = useState(null); - const [ckbKeys, setCkbKeys] = useState(null); - const [ckbMetaAddress, setCkbMetaAddress] = useState(null); - - const clearEvm = useCallback(() => { - setEvmKeys(null); - setEvmMetaAddress(null); - }, []); - const clearStellar = useCallback(() => { - setStellarKeys(null); - setStellarMetaAddress(null); - }, []); - const clearSolana = useCallback(() => { - setSolanaKeys(null); - setSolanaMetaAddress(null); - }, []); - const clearCkb = useCallback(() => { - setCkbKeys(null); - setCkbMetaAddress(null); + // Map from profileId → key slot. We keep this in React state so that + // changes to any profile's keys trigger re-renders in subscribers. + const [keysByProfile, setKeysByProfile] = useState>( + () => new Map([[DEFAULT_PROFILE_ID, emptySlot()]]), + ); + + const activeProfileId = useProfilesStore((s) => s.activeProfileId); + + // When the active profile changes, ensure the new profile has a slot. + useEffect(() => { + setKeysByProfile((prev) => { + if (prev.has(activeProfileId)) return prev; + const next = new Map(prev); + next.set(activeProfileId, emptySlot()); + return next; + }); + }, [activeProfileId]); + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + const getSlot = useCallback( + (profileId: string): ProfileKeySlot => { + return keysByProfile.get(profileId) ?? emptySlot(); + }, + [keysByProfile], + ); + + const patchSlot = useCallback((profileId: string, patch: Partial) => { + setKeysByProfile((prev) => { + const next = new Map(prev); + const existing = next.get(profileId) ?? emptySlot(); + next.set(profileId, { ...existing, ...patch }); + return next; + }); }, []); + // --------------------------------------------------------------------------- + // Active-profile key accessors (stable via useMemo so consumers don't + // re-render on every keysByProfile map update) + // --------------------------------------------------------------------------- + + const activeSlot = useMemo(() => getSlot(activeProfileId), [getSlot, activeProfileId]); + + // --------------------------------------------------------------------------- + // Setters — always write into the ACTIVE profile's slot + // --------------------------------------------------------------------------- + + const setEvmKeys = useCallback( + (keys: EVMStealthKeys) => patchSlot(activeProfileId, { evmKeys: keys }), + [patchSlot, activeProfileId], + ); + const setEvmMetaAddress = useCallback( + (metaAddress: string) => patchSlot(activeProfileId, { evmMetaAddress: metaAddress }), + [patchSlot, activeProfileId], + ); + const setStellarKeys = useCallback( + (keys: StellarStealthKeys) => patchSlot(activeProfileId, { stellarKeys: keys }), + [patchSlot, activeProfileId], + ); + const setStellarMetaAddress = useCallback( + (metaAddress: string) => patchSlot(activeProfileId, { stellarMetaAddress: metaAddress }), + [patchSlot, activeProfileId], + ); + const setSolanaKeys = useCallback( + (keys: SolanaStealthKeys) => patchSlot(activeProfileId, { solanaKeys: keys }), + [patchSlot, activeProfileId], + ); + const setSolanaMetaAddress = useCallback( + (metaAddress: string) => patchSlot(activeProfileId, { solanaMetaAddress: metaAddress }), + [patchSlot, activeProfileId], + ); + const setCkbKeys = useCallback( + (keys: CKBStealthKeys) => patchSlot(activeProfileId, { ckbKeys: keys }), + [patchSlot, activeProfileId], + ); + const setCkbMetaAddress = useCallback( + (metaAddress: string) => patchSlot(activeProfileId, { ckbMetaAddress: metaAddress }), + [patchSlot, activeProfileId], + ); + + // --------------------------------------------------------------------------- + // Clears — scoped to the active profile only (do NOT wipe other profiles) + // --------------------------------------------------------------------------- + + const clearEvm = useCallback( + () => patchSlot(activeProfileId, { evmKeys: null, evmMetaAddress: null }), + [patchSlot, activeProfileId], + ); + const clearStellar = useCallback( + () => patchSlot(activeProfileId, { stellarKeys: null, stellarMetaAddress: null }), + [patchSlot, activeProfileId], + ); + const clearSolana = useCallback( + () => patchSlot(activeProfileId, { solanaKeys: null, solanaMetaAddress: null }), + [patchSlot, activeProfileId], + ); + const clearCkb = useCallback( + () => patchSlot(activeProfileId, { ckbKeys: null, ckbMetaAddress: null }), + [patchSlot, activeProfileId], + ); + + // Read keys for any profile (e.g. ProfileSwitcher preview) + const getKeysForProfile = useCallback((id: string) => getSlot(id), [getSlot]); + + const value = useMemo( + () => ({ + // Active-profile flat accessors (identical API to the original context) + evmKeys: activeSlot.evmKeys, + evmMetaAddress: activeSlot.evmMetaAddress, + stellarKeys: activeSlot.stellarKeys, + stellarMetaAddress: activeSlot.stellarMetaAddress, + solanaKeys: activeSlot.solanaKeys, + solanaMetaAddress: activeSlot.solanaMetaAddress, + ckbKeys: activeSlot.ckbKeys, + ckbMetaAddress: activeSlot.ckbMetaAddress, + // Setters + setEvmKeys, + setEvmMetaAddress, + setStellarKeys, + setStellarMetaAddress, + setSolanaKeys, + setSolanaMetaAddress, + setCkbKeys, + setCkbMetaAddress, + // Clears + clearEvm, + clearStellar, + clearSolana, + clearCkb, + // Cross-profile read + getKeysForProfile, + }), + [ + activeSlot, + setEvmKeys, + setEvmMetaAddress, + setStellarKeys, + setStellarMetaAddress, + setSolanaKeys, + setSolanaMetaAddress, + setCkbKeys, + setCkbMetaAddress, + clearEvm, + clearStellar, + clearSolana, + clearCkb, + getKeysForProfile, + ], + ); + return ( - + {children} diff --git a/src/i18n/en.json b/src/i18n/en.json index b8b2172..d0c063c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4,7 +4,8 @@ "receive": "Receive", "schedule": "Schedule", "names": "Names", - "activity": "Activity" + "activity": "Activity", + "portfolio": "Portfolio" }, "header": { "menuLabel": "Menu", diff --git a/src/i18n/es.json b/src/i18n/es.json index 15df6a6..7679cab 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -4,7 +4,8 @@ "receive": "Recibir", "schedule": "Programar", "names": "Nombres", - "activity": "Actividad" + "activity": "Actividad", + "portfolio": "Portafolio" }, "header": { "menuLabel": "Menú", diff --git a/src/lib/portfolio.bench.test.ts b/src/lib/portfolio.bench.test.ts new file mode 100644 index 0000000..5aa0661 --- /dev/null +++ b/src/lib/portfolio.bench.test.ts @@ -0,0 +1,97 @@ +/** + * Benchmark: portfolio derivation functions on 500 synthetic ActivityEntry rows. + * + * Acceptance criterion from issue #148: + * filterByWindow + calcAssetTotals + calcMonthlyFlow + calcTopCounterparties + * must complete in under 100 ms for 500 entries per time-window switch. + * + * Run with: node --loader ts-node/esm src/lib/portfolio.bench.ts + * Or via: pnpm test:unit (picked up as a vitest test file) + */ + +import { describe, it, expect } from 'vitest'; +import type { ActivityEntry } from '@/stores/activityStore'; +import { + filterByWindow, + calcAssetTotals, + calcMonthlyFlow, + calcTopCounterparties, + type TimeWindow, +} from './portfolio'; + +// ─── Synthetic data generator ───────────────────────────────────────────────── + +const TOKENS = ['XLM', 'USDC', 'BTC', 'ETH', 'SOL']; +const DIRECTIONS = ['in', 'out'] as const; +const STATUSES = ['confirmed', 'pending', 'failed'] as const; +const RECIPIENTS = Array.from({ length: 20 }, (_, i) => `ADDR_${i.toString().padStart(3, '0')}`); + +const NOW = Date.now(); +const NINETY_DAYS = 90 * 24 * 60 * 60 * 1000; + +function generateEntries(count: number): ActivityEntry[] { + return Array.from({ length: count }, (_, i) => ({ + id: `bench-tx-${i}`, + chain: 'stellar', + wallet: 'BENCH_WALLET', + kind: 'stealth-send' as const, + direction: DIRECTIONS[i % 2], + status: STATUSES[i % 3], + amount: String(((i % 500) + 1) * 0.5), + token: TOKENS[i % TOKENS.length], + recipient: RECIPIENTS[i % RECIPIENTS.length], + timestamp: NOW - (i / count) * NINETY_DAYS, // spread evenly over 90 days + })); +} + +// ─── Benchmark test ─────────────────────────────────────────────────────────── + +describe('portfolio benchmark — 500 entries, <100ms requirement', () => { + const ENTRY_COUNT = 500; + const THRESHOLD_MS = 100; + const entries = generateEntries(ENTRY_COUNT); + const windows: TimeWindow[] = ['7d', '30d', '90d', 'all']; + + it(`runs all four derivation functions across all four windows in under ${THRESHOLD_MS}ms`, () => { + // Warm up Intl formatters (jsdom initialises locale data lazily on first call) + calcMonthlyFlow([]); + + const start = performance.now(); + + for (const win of windows) { + const filtered = filterByWindow(entries, win); + calcAssetTotals(filtered); + calcMonthlyFlow(filtered); + calcTopCounterparties(filtered, 5); + } + + const elapsed = performance.now() - start; + + console.log(`\n📊 Portfolio benchmark (${ENTRY_COUNT} entries × ${windows.length} windows)`); + console.log(` Total elapsed : ${elapsed.toFixed(3)} ms`); + console.log(` Per-window avg: ${(elapsed / windows.length).toFixed(3)} ms`); + console.log(` Threshold : ${THRESHOLD_MS} ms`); + console.log(` Result : ${elapsed < THRESHOLD_MS ? '✅ PASS' : '❌ FAIL'}`); + + expect(elapsed).toBeLessThan(THRESHOLD_MS); + }); + + it('runs a single time-window switch in under 50ms (hot-path latency)', () => { + // Warm up Intl formatters and JIT + calcMonthlyFlow([]); + filterByWindow(entries, '30d'); + + const start = performance.now(); + const filtered = filterByWindow(entries, '30d'); + calcAssetTotals(filtered); + calcMonthlyFlow(filtered); + calcTopCounterparties(filtered, 5); + const elapsed = performance.now() - start; + + console.log( + `\n⚡ Single window-switch (30d, ${filtered.length} filtered entries): ${elapsed.toFixed(3)} ms`, + ); + + expect(elapsed).toBeLessThan(50); + }); +}); diff --git a/src/lib/portfolio.test.ts b/src/lib/portfolio.test.ts new file mode 100644 index 0000000..2d4e9db --- /dev/null +++ b/src/lib/portfolio.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import type { ActivityEntry } from '@/stores/activityStore'; +import { + filterByWindow, + calcAssetTotals, + calcMonthlyFlow, + calcTopCounterparties, + type TimeWindow, +} from './portfolio'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const NOW = Date.UTC(2026, 0, 15, 12, 0, 0); // 2026-01-15T12:00:00Z — fixed reference + +const DAY = 24 * 60 * 60 * 1000; +const HOUR = 60 * 60 * 1000; + +function makeEntry(overrides: Partial): ActivityEntry { + return { + id: 'tx-1', + chain: 'stellar', + wallet: 'WALLET', + kind: 'stealth-send', + direction: 'out', + status: 'confirmed', + amount: '10', + token: 'XLM', + recipient: 'ADDR_A', + timestamp: NOW, + ...overrides, + }; +} + +// ─── filterByWindow ──────────────────────────────────────────────────────────── + +describe('filterByWindow', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns empty array when given no entries', () => { + expect(filterByWindow([], '7d')).toEqual([]); + expect(filterByWindow([], '30d')).toEqual([]); + expect(filterByWindow([], 'all')).toEqual([]); + }); + + it('returns all entries for the "all" window regardless of age', () => { + const entries = [ + makeEntry({ id: 'a', timestamp: NOW - 365 * DAY }), + makeEntry({ id: 'b', timestamp: NOW - 1 * DAY }), + makeEntry({ id: 'c', timestamp: NOW }), + ]; + expect(filterByWindow(entries, 'all')).toHaveLength(3); + }); + + it('includes entries exactly at the cutoff boundary (inclusive >=)', () => { + const cutoff7d = NOW - 7 * DAY; + const cutoff30d = NOW - 30 * DAY; + const cutoff90d = NOW - 90 * DAY; + + const atCutoff7d = makeEntry({ id: '7d-boundary', timestamp: cutoff7d }); + const atCutoff30d = makeEntry({ id: '30d-boundary', timestamp: cutoff30d }); + const atCutoff90d = makeEntry({ id: '90d-boundary', timestamp: cutoff90d }); + + expect(filterByWindow([atCutoff7d], '7d')).toHaveLength(1); + expect(filterByWindow([atCutoff30d], '30d')).toHaveLength(1); + expect(filterByWindow([atCutoff90d], '90d')).toHaveLength(1); + }); + + it('excludes entries 1ms before the cutoff', () => { + const justBefore7d = makeEntry({ id: 'just-before', timestamp: NOW - 7 * DAY - 1 }); + expect(filterByWindow([justBefore7d], '7d')).toHaveLength(0); + }); + + it('filters correctly with a mixed set spanning multiple windows', () => { + const entries = [ + makeEntry({ id: 'in-7d', timestamp: NOW - 3 * DAY }), + makeEntry({ id: 'in-30d-not-7d', timestamp: NOW - 10 * DAY }), + makeEntry({ id: 'in-90d-not-30d', timestamp: NOW - 45 * DAY }), + makeEntry({ id: 'older', timestamp: NOW - 120 * DAY }), + ]; + + expect(filterByWindow(entries, '7d')).toHaveLength(1); + expect(filterByWindow(entries, '30d')).toHaveLength(2); + expect(filterByWindow(entries, '90d')).toHaveLength(3); + expect(filterByWindow(entries, 'all')).toHaveLength(4); + }); + + it('does not mutate the original array', () => { + const entries = [makeEntry({ id: 'old', timestamp: NOW - 200 * DAY })]; + const original = [...entries]; + filterByWindow(entries, '7d'); + expect(entries).toEqual(original); + }); +}); + +// ─── calcAssetTotals ────────────────────────────────────────────────────────── + +describe('calcAssetTotals', () => { + it('returns empty object for empty input', () => { + expect(calcAssetTotals([])).toEqual({}); + }); + + it('accumulates a single inflow entry', () => { + const entry = makeEntry({ direction: 'in', amount: '50', token: 'XLM' }); + const result = calcAssetTotals([entry]); + expect(result['XLM']).toEqual({ in: 50, out: 0, net: 50 }); + }); + + it('accumulates a single outflow entry', () => { + const entry = makeEntry({ direction: 'out', amount: '20', token: 'XLM' }); + const result = calcAssetTotals([entry]); + expect(result['XLM']).toEqual({ in: 0, out: 20, net: -20 }); + }); + + it('accumulates mixed inflow and outflow for the same token', () => { + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: '100', token: 'XLM' }), + makeEntry({ id: 'b', direction: 'out', amount: '30', token: 'XLM' }), + makeEntry({ id: 'c', direction: 'in', amount: '20', token: 'XLM' }), + ]; + const result = calcAssetTotals(entries); + expect(result['XLM'].in).toBeCloseTo(120); + expect(result['XLM'].out).toBeCloseTo(30); + expect(result['XLM'].net).toBeCloseTo(90); + }); + + it('tracks multiple tokens independently', () => { + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: '100', token: 'XLM' }), + makeEntry({ id: 'b', direction: 'out', amount: '5', token: 'USDC' }), + makeEntry({ id: 'c', direction: 'in', amount: '200', token: 'USDC' }), + ]; + const result = calcAssetTotals(entries); + expect(result['XLM']).toEqual({ in: 100, out: 0, net: 100 }); + expect(result['USDC'].in).toBeCloseTo(200); + expect(result['USDC'].out).toBeCloseTo(5); + expect(result['USDC'].net).toBeCloseTo(195); + }); + + it('falls back to "Unknown" when token is missing', () => { + const entry = makeEntry({ direction: 'in', amount: '10', token: undefined }); + const result = calcAssetTotals([entry]); + expect(result['Unknown']).toBeDefined(); + expect(result['Unknown'].in).toBeCloseTo(10); + }); + + it('treats missing or non-numeric amount as 0', () => { + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: undefined, token: 'XLM' }), + makeEntry({ id: 'b', direction: 'out', amount: 'NaN', token: 'XLM' }), + ]; + const result = calcAssetTotals(entries); + expect(result['XLM']).toEqual({ in: 0, out: 0, net: 0 }); + }); +}); + +// ─── calcMonthlyFlow ────────────────────────────────────────────────────────── + +describe('calcMonthlyFlow', () => { + it('returns empty array for empty input', () => { + expect(calcMonthlyFlow([])).toEqual([]); + }); + + it('returns a single bucket for entries in the same month', () => { + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: '50', timestamp: Date.UTC(2026, 0, 5) }), + makeEntry({ id: 'b', direction: 'out', amount: '20', timestamp: Date.UTC(2026, 0, 20) }), + ]; + const result = calcMonthlyFlow(entries); + expect(result).toHaveLength(1); + expect(result[0].in).toBeCloseTo(50); + expect(result[0].out).toBeCloseTo(20); + }); + + it('produces separate buckets for different months', () => { + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: '10', timestamp: Date.UTC(2025, 10, 15) }), + makeEntry({ id: 'b', direction: 'in', amount: '20', timestamp: Date.UTC(2025, 11, 15) }), + makeEntry({ id: 'c', direction: 'out', amount: '5', timestamp: Date.UTC(2026, 0, 15) }), + ]; + const result = calcMonthlyFlow(entries); + expect(result).toHaveLength(3); + }); + + it('returns buckets sorted chronologically (oldest first)', () => { + const entries = [ + makeEntry({ id: 'a', timestamp: Date.UTC(2026, 2, 1) }), // Mar + makeEntry({ id: 'b', timestamp: Date.UTC(2025, 11, 1) }), // Dec + makeEntry({ id: 'c', timestamp: Date.UTC(2026, 0, 1) }), // Jan + ]; + const result = calcMonthlyFlow(entries); + expect(result.map((r) => r.month)).toEqual(['Dec 25', 'Jan 26', 'Mar 26']); + }); + + it('accumulates inflow and outflow per bucket correctly', () => { + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: '100', timestamp: Date.UTC(2026, 0, 1) }), + makeEntry({ id: 'b', direction: 'in', amount: '50', timestamp: Date.UTC(2026, 0, 20) }), + makeEntry({ id: 'c', direction: 'out', amount: '30', timestamp: Date.UTC(2026, 0, 25) }), + ]; + const result = calcMonthlyFlow(entries); + expect(result).toHaveLength(1); + expect(result[0].in).toBeCloseTo(150); + expect(result[0].out).toBeCloseTo(30); + }); + + it('handles entries at month boundaries (mid-month, unambiguous)', () => { + // Use mid-month timestamps so local-timezone offset cannot shift the month + const midJan = Date.UTC(2026, 0, 15, 12, 0, 0, 0); + const midFeb = Date.UTC(2026, 1, 15, 12, 0, 0, 0); + const entries = [ + makeEntry({ id: 'a', direction: 'in', amount: '10', timestamp: midJan }), + makeEntry({ id: 'b', direction: 'in', amount: '20', timestamp: midFeb }), + ]; + const result = calcMonthlyFlow(entries); + expect(result).toHaveLength(2); + // Jan bucket should have 10, Feb bucket should have 20 + const totalIn = result.reduce((sum, r) => sum + r.in, 0); + expect(totalIn).toBeCloseTo(30); + // Sorted chronologically: Jan before Feb + expect(result[0].in).toBeCloseTo(10); + expect(result[1].in).toBeCloseTo(20); + }); +}); + +// ─── calcTopCounterparties ──────────────────────────────────────────────────── + +describe('calcTopCounterparties', () => { + it('returns empty array for empty input', () => { + expect(calcTopCounterparties([])).toEqual([]); + }); + + it('skips entries with no recipient', () => { + const entries = [makeEntry({ recipient: undefined })]; + expect(calcTopCounterparties(entries)).toEqual([]); + }); + + it('aggregates counts and totals for a single counterparty', () => { + const entries = [ + makeEntry({ id: 'a', recipient: 'ADDR_A', amount: '10' }), + makeEntry({ id: 'b', recipient: 'ADDR_A', amount: '40' }), + ]; + const result = calcTopCounterparties(entries); + expect(result).toHaveLength(1); + expect(result[0].address).toBe('ADDR_A'); + expect(result[0].count).toBe(2); + expect(result[0].total).toBeCloseTo(50); + }); + + it('ranks counterparties by total descending', () => { + const entries = [ + makeEntry({ id: 'a', recipient: 'LOW', amount: '5' }), + makeEntry({ id: 'b', recipient: 'HIGH', amount: '200' }), + makeEntry({ id: 'c', recipient: 'MID', amount: '50' }), + ]; + const result = calcTopCounterparties(entries); + expect(result[0].address).toBe('HIGH'); + expect(result[1].address).toBe('MID'); + expect(result[2].address).toBe('LOW'); + }); + + it('respects the limit parameter (default 5)', () => { + const entries = Array.from({ length: 10 }, (_, i) => + makeEntry({ id: `tx-${i}`, recipient: `ADDR_${i}`, amount: String(i + 1) }), + ); + expect(calcTopCounterparties(entries)).toHaveLength(5); + expect(calcTopCounterparties(entries, 3)).toHaveLength(3); + expect(calcTopCounterparties(entries, 10)).toHaveLength(10); + }); + + it('attaches labels from the labels map when provided', () => { + const entries = [makeEntry({ recipient: 'ADDR_A', amount: '10' })]; + const labels = { ADDR_A: 'Alice' }; + const result = calcTopCounterparties(entries, 5, labels); + expect(result[0].label).toBe('Alice'); + }); + + it('leaves label undefined when address is not in labels map', () => { + const entries = [makeEntry({ recipient: 'ADDR_B', amount: '10' })]; + const labels = { ADDR_A: 'Alice' }; + const result = calcTopCounterparties(entries, 5, labels); + expect(result[0].label).toBeUndefined(); + }); + + it('uses count as tiebreaker when totals are equal', () => { + const entries = [ + makeEntry({ id: 'a', recipient: 'ONCE', amount: '100' }), + makeEntry({ id: 'b', recipient: 'TWICE', amount: '50' }), + makeEntry({ id: 'c', recipient: 'TWICE', amount: '50' }), + ]; + const result = calcTopCounterparties(entries); + // Both ONCE and TWICE total 100; TWICE has 2 txs so it ranks first + expect(result[0].address).toBe('TWICE'); + expect(result[1].address).toBe('ONCE'); + }); + + it('handles a mixed entry set with missing amounts', () => { + const entries = [ + makeEntry({ id: 'a', recipient: 'ADDR_A', amount: undefined }), + makeEntry({ id: 'b', recipient: 'ADDR_A', amount: '20' }), + ]; + const result = calcTopCounterparties(entries); + expect(result[0].total).toBeCloseTo(20); + expect(result[0].count).toBe(2); + }); +}); diff --git a/src/lib/portfolio.ts b/src/lib/portfolio.ts new file mode 100644 index 0000000..00295bf --- /dev/null +++ b/src/lib/portfolio.ts @@ -0,0 +1,127 @@ +import type { ActivityEntry } from '@/stores/activityStore'; + +export type TimeWindow = '7d' | '30d' | '90d' | 'all'; + +export interface AssetTotals { + [token: string]: { in: number; out: number; net: number }; +} + +export interface MonthlyFlow { + month: string; // e.g. "Jan 25" + in: number; + out: number; +} + +export interface Counterparty { + address: string; + label?: string; + count: number; + total: number; +} + +const WINDOW_MS: Record = { + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + '90d': 90 * 24 * 60 * 60 * 1000, + all: Infinity, +}; + +/** Filter entries to those within the given time window relative to now. */ +export function filterByWindow(entries: ActivityEntry[], window: TimeWindow): ActivityEntry[] { + if (window === 'all') return entries; + const cutoff = Date.now() - WINDOW_MS[window]; + return entries.filter((e) => e.timestamp >= cutoff); +} + +/** Accumulate per-token inflow/outflow/net totals. */ +export function calcAssetTotals(entries: ActivityEntry[]): AssetTotals { + const totals: AssetTotals = {}; + + for (const entry of entries) { + const token = entry.token ?? 'Unknown'; + const amount = parseFloat(entry.amount ?? '0') || 0; + + if (!totals[token]) { + totals[token] = { in: 0, out: 0, net: 0 }; + } + + if (entry.direction === 'in') { + totals[token].in += amount; + } else { + totals[token].out += amount; + } + totals[token].net = totals[token].in - totals[token].out; + } + + return totals; +} + +// Module-level formatter — constructed once, reused across all calls. +const _monthFmt = new Intl.DateTimeFormat('en-US', { month: 'short', year: '2-digit' }); + +/** Build a sorted array of monthly inflow/outflow buckets for a bar chart. */ +export function calcMonthlyFlow(entries: ActivityEntry[]): MonthlyFlow[] { + const buckets = new Map(); + + for (const entry of entries) { + const d = new Date(entry.timestamp); + // e.g. "Jan 25" + const month = _monthFmt.format(d); + // ISO sort key: "2025-01" + const sortKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const amount = parseFloat(entry.amount ?? '0') || 0; + + if (!buckets.has(month)) { + buckets.set(month, { in: 0, out: 0, sortKey }); + } + + const bucket = buckets.get(month)!; + if (entry.direction === 'in') { + bucket.in += amount; + } else { + bucket.out += amount; + } + } + + return Array.from(buckets.entries()) + .sort((a, b) => a[1].sortKey.localeCompare(b[1].sortKey)) + .map(([month, data]) => ({ month, in: data.in, out: data.out })); +} + +/** + * Rank counterparties by total volume. + * "recipient" field on outbound entries, "wallet" on inbound entries + * (inbound entries arrive at our stealth address, so the counterparty is the sender + * which we don't have — fall back to recipient field when present). + */ +export function calcTopCounterparties( + entries: ActivityEntry[], + limit = 5, + labels?: Record, +): Counterparty[] { + const map = new Map(); + + for (const entry of entries) { + const addr = entry.recipient; + if (!addr) continue; + + const amount = parseFloat(entry.amount ?? '0') || 0; + const existing = map.get(addr); + if (existing) { + existing.count += 1; + existing.total += amount; + } else { + map.set(addr, { count: 1, total: amount }); + } + } + + return Array.from(map.entries()) + .map(([address, data]) => ({ + address, + label: labels?.[address], + count: data.count, + total: data.total, + })) + .sort((a, b) => b.total - a.total || b.count - a.count) + .slice(0, limit); +} diff --git a/src/lib/profileSigningMessage.ts b/src/lib/profileSigningMessage.ts new file mode 100644 index 0000000..020cf4e --- /dev/null +++ b/src/lib/profileSigningMessage.ts @@ -0,0 +1,27 @@ +import { DEFAULT_PROFILE_ID } from '@/store/profilesStore'; + +/** + * Returns the signing message to use for key derivation for a given profile. + * + * CRITICAL correctness rule: + * - The default profile (id === 'default') MUST return the base message UNCHANGED + * so existing users' keys, meta-addresses, and on-chain announcements remain valid. + * - Every non-default profile gets a deterministic suffix that makes the resulting + * signature — and therefore the derived stealth keys — cryptographically distinct + * from the default and from every other profile. + * + * The suffix format is: "\n\nProfile: " + * The double-newline acts as a clear delimiter between the original message and the + * profile-specific extension. The profileId is a UUID, which is unique per profile. + * + * @param baseMessage The chain's canonical STEALTH_SIGNING_MESSAGE constant. + * @param profileId The id of the profile being derived. + * @returns The signing message to pass to signMessage / signMessageAsync. + */ +export function profileSigningMessage(baseMessage: string, profileId: string): string { + if (profileId === DEFAULT_PROFILE_ID) { + // Byte-for-byte identical to the original message — no change for existing users. + return baseMessage; + } + return `${baseMessage}\n\nProfile: ${profileId}`; +} diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index f0fd7b8..12a1d11 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -8,20 +8,24 @@ import { type ActivityStatus, } from '@/stores/activityStore'; import { downloadActivityCsv, downloadActivityJson } from '@/utils/activityExport'; +import { useProfilesStore } from '@/store/profilesStore'; type ActivityChain = 'horizen' | 'stellar' | 'solana' | 'ckb'; export default function Activity() { const { address } = useStellarWallet(); const { entries, clearHistory } = useActivityStore(); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const [filterChain, setFilterChain] = useState('all'); const [filterKind, setFilterKind] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const walletEntries = useMemo(() => { if (!address) return []; - return entries.filter((entry: ActivityEntry) => entry.wallet === address); - }, [entries, address]); + return entries.filter( + (entry: ActivityEntry) => entry.wallet === address && entry.profileId === activeProfileId, + ); + }, [entries, address, activeProfileId]); const filteredEntries = useMemo( () => @@ -62,7 +66,9 @@ export default function Activity() { + ))} + + + {/* ── Empty state ─────────────────────────────────────────────────────── */} + {!hasData && ( +