diff --git a/.storybook/decorators/withStealthKeys.tsx b/.storybook/decorators/withStealthKeys.tsx index c35b182..c5787e2 100644 --- a/.storybook/decorators/withStealthKeys.tsx +++ b/.storybook/decorators/withStealthKeys.tsx @@ -15,12 +15,6 @@ const baseValue: StealthKeysValue = { solanaMetaAddress: null, ckbKeys: null, ckbMetaAddress: null, - isRecoveryMode: false, - isReadOnly: false, - setIsRecoveryMode: noop, - setIsReadOnly: noop, - restoreFromRecoveryKit: noop, - exitRecoveryMode: noop, setEvmKeys: noop, setEvmMetaAddress: noop, setStellarKeys: noop, diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 206d156..24eb32a 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -10,14 +10,6 @@ "background_color": "#0e0e0e", "lang": "en", "categories": ["finance", "utilities"], - "share_target": { - "action": "/send", - "method": "GET", - "enctype": "application/x-www-form-urlencoded", - "params": { - "text": "text" - } - }, "icons": [ { "src": "/icons/icon-72x72.png", diff --git a/src/App.tsx b/src/App.tsx index 9f50a65..9211b9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; -import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { Routes, Route, Navigate } from 'react-router-dom'; import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; import { TelemetryBanner } from '@/components/TelemetryBanner'; @@ -15,160 +14,12 @@ import { useNotificationSW } from '@/hooks/useNotificationSW'; import Schedule from '@/pages/Schedule'; import StellarSplit from '@/pages/StellarSplit'; import Names from '@/pages/Names'; -import NamesAuctions from '@/pages/NamesAuctions'; import Activity from '@/pages/Activity'; import Portfolio from '@/pages/Portfolio'; import Debug from '@/pages/Debug'; -import { useChain } from '@/context/ChainContext'; -import { useStealthKeys } from '@/context/StealthKeysContext'; -import { KeyVault } from '@/vault/KeyVault'; -import { - APP_IDLE_TIMEOUT_MS, - IdleLock, - authenticateWithPasskey, - isPasskeySupported, -} from '@/lib/idleLock'; -import { parseStellarQrPayload } from '@/utils/qr'; - -function SessionLock({ onUnlock }: { onUnlock: () => void }) { - const [passphrase, setPassphrase] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - const vaultRef = useRef(null); - - const unlockWithPasskey = async () => { - setBusy(true); - setError(''); - try { - await authenticateWithPasskey(); - onUnlock(); - } catch { - setError('No Wraith passkey was found. Unlock with your vault passphrase instead.'); - } finally { - setBusy(false); - } - }; - - const unlockWithPassphrase = async (event: FormEvent) => { - event.preventDefault(); - if (!passphrase) return; - - setBusy(true); - setError(''); - try { - vaultRef.current ??= new KeyVault({ - idleTimeoutMs: 0, - lockOnBlur: false, - lockOnVisibilityChange: false, - }); - await vaultRef.current.unlock(passphrase); - await vaultRef.current.lock(); - setPassphrase(''); - onUnlock(); - } catch { - setError('That passphrase could not unlock the Wraith vault.'); - } finally { - setBusy(false); - } - }; - - return ( -
-
- -

- Wraith locked -

-

- Your session was locked after five minutes of inactivity. -

- - {isPasskeySupported() && ( - - )} - -
- - setPassphrase(event.target.value)} - className="mt-2 h-11 w-full border border-outline-variant bg-surface px-3 font-body text-sm text-on-surface outline-none focus:border-primary" - /> - -
- - {error && ( -

- {error} -

- )} -
-
- ); -} export function App() { useNotificationSW(); - const location = useLocation(); - const navigate = useNavigate(); - const { setChain } = useChain(); - const { clearEvm, clearStellar, clearSolana, clearCkb } = useStealthKeys(); - const [sessionLocked, setSessionLocked] = useState(false); - - const relockSession = useCallback(() => { - clearEvm(); - clearStellar(); - clearSolana(); - clearCkb(); - setSessionLocked(true); - }, [clearCkb, clearEvm, clearSolana, clearStellar]); - - useEffect(() => { - if (sessionLocked) return; - const idleLock = new IdleLock({ timeoutMs: APP_IDLE_TIMEOUT_MS, onIdle: relockSession }); - idleLock.start(); - return () => idleLock.stop(); - }, [relockSession, sessionLocked]); - - useEffect(() => { - if (location.pathname !== '/send') return; - const sharedText = new URLSearchParams(location.search).get('text'); - if (!sharedText) return; - - try { - const payload = parseStellarQrPayload(sharedText); - const params = new URLSearchParams({ to: payload.metaAddress }); - if (payload.amount) params.set('amount', payload.amount); - if (payload.memo) params.set('memo', payload.memo); - setChain('stellar'); - navigate(`/send?${params.toString()}`, { replace: true }); - } catch { - // Leave unsupported shared text untouched so the user can correct it manually. - } - }, [location.pathname, location.search, navigate, setChain]); - - if (sessionLocked) return setSessionLocked(false)} />; return (
@@ -187,7 +38,6 @@ 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/CopyButton.tsx b/src/components/CopyButton.tsx index 84c9e08..c79ea2c 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -4,23 +4,15 @@ import { useTranslation } from 'react-i18next'; export function CopyButton({ text }: { text: string }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); - - async function copyText() { - try { - if (!navigator.clipboard?.writeText) return; - await navigator.clipboard.writeText(text); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } - } - return (
- - {open && ( - - )} - - ); -} diff --git a/src/components/ProfileSwitcher.tsx b/src/components/ProfileSwitcher.tsx new file mode 100644 index 0000000..e22ab21 --- /dev/null +++ b/src/components/ProfileSwitcher.tsx @@ -0,0 +1,327 @@ +import { useState, useRef, useEffect } from 'react'; +import { + useProfilesStore, + PROFILE_COLOR_CLASSES, + DEFAULT_PROFILE_ID, + pickNextColor, +} from '@/store/profilesStore'; +import { useChain } from '@/context/ChainContext'; + +// --------------------------------------------------------------------------- +// Colored avatar dot (matches NetworkChip visual language) +// --------------------------------------------------------------------------- + +function ProfileDot({ colorTag, size = 'sm' }: { colorTag: string; size?: 'sm' | 'md' }) { + const classes = PROFILE_COLOR_CLASSES[colorTag] ?? PROFILE_COLOR_CLASSES['cyan']; + const sizeClass = size === 'md' ? 'h-2 w-2' : 'h-1.5 w-1.5'; + return ; +} + +// --------------------------------------------------------------------------- +// Delete confirmation mini-dialog (inline, no portal needed) +// --------------------------------------------------------------------------- + +function DeleteConfirm({ + label, + onConfirm, + onCancel, +}: { + label: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+

Delete "{label}"?

+

+ Activity for this profile is preserved. +

+
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 e030bba..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(), }); @@ -1237,7 +1247,7 @@ export function StellarReceive() { } }, [notifications]); - if (!isConnected && !stellarKeys) { + if (!isConnected) { return (
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/components/StellarWalletPicker.tsx b/src/components/StellarWalletPicker.tsx index 41ec80c..8a182c3 100644 --- a/src/components/StellarWalletPicker.tsx +++ b/src/components/StellarWalletPicker.tsx @@ -19,7 +19,6 @@ import { useState, useEffect } from 'react'; import { QRCodeSVG as QRCode } from 'qrcode.react'; import { WALLET_IDS, WALLET_META, type WalletId } from '@/wallets/stellar'; import type { StellarWalletState } from '@/hooks/useStellarWallet'; -import { PasskeyUnsupportedCard } from '@/components/PasskeyUnsupportedCard'; interface Props { state: StellarWalletState; @@ -32,14 +31,12 @@ export function StellarWalletPicker({ state }: Props) { connect, status, error, - errorCode, detecting, available, setPreconnectedWallet, } = state; const [pending, setPending] = useState(null); - const [lastAttemptedId, setLastAttemptedId] = useState(null); const [wcUri, setWcUri] = useState(null); const [wcConnecting, setWcConnecting] = useState(false); @@ -88,7 +85,6 @@ export function StellarWalletPicker({ state }: Props) { async function handleSelect(id: WalletId) { if (pending) return; setPending(id); - setLastAttemptedId(id); // Special handling for WalletConnect to capture URI if (id === 'walletconnect') { @@ -265,19 +261,14 @@ export function StellarWalletPicker({ state }: Props) { {/* Error message */} - {error && - status === 'error' && - (lastAttemptedId === 'passkey' && errorCode === 'NOT_AVAILABLE' ? ( - - ) : ( -

{error}

- ))} + {error && status === 'error' && ( +

{error}

+ )} {/* Footer note */}

Albedo, LOBSTR, and WalletConnect work in any browser — no extension needed. Freighter and - xBull require their browser extension to be installed. Passkey needs no extension either — - it signs with your device's built-in authenticator or a hardware security key. + xBull require their browser extension to be installed.

diff --git a/src/context/StealthKeysContext.tsx b/src/context/StealthKeysContext.tsx index 32eedba..b91810a 100644 --- a/src/context/StealthKeysContext.tsx +++ b/src/context/StealthKeysContext.tsx @@ -1,12 +1,37 @@ -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'; import { hexToBytes, type RecoveryKitData } from '@/lib/stellar/recoveryKit'; import { importLabels } from '@/lib/stealthLabels'; +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, + }; +} + interface StealthKeysContextValue { evmKeys: EVMStealthKeys | null; evmMetaAddress: string | null; @@ -16,12 +41,14 @@ interface StealthKeysContextValue { solanaMetaAddress: string | null; ckbKeys: CKBStealthKeys | null; ckbMetaAddress: string | null; + isRecoveryMode: boolean; isReadOnly: boolean; setIsRecoveryMode: (active: boolean) => void; setIsReadOnly: (readOnly: boolean) => void; restoreFromRecoveryKit: (kitData: RecoveryKitData) => void; exitRecoveryMode: () => void; + setEvmKeys: (keys: EVMStealthKeys) => void; setEvmMetaAddress: (metaAddress: string) => void; setStellarKeys: (keys: StellarStealthKeys) => void; @@ -30,16 +57,17 @@ interface StealthKeysContextValue { setSolanaMetaAddress: (metaAddress: string) => void; setCkbKeys: (keys: CKBStealthKeys) => void; setCkbMetaAddress: (metaAddress: string) => void; + clearEvm: () => void; clearStellar: () => void; clearSolana: () => void; clearCkb: () => void; + + 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. function StealthKeysCleaner({ clearStellar }: { clearStellar: () => void }) { const stellar = useContext(StellarWalletContext); const subscribeToDisconnect = stellar?.subscribeToDisconnect; @@ -51,170 +79,255 @@ function StealthKeysCleaner({ clearStellar }: { clearStellar: () => void }) { } 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 [keysByProfile, setKeysByProfile] = useState>( + () => new Map([[DEFAULT_PROFILE_ID, emptySlot()]]), + ); + const [isRecoveryMode, setIsRecoveryMode] = useState(false); const [isReadOnly, setIsReadOnly] = useState(false); + const activeProfileId = useProfilesStore((s) => s.activeProfileId); + + useEffect(() => { + setKeysByProfile((prev) => { + if (prev.has(activeProfileId)) return prev; + const next = new Map(prev); + next.set(activeProfileId, emptySlot()); + return next; + }); + }, [activeProfileId]); + + 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; + }); + }, []); + + const activeSlot = useMemo(() => getSlot(activeProfileId), [getSlot, activeProfileId]); + + 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], + ); + const clearEvm = useCallback(() => { if (isRecoveryMode) return; - setEvmKeys(null); - setEvmMetaAddress(null); - }, [isRecoveryMode]); + patchSlot(activeProfileId, { evmKeys: null, evmMetaAddress: null }); + }, [isRecoveryMode, patchSlot, activeProfileId]); const clearStellar = useCallback(() => { if (isRecoveryMode) return; - setStellarKeys(null); - setStellarMetaAddress(null); - }, [isRecoveryMode]); + patchSlot(activeProfileId, { stellarKeys: null, stellarMetaAddress: null }); + }, [isRecoveryMode, patchSlot, activeProfileId]); const clearSolana = useCallback(() => { if (isRecoveryMode) return; - setSolanaKeys(null); - setSolanaMetaAddress(null); - }, [isRecoveryMode]); + patchSlot(activeProfileId, { solanaKeys: null, solanaMetaAddress: null }); + }, [isRecoveryMode, patchSlot, activeProfileId]); const clearCkb = useCallback(() => { if (isRecoveryMode) return; - setCkbKeys(null); - setCkbMetaAddress(null); - }, [isRecoveryMode]); + patchSlot(activeProfileId, { ckbKeys: null, ckbMetaAddress: null }); + }, [isRecoveryMode, patchSlot, activeProfileId]); const exitRecoveryMode = useCallback(() => { setIsRecoveryMode(false); setIsReadOnly(false); - setEvmKeys(null); - setEvmMetaAddress(null); - setStellarKeys(null); - setStellarMetaAddress(null); - setSolanaKeys(null); - setSolanaMetaAddress(null); - setCkbKeys(null); - setCkbMetaAddress(null); - }, []); + patchSlot(activeProfileId, emptySlot()); + }, [patchSlot, activeProfileId]); + + const restoreFromRecoveryKit = useCallback( + (kitData: RecoveryKitData) => { + setIsRecoveryMode(true); + setIsReadOnly(!kitData.spendingScalarHex); + + const viewingKey = kitData.viewingScalarHex + ? hexToBytes(kitData.viewingScalarHex) + : new Uint8Array(32); + const spendingPubKey = kitData.spendingPubKeyHex + ? hexToBytes(kitData.spendingPubKeyHex) + : new Uint8Array(32); + const viewingPubKey = kitData.viewingPubKeyHex + ? hexToBytes(kitData.viewingPubKeyHex) + : new Uint8Array(32); + const spendingScalar = kitData.spendingScalarHex + ? BigInt( + kitData.spendingScalarHex.startsWith('0x') + ? kitData.spendingScalarHex + : `0x${kitData.spendingScalarHex}`, + ) + : undefined; - const restoreFromRecoveryKit = useCallback((kitData: RecoveryKitData) => { - setIsRecoveryMode(true); - setIsReadOnly(!kitData.spendingScalarHex); - - const viewingKey = kitData.viewingScalarHex - ? hexToBytes(kitData.viewingScalarHex) - : new Uint8Array(32); - const spendingPubKey = kitData.spendingPubKeyHex - ? hexToBytes(kitData.spendingPubKeyHex) - : new Uint8Array(32); - const viewingPubKey = kitData.viewingPubKeyHex - ? hexToBytes(kitData.viewingPubKeyHex) - : new Uint8Array(32); - const spendingScalar = kitData.spendingScalarHex - ? BigInt( - kitData.spendingScalarHex.startsWith('0x') - ? kitData.spendingScalarHex - : `0x${kitData.spendingScalarHex}`, - ) - : undefined; - - const chain = kitData.chain?.toLowerCase() || ''; - - if (chain === 'stellar' || kitData.metaAddress?.startsWith('st:xlm:')) { - const keys: any = { - viewingKey, - viewingScalar: viewingKey, - spendingKey: spendingPubKey, - spendingPubKey, - viewingPubKey, - spendingScalar, - }; - setStellarKeys(keys as StellarStealthKeys); - setStellarMetaAddress(kitData.metaAddress); - } else if (chain === 'horizen' || kitData.metaAddress?.startsWith('st:eth:')) { - const keys: any = { - viewingKey: kitData.viewingScalarHex, - spendingPubKey: kitData.spendingPubKeyHex || '', - viewingPubKey: kitData.viewingPubKeyHex || '', - spendingScalar: kitData.spendingScalarHex || '', - }; - setEvmKeys(keys as EVMStealthKeys); - setEvmMetaAddress(kitData.metaAddress); - } else if (chain === 'solana' || kitData.metaAddress?.startsWith('st:sol:')) { - const keys: any = { - viewingKey, - viewingScalar: viewingKey, - spendingKey: spendingPubKey, - spendingPubKey, - viewingPubKey, - spendingScalar, - }; - setSolanaKeys(keys as SolanaStealthKeys); - setSolanaMetaAddress(kitData.metaAddress); - } else if (chain === 'ckb' || kitData.metaAddress?.startsWith('st:ckb:')) { - const keys: any = { - viewingKey: kitData.viewingScalarHex, - spendingPubKey: kitData.spendingPubKeyHex || '', - viewingPubKey: kitData.viewingPubKeyHex || '', - spendingScalar: kitData.spendingScalarHex || '', - }; - setCkbKeys(keys as CKBStealthKeys); - setCkbMetaAddress(kitData.metaAddress); - } else { - const keys: any = { - viewingKey, - viewingScalar: viewingKey, - spendingKey: spendingPubKey, - spendingPubKey, - viewingPubKey, - spendingScalar, - }; - setStellarKeys(keys as StellarStealthKeys); - setStellarMetaAddress(kitData.metaAddress); - } - - if (kitData.labels) { - try { - importLabels(kitData.metaAddress, JSON.stringify(kitData.labels), true); - } catch { - // Labels restore optional + const chain = kitData.chain?.toLowerCase() || ''; + + if (chain === 'stellar' || kitData.metaAddress?.startsWith('st:xlm:')) { + const keys: any = { + viewingKey, + viewingScalar: viewingKey, + spendingKey: spendingPubKey, + spendingPubKey, + viewingPubKey, + spendingScalar, + }; + patchSlot(activeProfileId, { + stellarKeys: keys as StellarStealthKeys, + stellarMetaAddress: kitData.metaAddress, + }); + } else if (chain === 'horizen' || kitData.metaAddress?.startsWith('st:eth:')) { + const keys: any = { + viewingKey: kitData.viewingScalarHex, + spendingPubKey: kitData.spendingPubKeyHex || '', + viewingPubKey: kitData.viewingPubKeyHex || '', + spendingScalar: kitData.spendingScalarHex || '', + }; + patchSlot(activeProfileId, { + evmKeys: keys as EVMStealthKeys, + evmMetaAddress: kitData.metaAddress, + }); + } else if (chain === 'solana' || kitData.metaAddress?.startsWith('st:sol:')) { + const keys: any = { + viewingKey, + viewingScalar: viewingKey, + spendingKey: spendingPubKey, + spendingPubKey, + viewingPubKey, + spendingScalar, + }; + patchSlot(activeProfileId, { + solanaKeys: keys as SolanaStealthKeys, + solanaMetaAddress: kitData.metaAddress, + }); + } else if (chain === 'ckb' || kitData.metaAddress?.startsWith('st:ckb:')) { + const keys: any = { + viewingKey: kitData.viewingScalarHex, + spendingPubKey: kitData.spendingPubKeyHex || '', + viewingPubKey: kitData.viewingPubKeyHex || '', + spendingScalar: kitData.spendingScalarHex || '', + }; + patchSlot(activeProfileId, { + ckbKeys: keys as CKBStealthKeys, + ckbMetaAddress: kitData.metaAddress, + }); + } else { + const keys: any = { + viewingKey, + viewingScalar: viewingKey, + spendingKey: spendingPubKey, + spendingPubKey, + viewingPubKey, + spendingScalar, + }; + patchSlot(activeProfileId, { + stellarKeys: keys as StellarStealthKeys, + stellarMetaAddress: kitData.metaAddress, + }); } - } - }, []); + + if (kitData.labels) { + try { + importLabels(kitData.metaAddress, JSON.stringify(kitData.labels), true); + } catch { + // Labels restore optional + } + } + }, + [patchSlot, activeProfileId], + ); + + const getKeysForProfile = useCallback((id: string) => getSlot(id), [getSlot]); + + const value = useMemo( + () => ({ + evmKeys: activeSlot.evmKeys, + evmMetaAddress: activeSlot.evmMetaAddress, + stellarKeys: activeSlot.stellarKeys, + stellarMetaAddress: activeSlot.stellarMetaAddress, + solanaKeys: activeSlot.solanaKeys, + solanaMetaAddress: activeSlot.solanaMetaAddress, + ckbKeys: activeSlot.ckbKeys, + ckbMetaAddress: activeSlot.ckbMetaAddress, + isRecoveryMode, + isReadOnly, + setIsRecoveryMode, + setIsReadOnly, + restoreFromRecoveryKit, + exitRecoveryMode, + setEvmKeys, + setEvmMetaAddress, + setStellarKeys, + setStellarMetaAddress, + setSolanaKeys, + setSolanaMetaAddress, + setCkbKeys, + setCkbMetaAddress, + clearEvm, + clearStellar, + clearSolana, + clearCkb, + getKeysForProfile, + }), + [ + activeSlot, + isRecoveryMode, + isReadOnly, + restoreFromRecoveryKit, + exitRecoveryMode, + setEvmKeys, + setEvmMetaAddress, + setStellarKeys, + setStellarMetaAddress, + setSolanaKeys, + setSolanaMetaAddress, + setCkbKeys, + setCkbMetaAddress, + clearEvm, + clearStellar, + clearSolana, + clearCkb, + getKeysForProfile, + ], + ); return ( - + {children} diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000..53f5855 --- /dev/null +++ b/src/hooks/useFocusTrap.ts @@ -0,0 +1,120 @@ +import { useEffect, useRef } from 'react'; + +const FOCUSABLE_SELECTORS = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + 'details > summary', +].join(', '); + +function getFocusableElements(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter( + (el) => !el.closest('[hidden]') && getComputedStyle(el).display !== 'none', + ); +} + +interface UseFocusTrapOptions { + /** Whether the trap is currently active. Trap is installed only when true. */ + isActive: boolean; + /** + * Ref to the container element whose focusable descendants are trapped. + * Must be set before isActive becomes true. + */ + containerRef: React.RefObject; + /** + * Optional ref to the element that should receive initial focus. + * When omitted, the first focusable element inside containerRef is used. + */ + initialFocusRef?: React.RefObject; + /** + * Optional ref to the element that triggered the dialog. + * Focus is returned here when the trap is deactivated. + * When omitted, focus returns to document.activeElement at activation time. + */ + triggerRef?: React.RefObject; +} + +/** + * Traps keyboard focus within a container element while a dialog is open. + * + * - Tab / Shift+Tab cycle stays inside the container. + * - On activation, focuses initialFocusRef (or first focusable element). + * - On deactivation, returns focus to triggerRef (or the element that had + * focus when the trap was first activated). + */ +export function useFocusTrap({ + isActive, + containerRef, + initialFocusRef, + triggerRef, +}: UseFocusTrapOptions): void { + // Capture the element that had focus *before* the trap activates. + const previousFocusRef = useRef(null); + + useEffect(() => { + if (!isActive) return; + + // Capture the currently focused element so we can restore it on close. + previousFocusRef.current = document.activeElement; + + const container = containerRef.current; + if (!container) return; + + // Move focus to the requested initial element, or the first focusable one. + const setInitialFocus = () => { + if (initialFocusRef?.current) { + initialFocusRef.current.focus(); + } else { + const focusable = getFocusableElements(container); + if (focusable.length > 0) focusable[0].focus(); + } + }; + + // Small rAF delay so the container is fully painted before focusing. + const rafId = requestAnimationFrame(setInitialFocus); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + const focusable = getFocusableElements(container); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (e.shiftKey) { + // Shift+Tab: if focus is on (or before) the first element, wrap to last. + if (active === first || !container.contains(active)) { + e.preventDefault(); + last.focus(); + } + } else { + // Tab: if focus is on (or after) the last element, wrap to first. + if (active === last || !container.contains(active)) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + cancelAnimationFrame(rafId); + document.removeEventListener('keydown', handleKeyDown); + + // Return focus to the trigger element, or to whatever had focus before. + const returnTarget = triggerRef?.current ?? (previousFocusRef.current as HTMLElement | null); + if (returnTarget && typeof returnTarget.focus === 'function') { + returnTarget.focus(); + } + }; + }, [isActive, containerRef, initialFocusRef, triggerRef]); +} diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index e6954ec..ff21407 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -12,14 +12,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - getAdapter, - WALLET_IDS, - WalletError, - type StellarWallet, - type WalletId, - type WalletErrorCode, -} from '@/wallets/stellar'; +import { getAdapter, WALLET_IDS, type StellarWallet, type WalletId } from '@/wallets/stellar'; const STORAGE_KEY_WALLET = 'wraith:stellar:wallet'; const STORAGE_KEY_PUBKEY = 'wraith:stellar:pubkey'; @@ -35,8 +28,6 @@ export interface StellarWalletState { network: string | null; status: WalletStatus; error: string | null; - /** Machine-readable code for the last connect error, if any. */ - errorCode: WalletErrorCode | null; /** True while availability checks are running on mount. */ detecting: boolean; /** Availability map populated after detection. */ @@ -68,7 +59,6 @@ export function useStellarWallet(): StellarWalletState { const [network, setNetwork] = useState(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); - const [errorCode, setErrorCode] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [detecting, setDetecting] = useState(true); const [available, setAvailable] = useState>>({}); @@ -129,7 +119,6 @@ export function useStellarWallet(): StellarWalletState { connectingRef.current = true; setStatus('connecting'); setError(null); - setErrorCode(null); try { const adapter = getAdapter(id); @@ -149,7 +138,6 @@ export function useStellarWallet(): StellarWalletState { } catch (err) { setStatus('error'); setError(err instanceof Error ? err.message : String(err)); - setErrorCode(err instanceof WalletError ? err.code : null); } finally { connectingRef.current = false; } @@ -169,7 +157,6 @@ export function useStellarWallet(): StellarWalletState { setNetwork(null); setStatus('idle'); setError(null); - setErrorCode(null); localStorage.removeItem(STORAGE_KEY_WALLET); localStorage.removeItem(STORAGE_KEY_PUBKEY); localStorage.removeItem(STORAGE_KEY_NETWORK); @@ -211,7 +198,6 @@ export function useStellarWallet(): StellarWalletState { network, status, error, - errorCode, detecting, available, pickerOpen, diff --git a/src/lib/idleLock.ts b/src/lib/idleLock.ts deleted file mode 100644 index fdb4185..0000000 --- a/src/lib/idleLock.ts +++ /dev/null @@ -1,120 +0,0 @@ -export const APP_IDLE_TIMEOUT_MS = 5 * 60 * 1000; - -const ACTIVITY_EVENTS: Array = [ - 'pointerdown', - 'keydown', - 'touchstart', - 'scroll', -]; - -export interface IdleLockOptions { - timeoutMs: number; - onIdle: () => void; - lockOnBlur?: boolean; - lockOnVisibilityChange?: boolean; -} - -/** - * Shared inactivity timer used by both the encrypted vault and the app session. - * It checks elapsed wall-clock time when a mobile tab becomes visible again so - * background timer throttling cannot leave a stale session unlocked. - */ -export class IdleLock { - private timer: ReturnType | null = null; - private lastActivityAt = 0; - private started = false; - - private readonly handleActivity = () => this.touch(); - private readonly handleBlur = () => { - if (this.options.lockOnBlur) this.fire(); - }; - private readonly handleVisibilityChange = () => { - if (document.visibilityState === 'hidden' && this.options.lockOnVisibilityChange) { - this.fire(); - return; - } - - if (document.visibilityState === 'visible') this.checkElapsedTime(); - }; - - constructor(private readonly options: IdleLockOptions) {} - - start() { - if (typeof window === 'undefined' || this.options.timeoutMs <= 0) return; - - this.stop(); - this.started = true; - this.lastActivityAt = Date.now(); - for (const eventName of ACTIVITY_EVENTS) { - window.addEventListener(eventName, this.handleActivity, { passive: true }); - } - window.addEventListener('blur', this.handleBlur); - document.addEventListener('visibilitychange', this.handleVisibilityChange); - this.schedule(); - } - - stop() { - this.clearTimer(); - if (!this.started || typeof window === 'undefined') return; - - for (const eventName of ACTIVITY_EVENTS) { - window.removeEventListener(eventName, this.handleActivity); - } - window.removeEventListener('blur', this.handleBlur); - document.removeEventListener('visibilitychange', this.handleVisibilityChange); - this.started = false; - } - - touch() { - if (!this.started) return; - this.lastActivityAt = Date.now(); - this.schedule(); - } - - private checkElapsedTime() { - if (!this.started) return; - if (Date.now() - this.lastActivityAt >= this.options.timeoutMs) { - this.fire(); - } else { - this.schedule(); - } - } - - private schedule() { - this.clearTimer(); - if (!this.started) return; - - const remaining = Math.max(0, this.options.timeoutMs - (Date.now() - this.lastActivityAt)); - this.timer = globalThis.setTimeout(() => this.checkElapsedTime(), remaining); - } - - private fire() { - if (!this.started) return; - this.stop(); - this.options.onIdle(); - } - - private clearTimer() { - if (this.timer === null) return; - globalThis.clearTimeout(this.timer); - this.timer = null; - } -} - -export function isPasskeySupported() { - return typeof window !== 'undefined' && 'PublicKeyCredential' in window; -} - -export async function authenticateWithPasskey(): Promise { - if (!isPasskeySupported()) throw new Error('Passkeys are not supported on this device.'); - - const credential = await navigator.credentials.get({ - publicKey: { - challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)), - timeout: 60_000, - userVerification: 'required', - }, - }); - - if (!credential) throw new Error('No passkey is available for Wraith.'); -} diff --git a/src/lib/portfolio.bench.test.ts b/src/lib/portfolio.bench.test.ts index 5aa0661..9c6ddcc 100644 --- a/src/lib/portfolio.bench.test.ts +++ b/src/lib/portfolio.bench.test.ts @@ -40,6 +40,7 @@ function generateEntries(count: number): ActivityEntry[] { amount: String(((i % 500) + 1) * 0.5), token: TOKENS[i % TOKENS.length], recipient: RECIPIENTS[i % RECIPIENTS.length], + profileId: 'default', timestamp: NOW - (i / count) * NINETY_DAYS, // spread evenly over 90 days })); } diff --git a/src/lib/portfolio.test.ts b/src/lib/portfolio.test.ts index 2d4e9db..271c7f1 100644 --- a/src/lib/portfolio.test.ts +++ b/src/lib/portfolio.test.ts @@ -26,6 +26,7 @@ function makeEntry(overrides: Partial): ActivityEntry { amount: '10', token: 'XLM', recipient: 'ADDR_A', + profileId: 'default', timestamp: NOW, ...overrides, }; diff --git a/src/lib/privacy-posture.test.ts b/src/lib/privacy-posture.test.ts deleted file mode 100644 index a1a5b33..0000000 --- a/src/lib/privacy-posture.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getPrivacyPosture, getRpcHost, type RpcRoute } from './privacy-posture'; - -const defaultRoute: RpcRoute = { - chain: 'stellar', - url: 'https://soroban-testnet.stellar.org', - defaultUrl: 'https://soroban-testnet.stellar.org', -}; - -const privateRoute: RpcRoute = { - chain: 'stellar', - url: 'https://rpc.example.internal', - defaultUrl: 'https://soroban-testnet.stellar.org', -}; - -describe('getPrivacyPosture', () => { - it('is strict when telemetry is off and all relevant RPC routes are non-default', () => { - expect(getPrivacyPosture(false, [privateRoute])).toBe('strict'); - }); - - it('is relaxed when telemetry is on with a non-default RPC', () => { - expect(getPrivacyPosture(true, [privateRoute])).toBe('relaxed'); - }); - - it('is relaxed when telemetry is off with a default RPC', () => { - expect(getPrivacyPosture(false, [defaultRoute])).toBe('relaxed'); - }); - - it('is relaxed when telemetry is on with a default RPC', () => { - expect(getPrivacyPosture(true, [defaultRoute])).toBe('relaxed'); - }); - - it('uses only the active chain when deriving posture', () => { - const defaultSolanaRoute: RpcRoute = { - chain: 'solana', - url: 'https://api.devnet.solana.com', - defaultUrl: 'https://api.devnet.solana.com', - }; - - expect(getPrivacyPosture(false, [privateRoute, defaultSolanaRoute], 'stellar')).toBe('strict'); - expect(getPrivacyPosture(false, [privateRoute, defaultSolanaRoute], 'solana')).toBe('relaxed'); - }); - - it('treats RPC query-string differences as non-default routing', () => { - const proxiedRoute: RpcRoute = { - chain: 'stellar', - url: 'https://rpc.example.test/http?upstream=private', - defaultUrl: 'https://rpc.example.test/http?upstream=public', - }; - - expect(getPrivacyPosture(false, [proxiedRoute], 'stellar')).toBe('strict'); - }); - - it('is relaxed when the active chain has no configured routes', () => { - expect(getPrivacyPosture(false, [privateRoute], 'ckb')).toBe('relaxed'); - }); -}); - -describe('getRpcHost', () => { - it('extracts a copyable host without leaking path details', () => { - expect(getRpcHost('https://testnet.ckb.dev/rpc')).toBe('testnet.ckb.dev'); - }); -}); diff --git a/src/lib/privacy-posture.ts b/src/lib/privacy-posture.ts deleted file mode 100644 index dca4a04..0000000 --- a/src/lib/privacy-posture.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type PrivacyPosture = 'strict' | 'relaxed'; - -export interface RpcRoute { - chain: string; - label?: string; - url: string; - defaultUrl: string; -} - -function normalizeRpcUrl(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`; - } catch { - return url.replace(/\/$/, ''); - } -} - -export function getRpcHost(url: string): string { - try { - return new URL(url).host; - } catch { - return url; - } -} - -export function getPrivacyPosture( - telemetryEnabled: boolean, - routes: readonly RpcRoute[], - activeChain?: string, -): PrivacyPosture { - const relevantRoutes = activeChain - ? routes.filter((route) => route.chain === activeChain) - : routes; - const allRoutesAreNonDefault = - relevantRoutes.length > 0 && - relevantRoutes.every( - (route) => normalizeRpcUrl(route.url) !== normalizeRpcUrl(route.defaultUrl), - ); - - return !telemetryEnabled && allRoutesAreNonDefault ? 'strict' : 'relaxed'; -} 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/lib/stellar/names.ts b/src/lib/stellar/names.ts index 469a88b..d03aa7e 100644 --- a/src/lib/stellar/names.ts +++ b/src/lib/stellar/names.ts @@ -3,17 +3,14 @@ import { Account, Contract, nativeToScVal, - scValToNative, Address, xdr, } from '@stellar/stellar-sdk'; -import { Buffer } from 'buffer'; import { STELLAR_NETWORK } from '@/config'; -// Override for futurenet recordings or a new deployment without rebuilding this module. -export const NAMES_CONTRACT_ID = - import.meta.env.VITE_WRAITH_NAMES_CONTRACT_ID || - 'CD3Z7J2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; +// Wraith Names contract ID on Stellar Testnet +// TODO: Replace with actual contract ID from deployment +export const NAMES_CONTRACT_ID = 'CD3Z7J2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; export interface NameMetadata { avatar_url?: string; @@ -48,236 +45,6 @@ export interface MetadataParams { metadata: NameMetadata; } -export interface NameAuction { - name: string; - commitEnd: number; - revealEnd: number; - highestBidder: string | null; - highestAmount: bigint; - settled: boolean; -} - -export interface NameAuctionConfig { - reservePrice: bigint; - minBidIncrement: bigint; - commitSecs: number; - revealSecs: number; -} - -export interface CommitBidParams { - name: string; - commitmentHex: string; - deposit: bigint; -} - -export interface RevealBidParams { - name: string; - amount: bigint; - saltHex: string; -} - -export const MIN_AUCTION_BID_INCREMENT = 1_000_000n; // 0.1 XLM in stroops - -async function getSourceAccount(fromAddress: string) { - const accountRes = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${fromAddress}`); - if (!accountRes.ok) throw new Error('Failed to load account'); - const accountData = (await accountRes.json()) as { sequence: string }; - return new Account(fromAddress, accountData.sequence); -} - -async function simulateRead(operation: xdr.Operation): Promise { - const { rpc } = await import('@stellar/stellar-sdk'); - const server = new rpc.Server(STELLAR_NETWORK.rpcUrl); - const tx = new TransactionBuilder( - new Account('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWH', '0'), - { fee: '100', networkPassphrase: STELLAR_NETWORK.networkPassphrase }, - ) - .addOperation(operation) - .setTimeout(30) - .build(); - const simulation = await server.simulateTransaction(tx); - if ('error' in simulation) throw new Error(simulation.error); - if (!simulation.result) throw new Error('Contract returned no result'); - return scValToNative(simulation.result.retval) as T; -} - -async function buildAuctionTransaction(fromAddress: string, operation: xdr.Operation) { - const { rpc } = await import('@stellar/stellar-sdk'); - const server = new rpc.Server(STELLAR_NETWORK.rpcUrl); - const sourceAccount = await getSourceAccount(fromAddress); - const tx = new TransactionBuilder(sourceAccount, { - fee: '100', - networkPassphrase: STELLAR_NETWORK.networkPassphrase, - }) - .addOperation(operation) - .setTimeout(30) - .build(); - const simulation = await server.simulateTransaction(tx); - if ('error' in simulation) throw new Error(simulation.error); - return rpc.assembleTransaction(tx, simulation).build().toXDR(); -} - -function bytesScVal(hex: string) { - const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; - if (!/^[0-9a-f]{64}$/i.test(normalized)) throw new Error('Expected a 32-byte hex value'); - return xdr.ScVal.scvBytes(Buffer.from(normalized, 'hex')); -} - -function normalizeAuction(value: Record | null): NameAuction | null { - if (!value) return null; - return { - name: String(value.name ?? ''), - commitEnd: Number(value.commit_end ?? 0), - revealEnd: Number(value.reveal_end ?? 0), - highestBidder: value.highest_bidder ? String(value.highest_bidder) : null, - highestAmount: BigInt(String(value.highest_amount ?? 0)), - settled: Boolean(value.settled), - }; -} - -export async function getNameAuction(name: string): Promise { - const contract = new Contract(NAMES_CONTRACT_ID); - const value = await simulateRead | null>( - contract.call('get_auction', nativeToScVal(name.trim().toLowerCase())), - ); - return normalizeAuction(value); -} - -export async function getNameAuctionConfig(): Promise { - const contract = new Contract(NAMES_CONTRACT_ID); - const value = await simulateRead | null>(contract.call('auction_config')); - if (!value) return null; - return { - reservePrice: BigInt(String(value.reserve_price ?? 0)), - minBidIncrement: BigInt( - String(value.min_increment ?? value.min_bid_increment ?? MIN_AUCTION_BID_INCREMENT), - ), - commitSecs: Number(value.commit_secs ?? 0), - revealSecs: Number(value.reveal_secs ?? 0), - }; -} - -export async function getActiveNameAuctions(names: string[]): Promise { - const contract = new Contract(NAMES_CONTRACT_ID); - try { - const values = await simulateRead>>( - contract.call('get_active_auctions'), - ); - if (Array.isArray(values)) { - return values - .map((value) => normalizeAuction(value)) - .filter((auction): auction is NameAuction => auction !== null); - } - } catch { - // Current sealed-bid deployments expose lookup-by-name only. Fall back to - // the locally tracked names while remaining compatible with enumerable ABIs. - } - - const uniqueNames = [...new Set(names.map((name) => name.trim().toLowerCase()).filter(Boolean))]; - const auctions = await Promise.all( - uniqueNames.map(async (name) => { - try { - return await getNameAuction(name); - } catch { - return null; - } - }), - ); - return auctions.filter((auction): auction is NameAuction => auction !== null); -} - -export async function computeNameBidCommitment( - bidder: string, - name: string, - amount: bigint, - saltHex: string, -): Promise { - const contract = new Contract(NAMES_CONTRACT_ID); - const commitment = await simulateRead( - contract.call( - 'compute_commitment', - nativeToScVal(name.trim().toLowerCase()), - new Address(bidder).toScVal(), - nativeToScVal(amount, { type: 'i128' }), - bytesScVal(saltHex), - ), - ); - return Buffer.from(commitment).toString('hex'); -} - -export async function buildCommitNameBidTransaction( - bidder: string, - params: CommitBidParams, -): Promise { - const contract = new Contract(NAMES_CONTRACT_ID); - return buildAuctionTransaction( - bidder, - contract.call( - 'commit_bid', - new Address(bidder).toScVal(), - nativeToScVal(params.name.trim().toLowerCase()), - bytesScVal(params.commitmentHex), - nativeToScVal(params.deposit, { type: 'i128' }), - ), - ); -} - -export async function buildRevealNameBidTransaction( - bidder: string, - params: RevealBidParams, -): Promise { - const contract = new Contract(NAMES_CONTRACT_ID); - return buildAuctionTransaction( - bidder, - contract.call( - 'reveal_bid', - new Address(bidder).toScVal(), - nativeToScVal(params.name.trim().toLowerCase()), - nativeToScVal(params.amount, { type: 'i128' }), - bytesScVal(params.saltHex), - ), - ); -} - -export async function buildSettleNameAuctionTransaction(fromAddress: string, name: string) { - const contract = new Contract(NAMES_CONTRACT_ID); - return buildAuctionTransaction( - fromAddress, - contract.call('settle_auction', nativeToScVal(name.trim().toLowerCase())), - ); -} - -export async function buildRefundNameBidTransaction(bidder: string, name: string) { - const contract = new Contract(NAMES_CONTRACT_ID); - return buildAuctionTransaction( - bidder, - contract.call( - 'withdraw_bid', - new Address(bidder).toScVal(), - nativeToScVal(name.trim().toLowerCase()), - ), - ); -} - -export async function buildClaimAuctionNameTransaction( - winner: string, - name: string, - stealthMetaAddress: Uint8Array, -) { - if (stealthMetaAddress.length !== 64) - throw new Error('A 64-byte stealth meta-address is required'); - const contract = new Contract(NAMES_CONTRACT_ID); - return buildAuctionTransaction( - winner, - contract.call( - 'claim_name', - new Address(winner).toScVal(), - nativeToScVal(name.trim().toLowerCase()), - xdr.ScVal.scvBytes(Buffer.from(stealthMetaAddress)), - ), - ); -} - /** * Check if a name is available */ diff --git a/src/lib/stellar/passkey.test.ts b/src/lib/stellar/passkey.test.ts deleted file mode 100644 index c97046e..0000000 --- a/src/lib/stellar/passkey.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parsePrfExtensionResult, - bufferToBase64Url, - base64UrlToBuffer, - isSessionValid, - SESSION_KEY_TTL_MS, - SESSION_KEY_MAX_SIGNATURES, - PasskeyError, - type PasskeySession, -} from './passkey'; - -// ── parsePrfExtensionResult ───────────────────────────────────────────────── - -describe('parsePrfExtensionResult', () => { - it('extracts the PRF secret when present', () => { - const secretBytes = new Uint8Array(32).fill(7); - const result = parsePrfExtensionResult({ - prf: { enabled: true, results: { first: secretBytes } }, - } as AuthenticationExtensionsClientOutputs); - - expect(result).toEqual(secretBytes); - }); - - it('accepts an ArrayBuffer for the first result', () => { - const secretBytes = new Uint8Array(32).fill(3); - const result = parsePrfExtensionResult({ - prf: { enabled: true, results: { first: secretBytes.buffer } }, - } as AuthenticationExtensionsClientOutputs); - - expect(result).toEqual(secretBytes); - }); - - it('throws PRF_UNSUPPORTED when the prf member is absent', () => { - expect(() => parsePrfExtensionResult({} as AuthenticationExtensionsClientOutputs)).toThrow( - PasskeyError, - ); - try { - parsePrfExtensionResult({} as AuthenticationExtensionsClientOutputs); - } catch (err) { - expect((err as PasskeyError).code).toBe('PRF_UNSUPPORTED'); - } - }); - - it('throws PRF_UNSUPPORTED when the extension results are null', () => { - expect(() => parsePrfExtensionResult(null)).toThrow(PasskeyError); - }); - - it('throws PRF_UNSUPPORTED when enabled is explicitly false', () => { - expect(() => - parsePrfExtensionResult({ - prf: { enabled: false }, - } as AuthenticationExtensionsClientOutputs), - ).toThrow(/unavailable/); - }); - - it('throws PRF_UNSUPPORTED when results.first is missing', () => { - expect(() => - parsePrfExtensionResult({ - prf: { enabled: true, results: {} }, - } as AuthenticationExtensionsClientOutputs), - ).toThrow(/did not evaluate/); - }); - - it('throws PRF_UNSUPPORTED when the secret is empty', () => { - expect(() => - parsePrfExtensionResult({ - prf: { enabled: true, results: { first: new Uint8Array(0) } }, - } as AuthenticationExtensionsClientOutputs), - ).toThrow(/empty secret/); - }); -}); - -// ── base64url helpers ─────────────────────────────────────────────────────── - -describe('base64url helpers', () => { - it('round-trips arbitrary byte sequences', () => { - const bytes = new Uint8Array([0, 1, 2, 253, 254, 255, 16, 32, 64, 128]); - expect(base64UrlToBuffer(bufferToBase64Url(bytes))).toEqual(bytes); - }); - - it('produces URL-safe output with no padding', () => { - const bytes = new Uint8Array(33).fill(255); - const encoded = bufferToBase64Url(bytes); - expect(encoded).not.toMatch(/[+/=]/); - }); -}); - -// ── session ceiling ───────────────────────────────────────────────────────── - -describe('isSessionValid', () => { - function makeSession(overrides: Partial = {}): PasskeySession { - return { - createdAt: Date.now(), - signatureCount: 0, - ...overrides, - }; - } - - it('returns false for null', () => { - expect(isSessionValid(null)).toBe(false); - }); - - it('returns true for a fresh session under both ceilings', () => { - expect(isSessionValid(makeSession())).toBe(true); - }); - - it('returns false once the TTL has elapsed', () => { - const session = makeSession({ createdAt: Date.now() - SESSION_KEY_TTL_MS - 1 }); - expect(isSessionValid(session)).toBe(false); - }); - - it('returns false once the signature ceiling is reached', () => { - const session = makeSession({ signatureCount: SESSION_KEY_MAX_SIGNATURES }); - expect(isSessionValid(session)).toBe(false); - }); -}); diff --git a/src/lib/stellar/passkey.ts b/src/lib/stellar/passkey.ts deleted file mode 100644 index 54a6e02..0000000 --- a/src/lib/stellar/passkey.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * src/lib/stellar/passkey.ts - * - * Browser-side WebAuthn plumbing for the Passkey wallet mode. Everything - * here is pure Web Authentication API + PRF extension handling — it never - * talks to Horizon or Soroban. `PasskeyAdapter` uses the secret this module - * derives to seed a classic Ed25519 Stellar signing key (see the scope note - * at the top of PasskeyAdapter.ts for why it's classic rather than a - * Soroban smart account). - * - * The PRF extension (https://w3c.github.io/webauthn/#prf-extension) lets a - * passkey act as a deterministic key-derivation function: evaluating the same - * salt against the same credential always returns the same 32-byte secret, - * without ever exposing the authenticator's private key. That secret is what - * seeds the account's signing key. - */ - -const RP_SALT_LABEL = new TextEncoder().encode('wraith-protocol:stellar:passkey:v1'); - -export type PasskeyErrorCode = - | 'PRF_UNSUPPORTED' - | 'NO_CREDENTIAL' - | 'USER_REJECTED' - | 'CREATE_FAILED' - | 'GET_FAILED'; - -export class PasskeyError extends Error { - constructor( - message: string, - public readonly code: PasskeyErrorCode, - ) { - super(message); - this.name = 'PasskeyError'; - } -} - -// ─── base64url helpers ────────────────────────────────────────────────────── - -export function bufferToBase64Url(buf: ArrayBuffer | Uint8Array): string { - const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -export function base64UrlToBuffer(value: string): Uint8Array { - const padded = value.replace(/-/g, '+').replace(/_/g, '/'); - const padLength = (4 - (padded.length % 4)) % 4; - const binary = atob(padded + '='.repeat(padLength)); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -// ─── Feature detection ────────────────────────────────────────────────────── - -/** - * Best-effort check for whether this browser can plausibly support the PRF - * extension. WebAuthn's `getClientCapabilities()` (when present) reports it - * directly; older browsers only reveal PRF support at credential-creation - * time, so this is a necessary-but-not-sufficient gate used to decide - * whether to attempt the first-run flow at all. - */ -export async function isPrfLikelySupported(): Promise { - if (typeof window === 'undefined' || !window.PublicKeyCredential) return false; - - const getClientCapabilities = ( - window.PublicKeyCredential as unknown as { - getClientCapabilities?: () => Promise>; - } - ).getClientCapabilities; - - if (typeof getClientCapabilities === 'function') { - try { - const capabilities = await getClientCapabilities(); - if ('extension:prf' in capabilities) return capabilities['extension:prf']; - } catch { - // Fall through to the permissive default below. - } - } - - // No capability API available — assume support and let credential - // creation/assertion surface a PRF_UNSUPPORTED error if it turns out wrong. - return true; -} - -// ─── PRF extension result parsing (pure — unit tested) ───────────────────── - -interface PrfExtensionOutput { - enabled?: boolean; - results?: { - first?: BufferSource; - second?: BufferSource; - }; -} - -// Deliberately not `extends AuthenticationExtensionsClientOutputs` — lib.dom's -// AuthenticationExtensionsPRFOutputs requires `results.first` whenever `results` -// is present, which is stricter than what we want to assert before validating it. -interface ExtensionResultsWithPrf { - prf?: PrfExtensionOutput; -} - -/** - * Extracts the 32-byte PRF secret from a WebAuthn credential's client - * extension results. Used for both `create()` and `get()` outputs — the - * shape of the `prf` extension member is identical in both. - * - * Throws `PasskeyError('PRF_UNSUPPORTED', …)` whenever the authenticator - * did not evaluate the PRF extension, so callers can render the no-PRF - * next-step card instead of failing silently. - */ -export function parsePrfExtensionResult( - extensionResults: AuthenticationExtensionsClientOutputs | null | undefined, -): Uint8Array { - const prf = (extensionResults as ExtensionResultsWithPrf | null | undefined)?.prf; - - if (!prf) { - throw new PasskeyError( - 'This authenticator did not return a PRF extension result.', - 'PRF_UNSUPPORTED', - ); - } - - if (prf.enabled === false) { - throw new PasskeyError( - 'This authenticator reported the PRF extension as unavailable.', - 'PRF_UNSUPPORTED', - ); - } - - const first = prf.results?.first; - if (!first) { - throw new PasskeyError( - 'The authenticator did not evaluate the PRF salt for this credential.', - 'PRF_UNSUPPORTED', - ); - } - - const secret = first instanceof Uint8Array ? first : new Uint8Array(first as ArrayBuffer); - if (secret.length === 0) { - throw new PasskeyError('The PRF extension returned an empty secret.', 'PRF_UNSUPPORTED'); - } - - return secret; -} - -// ─── Credential creation / assertion ──────────────────────────────────────── - -export interface CreatePasskeyResult { - credentialId: Uint8Array; - prfSecret: Uint8Array; -} - -/** - * Registers a new platform passkey with the PRF extension requested, and - * returns both the credential id (to persist for future sign-in) and the - * derived secret (to seed the smart-account signing key). - */ -export async function createPasskeyCredential(opts: { - rpId: string; - rpName: string; - userName: string; -}): Promise { - if (typeof navigator === 'undefined' || !navigator.credentials) { - throw new PasskeyError('WebAuthn is not available in this browser.', 'PRF_UNSUPPORTED'); - } - - const userId = crypto.getRandomValues(new Uint8Array(16)); - const challenge = crypto.getRandomValues(new Uint8Array(32)); - - let credential: Credential | null; - try { - credential = await navigator.credentials.create({ - publicKey: { - rp: { id: opts.rpId, name: opts.rpName }, - user: { id: userId, name: opts.userName, displayName: opts.userName }, - challenge, - pubKeyCredParams: [ - { type: 'public-key', alg: -7 }, // ES256 - { type: 'public-key', alg: -257 }, // RS256 fallback - ], - authenticatorSelection: { - residentKey: 'required', - userVerification: 'required', - }, - extensions: { - prf: { eval: { first: RP_SALT_LABEL } }, - } as AuthenticationExtensionsClientInputs, - }, - }); - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - throw new PasskeyError('Passkey creation was cancelled.', 'USER_REJECTED'); - } - throw new PasskeyError(`Passkey creation failed: ${String(err)}`, 'CREATE_FAILED'); - } - - if (!credential) { - throw new PasskeyError('Passkey creation returned no credential.', 'CREATE_FAILED'); - } - - const publicKeyCredential = credential as PublicKeyCredential; - const prfSecret = parsePrfExtensionResult(publicKeyCredential.getClientExtensionResults()); - - return { - credentialId: new Uint8Array(publicKeyCredential.rawId), - prfSecret, - }; -} - -/** - * Re-authenticates against a previously registered credential and - * re-derives the same PRF secret (deterministic for a given credential + - * salt), so the account's signing key never needs to be persisted. - */ -export async function getPasskeyAssertion(credentialId: Uint8Array): Promise { - if (typeof navigator === 'undefined' || !navigator.credentials) { - throw new PasskeyError('WebAuthn is not available in this browser.', 'PRF_UNSUPPORTED'); - } - - const challenge = crypto.getRandomValues(new Uint8Array(32)); - - let assertion: Credential | null; - try { - assertion = await navigator.credentials.get({ - publicKey: { - challenge, - allowCredentials: [{ id: credentialId as BufferSource, type: 'public-key' }], - userVerification: 'required', - extensions: { - prf: { eval: { first: RP_SALT_LABEL } }, - } as AuthenticationExtensionsClientInputs, - }, - }); - } catch (err) { - if (err instanceof DOMException && err.name === 'NotAllowedError') { - throw new PasskeyError('Passkey sign-in was cancelled.', 'USER_REJECTED'); - } - throw new PasskeyError(`Passkey sign-in failed: ${String(err)}`, 'GET_FAILED'); - } - - if (!assertion) { - throw new PasskeyError('No matching passkey was found.', 'NO_CREDENTIAL'); - } - - const publicKeyCredential = assertion as PublicKeyCredential; - return parsePrfExtensionResult(publicKeyCredential.getClientExtensionResults()); -} - -// ─── Session-key ceiling ───────────────────────────────────────────────────── - -/** - * The signing key derived from one PRF ceremony is kept resident in memory - * and reused for repeated signs within a browser session, instead of - * re-running the PRF ceremony (and its biometric prompt) on every send. - * Both ceilings are enforced together — whichever is hit first ends the - * session and the next sign re-derives the key from a fresh PRF assertion. - */ -export const SESSION_KEY_TTL_MS = 30 * 60 * 1000; // 30 minutes -export const SESSION_KEY_MAX_SIGNATURES = 20; - -export interface PasskeySession { - createdAt: number; - signatureCount: number; -} - -export function isSessionValid(session: PasskeySession | null): session is PasskeySession { - if (!session) return false; - const age = Date.now() - session.createdAt; - return age < SESSION_KEY_TTL_MS && session.signatureCount < SESSION_KEY_MAX_SIGNATURES; -} diff --git a/src/lib/stellar/recoveryKit.test.ts b/src/lib/stellar/recoveryKit.test.ts deleted file mode 100644 index 0832aca..0000000 --- a/src/lib/stellar/recoveryKit.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - validatePassphrase, - generateRecoveryFilename, - exportRecoveryKit, - importRecoveryKit, -} from './recoveryKit'; - -describe('recoveryKit', () => { - it('validates passphrase strength and blocks weak passphrases (< 8 chars)', () => { - const weak1 = validatePassphrase('short'); - expect(weak1.valid).toBe(false); - expect(weak1.score).toBe(0); - expect(weak1.message).toContain('at least 8 characters'); - - const weak2 = validatePassphrase('1234567'); - expect(weak2.valid).toBe(false); - - const valid = validatePassphrase('securePass123!'); - expect(valid.valid).toBe(true); - expect(valid.score).toBeGreaterThanOrEqual(3); - }); - - it('generates filename including meta-address prefix', () => { - const filename = generateRecoveryFilename('st:xlm:0x1234567890abcdef'); - expect(filename).toContain('wraith-recovery-kit-1234567890ab-'); - expect(filename.endsWith('.json')).toBe(true); - }); - - it('round-trips export and import encrypted kit', async () => { - const options = { - passphrase: 'super-secret-passphrase-123', - chain: 'stellar', - metaAddress: 'st:xlm:0xabcdef1234567890', - viewingScalarHex: '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20', - viewingPubKeyHex: '2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40', - spendingPubKeyHex: '4142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60', - spendingScalarHex: '6162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80', - labels: { - '0x123': { label: 'Test Payment', tags: ['vip'] }, - }, - }; - - const encryptedKit = await exportRecoveryKit(options); - expect(encryptedKit.version).toBe(1); - expect(encryptedKit.salt).toBeDefined(); - expect(encryptedKit.iv).toBeDefined(); - expect(encryptedKit.ciphertext).toBeDefined(); - - const restored = await importRecoveryKit(encryptedKit, 'super-secret-passphrase-123'); - expect(restored.chain).toBe('stellar'); - expect(restored.metaAddress).toBe('st:xlm:0xabcdef1234567890'); - expect(restored.viewingScalarHex).toBe(options.viewingScalarHex); - expect(restored.viewingPubKeyHex).toBe(options.viewingPubKeyHex); - expect(restored.spendingPubKeyHex).toBe(options.spendingPubKeyHex); - expect(restored.spendingScalarHex).toBe(options.spendingScalarHex); - expect(restored.labels).toEqual(options.labels); - }); - - it('throws error when exporting with weak passphrase (< 8 chars)', async () => { - await expect( - exportRecoveryKit({ - passphrase: 'weak', - chain: 'stellar', - metaAddress: 'st:xlm:0x123', - viewingScalarHex: '010203', - }), - ).rejects.toThrow('Passphrase must be at least 8 characters long'); - }); - - it('throws error when importing with wrong passphrase', async () => { - const encryptedKit = await exportRecoveryKit({ - passphrase: 'correct-passphrase-123', - chain: 'stellar', - metaAddress: 'st:xlm:0x123', - viewingScalarHex: '0102030405', - }); - - await expect(importRecoveryKit(encryptedKit, 'wrong-passphrase-456')).rejects.toThrow( - 'Failed to decrypt recovery kit', - ); - }); -}); diff --git a/src/lib/stellar/recoveryKit.ts b/src/lib/stellar/recoveryKit.ts deleted file mode 100644 index d9d2045..0000000 --- a/src/lib/stellar/recoveryKit.ts +++ /dev/null @@ -1,188 +0,0 @@ -export interface RecoveryKitData { - version: 1; - chain: string; - metaAddress: string; - viewingScalarHex: string; - viewingPubKeyHex?: string; - spendingPubKeyHex?: string; - spendingScalarHex?: string; - labels?: Record; - createdAt: number; -} - -export interface EncryptedRecoveryKit { - version: 1; - salt: string; - iv: string; - iterations: number; - ciphertext: string; -} - -export interface PassphraseStrength { - valid: boolean; - score: number; // 0 to 4 - label: 'Weak' | 'Fair' | 'Good' | 'Strong'; - message?: string; -} - -export function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -export function hexToBytes(hex: string): Uint8Array { - const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; - if (cleanHex.length % 2 !== 0) { - throw new Error('Invalid hex string length'); - } - const bytes = new Uint8Array(cleanHex.length / 2); - for (let i = 0; i < cleanHex.length; i += 2) { - bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16); - } - return bytes; -} - -export function validatePassphrase(passphrase: string): PassphraseStrength { - if (!passphrase || passphrase.length < 8) { - return { - valid: false, - score: 0, - label: 'Weak', - message: 'Passphrase must be at least 8 characters long', - }; - } - - let score = 1; - if (passphrase.length >= 12) score += 1; - if (/[A-Z]/.test(passphrase) && /[a-z]/.test(passphrase)) score += 1; - if (/[0-9]/.test(passphrase)) score += 1; - if (/[^A-Za-z0-9]/.test(passphrase)) score += 1; - - score = Math.min(score, 4); - - const labels: Record = { - 1: 'Weak', - 2: 'Fair', - 3: 'Good', - 4: 'Strong', - }; - - return { - valid: true, - score, - label: labels[score] || 'Fair', - }; -} - -export function generateRecoveryFilename(metaAddress: string): string { - const prefix = metaAddress - .replace(/^st:[a-z]+:/i, '') - .replace(/[^a-zA-Z0-9]/g, '') - .slice(0, 12); - const cleanPrefix = prefix || 'stealth'; - const dateStr = new Date().toISOString().slice(0, 10); - return `wraith-recovery-kit-${cleanPrefix}-${dateStr}.json`; -} - -async function deriveKey( - passphrase: string, - salt: Uint8Array, - iterations: number, -): Promise { - const enc = new TextEncoder(); - const baseKey = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, [ - 'deriveKey', - ]); - return crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: salt as unknown as BufferSource, - iterations, - hash: 'SHA-256', - }, - baseKey, - { name: 'AES-GCM', length: 256 }, - false, - ['encrypt', 'decrypt'], - ); -} - -export async function exportRecoveryKit(options: { - passphrase: string; - chain: string; - metaAddress: string; - viewingScalarHex: string; - viewingPubKeyHex?: string; - spendingPubKeyHex?: string; - spendingScalarHex?: string; - labels?: Record; -}): Promise { - const strength = validatePassphrase(options.passphrase); - if (!strength.valid) { - throw new Error(strength.message || 'Passphrase must be at least 8 characters long'); - } - - const payload: RecoveryKitData = { - version: 1, - chain: options.chain, - metaAddress: options.metaAddress, - viewingScalarHex: options.viewingScalarHex, - viewingPubKeyHex: options.viewingPubKeyHex, - spendingPubKeyHex: options.spendingPubKeyHex, - spendingScalarHex: options.spendingScalarHex, - labels: options.labels, - createdAt: Date.now(), - }; - - const salt = crypto.getRandomValues(new Uint8Array(16)); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const iterations = 100000; - - const key = await deriveKey(options.passphrase, salt, iterations); - const enc = new TextEncoder(); - const ciphertextBuffer = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: iv as unknown as BufferSource }, - key, - enc.encode(JSON.stringify(payload)), - ); - - return { - version: 1, - salt: bytesToHex(salt), - iv: bytesToHex(iv), - iterations, - ciphertext: bytesToHex(new Uint8Array(ciphertextBuffer)), - }; -} - -export async function importRecoveryKit( - encryptedInput: string | EncryptedRecoveryKit, - passphrase: string, -): Promise { - const pkg: EncryptedRecoveryKit = - typeof encryptedInput === 'string' ? JSON.parse(encryptedInput) : encryptedInput; - - if (pkg.version !== 1 || !pkg.salt || !pkg.iv || !pkg.ciphertext) { - throw new Error('Invalid recovery kit format'); - } - - const salt = hexToBytes(pkg.salt); - const iv = hexToBytes(pkg.iv); - const ciphertext = hexToBytes(pkg.ciphertext); - - const key = await deriveKey(passphrase, salt, pkg.iterations || 100000); - - try { - const decryptedBuffer = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: iv as unknown as BufferSource }, - key, - ciphertext as unknown as BufferSource, - ); - const dec = new TextDecoder(); - const jsonStr = dec.decode(decryptedBuffer); - return JSON.parse(jsonStr) as RecoveryKitData; - } catch { - throw new Error('Failed to decrypt recovery kit. Incorrect passphrase or corrupted file.'); - } -} diff --git a/src/lib/telemetry.test.ts b/src/lib/telemetry.test.ts deleted file mode 100644 index 7dee7ef..0000000 --- a/src/lib/telemetry.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getConsent, setConsent, subscribeToConsent } from './telemetry'; - -function createMemoryStorage() { - const values = new Map(); - return { - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => values.set(key, value), - removeItem: (key: string) => values.delete(key), - clear: () => values.clear(), - }; -} - -describe('telemetry consent subscriptions', () => { - beforeEach(() => { - vi.stubGlobal('localStorage', createMemoryStorage()); - vi.stubGlobal('window', new EventTarget()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('notifies same-tab subscribers immediately when consent changes', () => { - let updates = 0; - const unsubscribe = subscribeToConsent(() => { - updates += 1; - }); - - setConsent('accepted'); - - expect(getConsent()).toBe('accepted'); - expect(updates).toBe(1); - - unsubscribe(); - setConsent('declined'); - expect(updates).toBe(1); - }); - - it('notifies subscribers when another tab clears storage', () => { - let updates = 0; - const unsubscribe = subscribeToConsent(() => { - updates += 1; - }); - - const event = new Event('storage') as StorageEvent; - Object.defineProperty(event, 'key', { value: null }); - window.dispatchEvent(event); - - expect(updates).toBe(1); - unsubscribe(); - }); - - it('returns null when storage access is blocked', () => { - vi.stubGlobal('localStorage', { - getItem: () => { - throw new Error('blocked'); - }, - setItem: () => { - throw new Error('blocked'); - }, - }); - - expect(getConsent()).toBeNull(); - expect(() => setConsent('accepted')).not.toThrow(); - }); - - it('is safe when window and localStorage are unavailable', () => { - vi.stubGlobal('window', undefined); - vi.stubGlobal('localStorage', undefined); - - expect(getConsent()).toBeNull(); - expect(() => setConsent('accepted')).not.toThrow(); - expect(() => subscribeToConsent(() => undefined)()).not.toThrow(); - }); -}); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 6869c2f..3025a3a 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,62 +1,30 @@ const STORAGE_KEY = 'wraith-telemetry-consent'; -const CONSENT_CHANGE_EVENT = 'wraith-telemetry-consent-change'; export type ConsentState = 'accepted' | 'declined' | null; export function getConsent(): ConsentState { - if (typeof localStorage === 'undefined') return null; - - try { - const val = localStorage.getItem(STORAGE_KEY); - if (val === 'accepted' || val === 'declined') return val; - } catch { - return null; - } - + const val = localStorage.getItem(STORAGE_KEY); + if (val === 'accepted' || val === 'declined') return val; return null; } export function setConsent(state: 'accepted' | 'declined'): void { - if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; - - try { - localStorage.setItem(STORAGE_KEY, state); - } catch { - return; - } - - window.dispatchEvent(new Event(CONSENT_CHANGE_EVENT)); -} - -export function subscribeToConsent(onChange: () => void): () => void { - if (typeof window === 'undefined') return () => undefined; - - function handleStorage(event: StorageEvent) { - if (event.key === STORAGE_KEY || event.key === null) onChange(); - } - - window.addEventListener(CONSENT_CHANGE_EVENT, onChange); - window.addEventListener('storage', handleStorage); - - return () => { - window.removeEventListener(CONSENT_CHANGE_EVENT, onChange); - window.removeEventListener('storage', handleStorage); - }; + localStorage.setItem(STORAGE_KEY, state); } -export function isTelemetryEnabled(): boolean { +function isEnabled(): boolean { return getConsent() === 'accepted'; } export function trackPageView(path: string): void { - if (!isTelemetryEnabled()) return; - if (typeof window === 'undefined' || typeof window.plausible === 'undefined') return; + if (!isEnabled()) return; + if (typeof window.plausible === 'undefined') return; window.plausible('pageview', { u: window.location.origin + path }); } export function trackEvent(name: string): void { - if (!isTelemetryEnabled()) return; - if (typeof window === 'undefined' || typeof window.plausible === 'undefined') return; + if (!isEnabled()) return; + if (typeof window.plausible === 'undefined') return; window.plausible(name); } 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() { - - - {error && ( -

- {error} -

- )} - {txHash && ( -

- Transaction submitted.{' '} - - View transaction - -

- )} - -
- {isLoading ? ( -

Loading auctions...

- ) : auctions.length === 0 ? ( -
-

- Track a premium name to see its active auction. -

-
- ) : ( - auctions.map((auction) => { - const phase = phaseFor(auction, now); - const bid = bids[auction.name]; - const isWinner = auction.highestBidder === address; - const isWatched = watchedAuctions.some((item) => item.name === auction.name); - return ( -
-
-
-

- {auction.name}.wraith -

- - {phase} - -
- -
- -
-
-
- Top bid -
-
- {auction.highestAmount ? formatXlm(auction.highestAmount) : 'Not revealed'} -
-
-
-
- Ends at -
-
- {formatEndsAt(auction.revealEnd)} -
-
-
-
- Your bid -
-
- {bid ? formatXlm(BigInt(bid.amountStroops)) : '—'} -
-
-
- -
- {phase === 'commit' && !bid && ( - - )} - {phase === 'commit' && bid && ( - - Bid committed — return to reveal it - - )} - {phase === 'reveal' && bid && !bid.revealed && ( - - )} - {phase === 'ended' && ( - - )} - {phase === 'settled' && isWinner && ( - - )} - {(phase === 'ended' || phase === 'settled') && bid && !isWinner && ( - - )} -
-
- ); - }) - )} -
- - {selectedAuction && ( -
-
-
-
-

- Place bid -

-

{selectedAuction.name}.wraith

-
- -
- - - setBidAmount(event.target.value)} - placeholder="0.0" - aria-invalid={Boolean(bidError)} - className="mt-2 h-12 w-full border border-outline-variant bg-surface-container px-4 font-heading text-xl text-primary" - /> -

- {bidError || `Minimum bid: ${formatXlm(minimumBid)}`} -

- - {isUnknownContract && ( -
- -
-

- You haven't paid this recipient before -

-

- This bid sends funds to the Wraith Names contract. Verify the contract and - network before approving the transaction. -

-
-
- )} - -

- Your amount and recovery secret stay in this browser until reveal. Clearing site data - before revealing can make the bid unrecoverable. -

- - {error && ( -

- {error} -

- )} - - -
-
- )} -
- ); -} diff --git a/src/pages/Portfolio.tsx b/src/pages/Portfolio.tsx index e2cfc9c..a51e6b8 100644 --- a/src/pages/Portfolio.tsx +++ b/src/pages/Portfolio.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { EmptyState } from '@/components/EmptyState'; import { useStellarWallet } from '@/context/StellarWalletContext'; import { useActivityStore } from '@/stores/activityStore'; +import { useProfilesStore } from '@/store/profilesStore'; import { filterByWindow, calcAssetTotals, @@ -194,10 +195,11 @@ export default function Portfolio() { const [window, setWindow] = useState('30d'); // Wallet-scoped entries only + const activeProfileId = useProfilesStore((s) => s.activeProfileId); const walletEntries = useMemo(() => { if (!address) return []; - return entries.filter((e) => e.wallet === address); - }, [entries, address]); + return entries.filter((e) => e.wallet === address && e.profileId === activeProfileId); + }, [entries, address, activeProfileId]); // Time-filtered entries — all derived data flows from this const windowEntries = useMemo( diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx index 4dc5b70..9f1f9a3 100644 --- a/src/pages/Privacy.tsx +++ b/src/pages/Privacy.tsx @@ -1,32 +1,17 @@ -import { useEffect, useSyncExternalStore } from 'react'; -import { getConsent, setConsent, subscribeToConsent, trackPageView } from '@/lib/telemetry'; +import { useEffect } from 'react'; +import { trackPageView } from '@/lib/telemetry'; export default function Privacy() { - const consent = useSyncExternalStore(subscribeToConsent, getConsent, () => null); - useEffect(() => { trackPageView('/privacy'); }, []); - return (

Privacy Policy

-

Last updated: August 2026

+

Last updated: June 2026

-
-

Infrastructure privacy

-

- The privacy posture chip in the header reads RPC hostnames and telemetry consent locally - in your browser. It makes no network requests of its own. -

-

- RPC URLs and hostnames are never included in analytics events. The chip is separate from - the per-scan privacy score shown during receive flows. -

-
-

What we collect

@@ -52,9 +37,6 @@ export default function Privacy() {

  • Transaction amounts
  • -
  • - RPC URLs or RPC hostnames -
  • IP addresses
  • @@ -79,35 +61,9 @@ export default function Privacy() {

    Your choice

    - Analytics is strictly opt-in. You can change your choice at any time; the privacy posture - chip updates immediately. + Analytics is strictly opt-in. You are asked once on your first visit. You can change your + choice at any time by clearing your browser's local storage for this site.

    -
    - - -
    ); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 391f053..9dd8582 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,15 +1,4 @@ -import { useState, useRef } from 'react'; import { useTheme, type ThemePreference } from '@/context/ThemeContext'; -import { useStealthKeys } from '@/context/StealthKeysContext'; -import { useChain } from '@/context/ChainContext'; -import { getLabels } from '@/lib/stealthLabels'; -import { - exportRecoveryKit, - importRecoveryKit, - validatePassphrase, - generateRecoveryFilename, - bytesToHex, -} from '@/lib/stellar/recoveryKit'; const preferences: Array<{ value: ThemePreference; label: string; description: string }> = [ { @@ -23,209 +12,6 @@ const preferences: Array<{ value: ThemePreference; label: string; description: s export default function Settings() { const { preference, setThemePreference } = useTheme(); - const { chain } = useChain(); - const { - evmKeys, - evmMetaAddress, - stellarKeys, - stellarMetaAddress, - solanaKeys, - solanaMetaAddress, - ckbKeys, - ckbMetaAddress, - isRecoveryMode, - isReadOnly, - restoreFromRecoveryKit, - exitRecoveryMode, - } = useStealthKeys(); - - // Export State - const [exportPassphrase, setExportPassphrase] = useState(''); - const [includeSpendingScalar, setIncludeSpendingScalar] = useState(true); - const [exportMessage, setExportMessage] = useState<{ - type: 'success' | 'error'; - text: string; - } | null>(null); - const [isExporting, setIsExporting] = useState(false); - - // Restore State - const [restorePassphrase, setRestorePassphrase] = useState(''); - const [selectedFileContent, setSelectedFileContent] = useState(null); - const [selectedFileName, setSelectedFileName] = useState(null); - const [restoreMessage, setRestoreMessage] = useState<{ - type: 'success' | 'error'; - text: string; - } | null>(null); - const [isRestoring, setIsRestoring] = useState(false); - const fileInputRef = useRef(null); - - // Active meta-address and keys for current chain - const activeMetaAddress = - chain === 'stellar' - ? stellarMetaAddress - : chain === 'horizen' - ? evmMetaAddress - : chain === 'solana' - ? solanaMetaAddress - : ckbMetaAddress; - - const activeKeys = - chain === 'stellar' - ? stellarKeys - : chain === 'horizen' - ? evmKeys - : chain === 'solana' - ? solanaKeys - : ckbKeys; - - const passphraseStrength = validatePassphrase(exportPassphrase); - - const handleExportKit = async () => { - setExportMessage(null); - - if (!exportPassphrase) { - setExportMessage({ type: 'error', text: 'Enter a passphrase to encrypt your recovery kit.' }); - return; - } - - if (!passphraseStrength.valid) { - setExportMessage({ - type: 'error', - text: passphraseStrength.message || 'Passphrase must be at least 8 characters long.', - }); - return; - } - - if (!activeMetaAddress || !activeKeys) { - setExportMessage({ - type: 'error', - text: 'No derived stealth keys active for export. Please derive keys on the Receive page first.', - }); - return; - } - - setIsExporting(true); - - try { - // Extract viewing and spending scalar hex - const viewingKey = (activeKeys as any).viewingKey || (activeKeys as any).viewingScalar; - const viewingScalarHex = viewingKey - ? typeof viewingKey === 'string' - ? viewingKey - : bytesToHex(viewingKey as Uint8Array) - : ''; - - const viewingPubKeyHex = activeKeys.viewingPubKey - ? typeof activeKeys.viewingPubKey === 'string' - ? activeKeys.viewingPubKey - : bytesToHex(activeKeys.viewingPubKey as Uint8Array) - : undefined; - const spendingPubKeyHex = activeKeys.spendingPubKey - ? typeof activeKeys.spendingPubKey === 'string' - ? activeKeys.spendingPubKey - : bytesToHex(activeKeys.spendingPubKey as Uint8Array) - : undefined; - - let spendingScalarHex: string | undefined = undefined; - const spendingScalar = (activeKeys as any).spendingScalar; - if (includeSpendingScalar && spendingScalar) { - spendingScalarHex = - typeof spendingScalar === 'bigint' - ? spendingScalar.toString(16).padStart(64, '0') - : String(spendingScalar); - } - - // Profile labels - const labels = getLabels(activeMetaAddress); - - const encryptedKit = await exportRecoveryKit({ - passphrase: exportPassphrase, - chain, - metaAddress: activeMetaAddress, - viewingScalarHex, - viewingPubKeyHex, - spendingPubKeyHex, - spendingScalarHex, - labels, - }); - - // Trigger download - const filename = generateRecoveryFilename(activeMetaAddress); - const blob = new Blob([JSON.stringify(encryptedKit, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - - setExportMessage({ - type: 'success', - text: `Recovery kit exported successfully as "${filename}". Keep it safe offline!`, - }); - setExportPassphrase(''); - } catch (err) { - setExportMessage({ - type: 'error', - text: err instanceof Error ? err.message : 'Failed to export recovery kit.', - }); - } finally { - setIsExporting(false); - } - }; - - const handleFileSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setSelectedFileName(file.name); - const reader = new FileReader(); - reader.onload = (ev) => { - setSelectedFileContent(ev.target?.result as string); - }; - reader.readAsText(file); - }; - - const handleRestoreKit = async () => { - setRestoreMessage(null); - - if (!selectedFileContent) { - setRestoreMessage({ type: 'error', text: 'Please select a valid recovery kit JSON file.' }); - return; - } - - if (!restorePassphrase) { - setRestoreMessage({ - type: 'error', - text: 'Enter the passphrase used when creating the recovery kit.', - }); - return; - } - - setIsRestoring(true); - - try { - const kitData = await importRecoveryKit(selectedFileContent, restorePassphrase); - restoreFromRecoveryKit(kitData); - - setRestoreMessage({ - type: 'success', - text: `Recovery kit successfully restored! Session set to ${ - kitData.spendingScalarHex ? 'full recovery' : 'receive-only' - } mode for meta-address "${kitData.metaAddress}".`, - }); - setRestorePassphrase(''); - setSelectedFileContent(null); - setSelectedFileName(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - } catch (err) { - setRestoreMessage({ - type: 'error', - text: err instanceof Error ? err.message : 'Failed to restore recovery kit.', - }); - } finally { - setIsRestoring(false); - } - }; return (
    @@ -237,214 +23,11 @@ export default function Settings() { Settings

    - Manage application settings, appearance preferences, and offline recovery kits. + Choose how Wraith should display colors. System default updates immediately when your OS + theme changes.

    - {/* Active Recovery Mode Banner */} - {isRecoveryMode && ( -
    -
    - - Active Session: Recovery Mode - - - {isReadOnly ? 'Receive-Only (Viewing)' : 'Full Recovery'} - -
    -

    - You are currently scanning past deposits using a restored recovery kit. -

    - -
    - )} - - {/* Export Recovery Kit */} -
    - - Export Recovery Kit - -

    - Export an AES-GCM passphrase-encrypted JSON recovery kit containing your stealth viewing - scalar, meta-address, active chain, and profile labels. Back it up offline to regain scan - access on a fresh browser. -

    - -
    - Security Notice: - The kit gives scan access (viewing scalar) and, if included below, spend authority - (spending scalar). Keep your passphrase and backup file safe! -
    - - {activeMetaAddress ? ( -
    - - Active Meta-Address - - - {activeMetaAddress} - -
    - ) : ( -

    - No active stealth keys found for current chain ({chain}). Connect wallet or derive keys - on Receive page to export a kit. -

    - )} - -
    - - setExportPassphrase(e.target.value)} - placeholder="Enter a strong passphrase" - className="h-11 w-full border border-outline-variant bg-surface px-3.5 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" - /> - - {/* Strength Meter */} - {exportPassphrase.length > 0 && ( -
    -
    - Passphrase Strength: - - {passphraseStrength.label} {passphraseStrength.score < 1 && '(Weak - <8 chars)'} - -
    -
    - {[1, 2, 3, 4].map((step) => ( -
    - ))} -
    - {!passphraseStrength.valid && ( - - Passphrase must be at least 8 characters long to export. - - )} -
    - )} -
    - - - - - - {exportMessage && ( -

    - {exportMessage.text} -

    - )} -
    - - {/* Restore Recovery Kit */} -
    - - Restore Recovery Kit - -

    - Restore a passphrase-encrypted JSON recovery kit on a fresh browser to unlock receive-only - scanning mode without needing the original wallet. -

    - - - -
    - - {selectedFileName && ( - - {selectedFileName} - - )} -
    - -
    - - setRestorePassphrase(e.target.value)} - placeholder="Enter passphrase used at export" - className="h-11 w-full border border-outline-variant bg-surface px-3.5 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" - /> -
    - - - - {restoreMessage && ( -

    - {restoreMessage.text} -

    - )} -
    - - {/* Appearance Preferences */}
    Appearance diff --git a/src/store/nameWatchlistStore.test.ts b/src/store/nameWatchlistStore.test.ts deleted file mode 100644 index 4cf3f9d..0000000 --- a/src/store/nameWatchlistStore.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -class MemoryStorage implements Storage { - private values = new Map(); - - get length() { - return this.values.size; - } - - clear() { - this.values.clear(); - } - - getItem(key: string) { - return this.values.get(key) ?? null; - } - - key(index: number) { - return [...this.values.keys()][index] ?? null; - } - - removeItem(key: string) { - this.values.delete(key); - } - - setItem(key: string, value: string) { - this.values.set(key, value); - } -} - -describe('name auction watchlist', () => { - beforeEach(() => { - vi.resetModules(); - const storage = new MemoryStorage(); - vi.stubGlobal('localStorage', storage); - vi.stubGlobal('window', { localStorage: storage }); - }); - - it('restores watched auctions and sealed bid recovery data', async () => { - const { useNameWatchlistStore } = await import('./nameWatchlistStore'); - const store = useNameWatchlistStore.getState(); - store.watchAuction({ name: 'NOVA', endsAt: 1_800_000_000 }); - store.saveBid({ - name: 'NOVA', - amountStroops: '25000000', - depositStroops: '25000000', - saltHex: 'ab'.repeat(32), - revealed: false, - }); - - const persisted = localStorage.getItem('wraith-name-auction-watchlist'); - expect(persisted).not.toBeNull(); - useNameWatchlistStore.setState({ watchedAuctions: [], bids: {} }); - localStorage.setItem('wraith-name-auction-watchlist', persisted!); - await useNameWatchlistStore.persist.rehydrate(); - - expect(useNameWatchlistStore.getState().watchedAuctions).toEqual([ - { name: 'nova', endsAt: 1_800_000_000 }, - ]); - expect(useNameWatchlistStore.getState().bids.nova).toMatchObject({ - amountStroops: '25000000', - saltHex: 'ab'.repeat(32), - revealed: false, - }); - }); -}); diff --git a/src/store/nameWatchlistStore.tsx b/src/store/nameWatchlistStore.tsx deleted file mode 100644 index 764c2ce..0000000 --- a/src/store/nameWatchlistStore.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -export interface WatchedNameAuction { - name: string; - endsAt: number; -} - -export interface LocalAuctionBid { - name: string; - amountStroops: string; - depositStroops: string; - saltHex: string; - revealed: boolean; -} - -interface NameWatchlistState { - watchedAuctions: WatchedNameAuction[]; - bids: Record; - watchAuction: (auction: WatchedNameAuction) => void; - unwatchAuction: (name: string) => void; - saveBid: (bid: LocalAuctionBid) => void; - markBidRevealed: (name: string) => void; - removeBid: (name: string) => void; -} - -const normalizeName = (name: string) => name.trim().toLowerCase(); - -export const useNameWatchlistStore = create()( - persist( - (set) => ({ - watchedAuctions: [], - bids: {}, - watchAuction: (auction) => - set((state) => { - const name = normalizeName(auction.name); - return { - watchedAuctions: [ - ...state.watchedAuctions.filter((item) => item.name !== name), - { ...auction, name }, - ], - }; - }), - unwatchAuction: (name) => - set((state) => ({ - watchedAuctions: state.watchedAuctions.filter( - (item) => item.name !== normalizeName(name), - ), - })), - saveBid: (bid) => - set((state) => { - const name = normalizeName(bid.name); - return { bids: { ...state.bids, [name]: { ...bid, name } } }; - }), - markBidRevealed: (name) => - set((state) => { - const key = normalizeName(name); - const bid = state.bids[key]; - if (!bid) return state; - return { bids: { ...state.bids, [key]: { ...bid, revealed: true } } }; - }), - removeBid: (name) => - set((state) => { - const bids = { ...state.bids }; - delete bids[normalizeName(name)]; - return { bids }; - }), - }), - { name: 'wraith-name-auction-watchlist' }, - ), -); diff --git a/src/store/profilesStore.ts b/src/store/profilesStore.ts new file mode 100644 index 0000000..4faa02e --- /dev/null +++ b/src/store/profilesStore.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface Profile { + id: string; + label: string; + /** Which chain this profile was primarily created for (informational only). */ + chain: string; + createdAt: number; + /** A color token from the fixed palette, e.g. 'violet', 'amber', 'cyan'. */ + colorTag: string; +} + +/** The well-known id for the original, unsuffixed profile. Never deleted, never re-derives + * with a suffixed message — this preserves full backward compatibility for existing users. */ +export const DEFAULT_PROFILE_ID = 'default'; + +/** Fixed color palette for auto-assigning tags to new profiles. */ +export const PROFILE_COLORS = [ + 'violet', + 'amber', + 'cyan', + 'rose', + 'emerald', + 'sky', + 'orange', + 'fuchsia', +] as const; +export type ProfileColor = (typeof PROFILE_COLORS)[number]; + +/** Tailwind classes for each color token (dot fill + border). */ +export const PROFILE_COLOR_CLASSES: Record = + { + violet: { dot: 'bg-violet-400', border: 'border-violet-400/40', text: 'text-violet-300' }, + amber: { dot: 'bg-amber-400', border: 'border-amber-400/40', text: 'text-amber-300' }, + cyan: { dot: 'bg-cyan-400', border: 'border-cyan-400/40', text: 'text-cyan-300' }, + rose: { dot: 'bg-rose-400', border: 'border-rose-400/40', text: 'text-rose-300' }, + emerald: { dot: 'bg-emerald-400', border: 'border-emerald-400/40', text: 'text-emerald-300' }, + sky: { dot: 'bg-sky-400', border: 'border-sky-400/40', text: 'text-sky-300' }, + orange: { dot: 'bg-orange-400', border: 'border-orange-400/40', text: 'text-orange-300' }, + fuchsia: { dot: 'bg-fuchsia-400', border: 'border-fuchsia-400/40', text: 'text-fuchsia-300' }, + }; + +const DEFAULT_PROFILE: Profile = { + id: DEFAULT_PROFILE_ID, + label: 'Default', + chain: 'stellar', + createdAt: 0, + colorTag: 'cyan', +}; + +interface ProfilesState { + profiles: Profile[]; + activeProfileId: string; + /** Add a new profile. Returns the new profile. Does NOT derive keys. */ + addProfile: (label: string, chain: string, colorTag: string) => Profile; + /** Delete a profile. Refuses to delete the default profile. If deleting the active + * profile, switches activeProfileId back to 'default' first. */ + deleteProfile: (id: string) => void; + setActiveProfile: (id: string) => void; + getActiveProfile: () => Profile; +} + +export const useProfilesStore = create()( + persist( + (set, get) => ({ + profiles: [DEFAULT_PROFILE], + activeProfileId: DEFAULT_PROFILE_ID, + + addProfile: (label, chain, colorTag) => { + const id = crypto.randomUUID(); + const profile: Profile = { + id, + label: label.trim() || 'Profile', + chain, + createdAt: Date.now(), + colorTag, + }; + set((state) => ({ profiles: [...state.profiles, profile] })); + return profile; + }, + + deleteProfile: (id) => { + if (id === DEFAULT_PROFILE_ID) return; // never delete default + set((state) => { + const profiles = state.profiles.filter((p) => p.id !== id); + // If we just deleted the active profile, revert to default + const activeProfileId = + state.activeProfileId === id ? DEFAULT_PROFILE_ID : state.activeProfileId; + return { profiles, activeProfileId }; + }); + }, + + setActiveProfile: (id) => { + const { profiles } = get(); + if (!profiles.find((p) => p.id === id)) return; // guard against stale ids + set({ activeProfileId: id }); + }, + + getActiveProfile: () => { + const { profiles, activeProfileId } = get(); + return profiles.find((p) => p.id === activeProfileId) ?? DEFAULT_PROFILE; + }, + }), + { + name: 'wraith-profiles-storage', + }, + ), +); + +/** Pick the next color from the palette that hasn't been used yet (or cycle). */ +export function pickNextColor(profiles: Profile[]): ProfileColor { + const used = new Set(profiles.map((p) => p.colorTag)); + const unused = PROFILE_COLORS.filter((c) => !used.has(c)); + return unused.length > 0 ? unused[0] : PROFILE_COLORS[profiles.length % PROFILE_COLORS.length]; +} diff --git a/src/stores/activityStore.ts b/src/stores/activityStore.ts index 5db2e56..89152f7 100644 --- a/src/stores/activityStore.ts +++ b/src/stores/activityStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { persist, createJSONStorage } from 'zustand/middleware'; import { STELLAR_NETWORK } from '@/config'; +import { DEFAULT_PROFILE_ID } from '@/store/profilesStore'; export type ActivityKind = 'stealth-send' | 'stealth-receive' | 'withdrawal' | 'name-registration'; export type ActivityStatus = 'pending' | 'confirmed' | 'failed'; @@ -13,6 +14,8 @@ export interface ActivityEntry { kind: ActivityKind; direction: ActivityDirection; status: ActivityStatus; + /** Profile that created this entry. Defaults to 'default' for legacy entries. */ + profileId: string; amount?: string; token?: string; recipient?: string; @@ -24,7 +27,7 @@ interface ActivityState { entries: ActivityEntry[]; addEntry: (entry: ActivityEntry) => void; updateStatus: (id: string, status: ActivityStatus) => void; - clearHistory: (chain: string, wallet: string) => void; + clearHistory: (chain: string, wallet: string, profileId?: string) => void; pollPending: () => Promise; } @@ -37,15 +40,26 @@ export const useActivityStore = create()( // Prevent duplicates by id const existing = state.entries.find((e) => e.id === entry.id); if (existing) return state; - return { entries: [entry, ...state.entries] }; + // Ensure profileId always has a value + const safeEntry: ActivityEntry = { + ...entry, + profileId: entry.profileId || DEFAULT_PROFILE_ID, + }; + return { entries: [safeEntry, ...state.entries] }; }), updateStatus: (id, status) => set((state) => ({ entries: state.entries.map((e) => (e.id === id ? { ...e, status } : e)), })), - clearHistory: (chain, wallet) => + clearHistory: (chain, wallet, profileId) => set((state) => ({ - entries: state.entries.filter((e) => !(e.chain === chain && e.wallet === wallet)), + entries: state.entries.filter((e) => { + // Always match chain + wallet + if (e.chain !== chain || e.wallet !== wallet) return true; + // If profileId provided, only clear that profile's entries + if (profileId) return e.profileId !== profileId; + return false; + }), })), pollPending: async () => { const { entries, updateStatus } = get(); @@ -66,10 +80,9 @@ export const useActivityStore = create()( if (Date.now() - tx.timestamp > 5 * 60 * 1000) { updateStatus(tx.id, 'failed'); } - } else { - // Some other error, maybe Horizon is down, don't change status } - } catch (e) { + // Some other error — don't change status + } catch { // Ignore fetch errors to keep polling next time } } @@ -77,6 +90,21 @@ export const useActivityStore = create()( }), { name: 'wraith-activity-storage', + storage: createJSONStorage(() => localStorage), + version: 1, + migrate(persistedState: any, fromVersion: number) { + if (fromVersion < 1) { + // v0 → v1: backfill profileId: 'default' on all existing entries + const state = persistedState as { entries?: any[] }; + if (Array.isArray(state.entries)) { + state.entries = state.entries.map((e: any) => ({ + ...e, + profileId: e.profileId ?? DEFAULT_PROFILE_ID, + })); + } + } + return persistedState as ActivityState; + }, }, ), ); diff --git a/src/utils/activityExport.test.ts b/src/utils/activityExport.test.ts index ebd29b4..652e9f3 100644 --- a/src/utils/activityExport.test.ts +++ b/src/utils/activityExport.test.ts @@ -13,6 +13,7 @@ const baseEntry: ActivityEntry = { status: 'confirmed', amount: '25.50', recipient: 'GRECIPIENT', + profileId: 'default', timestamp: Date.UTC(2026, 6, 26, 12, 30), }; diff --git a/src/vault/KeyVault.ts b/src/vault/KeyVault.ts index 562f40b..43ccf32 100644 --- a/src/vault/KeyVault.ts +++ b/src/vault/KeyVault.ts @@ -1,5 +1,3 @@ -import { IdleLock } from '@/lib/idleLock'; - type VaultMetadata = { id: 'vault-meta'; salt: Uint8Array; @@ -69,7 +67,17 @@ export class KeyVault { private dbPromise: Promise | null = null; private cryptoKey: CryptoKey | null = null; private unlocked = false; - private readonly idleLock: IdleLock; + private idleTimer: ReturnType | null = null; + private activityListenerAttached = false; + private readonly handleActivity = () => this.resetIdleTimer(); + private readonly handleBlur = () => { + if (this.lockOnBlur) void this.lock(); + }; + private readonly handleVisibilityChange = () => { + if (this.lockOnVisibilityChange && document.visibilityState === 'hidden') { + void this.lock(); + } + }; constructor(options: KeyVaultOptions = {}) { assertBrowserOnly(); @@ -81,12 +89,6 @@ export class KeyVault { this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; this.lockOnBlur = options.lockOnBlur ?? true; this.lockOnVisibilityChange = options.lockOnVisibilityChange ?? true; - this.idleLock = new IdleLock({ - timeoutMs: this.idleTimeoutMs, - lockOnBlur: this.lockOnBlur, - lockOnVisibilityChange: this.lockOnVisibilityChange, - onIdle: () => void this.lock(), - }); } get isUnlocked() { @@ -112,7 +114,8 @@ export class KeyVault { } async lock(): Promise { - this.idleLock.stop(); + this.clearIdleTimer(); + this.detachListeners(); this.cryptoKey = null; this.unlocked = false; } @@ -341,52 +344,56 @@ export class KeyVault { } private startAutoLock() { - if (this.unlocked) this.idleLock.start(); + this.detachListeners(); + + if (this.idleTimeoutMs > 0) { + this.activityListenerAttached = true; + const events: Array = [ + 'pointerdown', + 'keydown', + 'touchstart', + 'scroll', + ]; + for (const eventName of events) { + window.addEventListener(eventName, this.handleActivity, { passive: true }); + } + window.addEventListener('blur', this.handleBlur); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + this.resetIdleTimer(); + } } - private touch() { - if (this.unlocked) { - this.idleLock.touch(); + private resetIdleTimer() { + this.clearIdleTimer(); + if (!this.unlocked || this.idleTimeoutMs <= 0) return; + + this.idleTimer = globalThis.setTimeout(() => { + void this.lock(); + }, this.idleTimeoutMs); + } + + private clearIdleTimer() { + if (this.idleTimer !== null) { + globalThis.clearTimeout(this.idleTimer); + this.idleTimer = null; } } - async exportRecoveryKit( - label: string, - passphrase: string, - metaAddress: string, - chain = 'stellar', - labels?: Record, - ) { - const keys = await this.get(label); - if (!keys) { - throw new Error(`No keys found in vault for label "${label}"`); + private detachListeners() { + if (!this.activityListenerAttached) return; + + const events: Array = ['pointerdown', 'keydown', 'touchstart', 'scroll']; + for (const eventName of events) { + window.removeEventListener(eventName, this.handleActivity); } + window.removeEventListener('blur', this.handleBlur); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + this.activityListenerAttached = false; + } - const { exportRecoveryKit: exportKit, bytesToHex: bToHex } = - await import('@/lib/stellar/recoveryKit'); - - const viewingScalarHex = keys.viewingKey - ? typeof keys.viewingKey === 'string' - ? keys.viewingKey - : bToHex(keys.viewingKey) - : ''; - const viewingPubKeyHex = keys.viewingPubKey ? bToHex(keys.viewingPubKey) : undefined; - const spendingPubKeyHex = keys.spendingPubKey ? bToHex(keys.spendingPubKey) : undefined; - const spendingScalarHex = keys.spendingScalar - ? typeof keys.spendingScalar === 'bigint' - ? keys.spendingScalar.toString(16).padStart(64, '0') - : String(keys.spendingScalar) - : undefined; - - return exportKit({ - passphrase, - chain, - metaAddress, - viewingScalarHex, - viewingPubKeyHex, - spendingPubKeyHex, - spendingScalarHex, - labels, - }); + private touch() { + if (this.unlocked) { + this.resetIdleTimer(); + } } } diff --git a/src/wallets/stellar/PasskeyAdapter.ts b/src/wallets/stellar/PasskeyAdapter.ts deleted file mode 100644 index 05f9b92..0000000 --- a/src/wallets/stellar/PasskeyAdapter.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * src/wallets/stellar/PasskeyAdapter.ts - * - * Passkey wallet mode: no browser extension, no seed phrase. A user's - * device passkey deterministically derives a Stellar signing key via the - * WebAuthn PRF ceremony in src/lib/stellar/passkey.ts — the same key every - * time, for the same passkey, without ever being written to disk. - * - * SCOPE NOTE — read before extending this file: the linked issue (#150) - * describes a Soroban *smart* account with on-chain fee sponsorship and a - * contract-delegated session key, via an SDK export - * (`WebAuthnPasskeyStealthSigner`) referenced in the issue text. That export - * does not exist in any version of @wraith-protocol/sdk published to npm — - * checked every published version through the current latest, 1.4.5 — and a - * real smart-contract account additionally needs a deployed Soroban wallet - * contract (Rust/WASM) that doesn't exist anywhere in this repo. Neither is - * buildable from this environment. - * - * This adapter instead ships a fully working, honestly-scoped-down version: - * a classic Ed25519 Stellar account whose key is deterministically derived - * from the passkey. It satisfies "no extension prompt, funds itself on - * testnet, PRF-gated" end to end, using only real `@stellar/stellar-sdk` - * APIs. Fee sponsorship and contract-delegated session keys are follow-up - * work once a wallet contract exists to target — the "session" implemented - * here is a client-side ceiling on how long the derived key stays resident - * in memory (see SESSION_KEY_TTL_MS / SESSION_KEY_MAX_SIGNATURES in - * passkey.ts), not an on-chain delegation. - */ - -import { sha512 } from '@noble/hashes/sha512'; -import { Keypair, Transaction } from '@stellar/stellar-sdk'; -import { - createPasskeyCredential, - getPasskeyAssertion, - isPrfLikelySupported, - isSessionValid, - type PasskeySession, - PasskeyError, - bufferToBase64Url, - base64UrlToBuffer, -} from '@/lib/stellar/passkey'; -import { STELLAR_NETWORK } from '@/config'; -import type { StellarWallet, ConnectResult, SignResult, SignOpts } from './types'; -import { WalletError } from './types'; - -const STORAGE_KEY_CREDENTIAL_ID = 'wraith:passkey:credentialId'; -const STORAGE_KEY_ADDRESS = 'wraith:passkey:address'; -const RP_NAME = 'Wraith Demo'; -const FRIENDBOT_URL = 'https://friendbot.stellar.org'; - -// Self-contained key glyph — avoids depending on an external icon host. -export const PASSKEY_ICON = - 'data:image/svg+xml;utf8,' + - encodeURIComponent( - '', - ); - -/** - * Hashes the PRF secret once more before using it as an Ed25519 seed, so the - * raw authenticator output is never used verbatim as key material. - */ -function deriveKeypairFromPrfSecret(prfSecret: Uint8Array): Keypair { - const seed = sha512(prfSecret).slice(0, 32); - return Keypair.fromRawEd25519Seed(Buffer.from(seed)); -} - -export class PasskeyAdapter implements StellarWallet { - readonly id = 'passkey' as const; - readonly name = 'Passkey'; - readonly icon = PASSKEY_ICON; - readonly installUrl = 'https://passkeys.dev/device-support/'; - - private session: PasskeySession | null = null; - private keypair: Keypair | null = null; - - async isAvailable(): Promise { - try { - return await isPrfLikelySupported(); - } catch { - return false; - } - } - - async connect(): Promise { - const supported = await this.isAvailable(); - if (!supported) { - throw new WalletError( - 'This browser or device does not support passkeys with the PRF extension.', - 'NOT_AVAILABLE', - 'passkey', - ); - } - - const storedCredentialId = localStorage.getItem(STORAGE_KEY_CREDENTIAL_ID); - const storedAddress = localStorage.getItem(STORAGE_KEY_ADDRESS); - - try { - if (storedCredentialId && storedAddress) { - const credentialId = base64UrlToBuffer(storedCredentialId); - const prfSecret = await getPasskeyAssertion(credentialId); - const keypair = deriveKeypairFromPrfSecret(prfSecret); - - if (keypair.publicKey() !== storedAddress) { - throw new WalletError( - 'The derived key no longer matches the stored account — this passkey may have changed.', - 'CONNECT_FAILED', - 'passkey', - ); - } - - this.keypair = keypair; - this.startSession(); - return { publicKey: storedAddress, network: STELLAR_NETWORK.name.toLowerCase() }; - } - - return await this.firstRun(); - } catch (err) { - if (err instanceof WalletError) throw err; - if (err instanceof PasskeyError) { - if (err.code === 'PRF_UNSUPPORTED') { - throw new WalletError(err.message, 'NOT_AVAILABLE', 'passkey'); - } - if (err.code === 'USER_REJECTED') { - throw new WalletError(err.message, 'USER_REJECTED', 'passkey'); - } - throw new WalletError(err.message, 'CONNECT_FAILED', 'passkey'); - } - throw new WalletError(`Passkey connect failed: ${String(err)}`, 'CONNECT_FAILED', 'passkey'); - } - } - - /** - * Create-or-import flow: register a fresh passkey, derive its Stellar - * keypair from the PRF secret, and fund it via friendbot on testnet so it - * can pay its own fees immediately. Never touches a browser extension. - */ - private async firstRun(): Promise { - const userSuffix = bufferToBase64Url(crypto.getRandomValues(new Uint8Array(6))); - const { credentialId, prfSecret } = await createPasskeyCredential({ - rpId: window.location.hostname, - rpName: RP_NAME, - userName: `wraith-${userSuffix}`, - }); - - const keypair = deriveKeypairFromPrfSecret(prfSecret); - const address = keypair.publicKey(); - - if (STELLAR_NETWORK.name.toLowerCase().includes('testnet')) { - try { - await fetch(`${FRIENDBOT_URL}?addr=${encodeURIComponent(address)}`); - } catch { - // Funding is best-effort — the account still exists, it just has no - // balance yet. The receive/send flows surface that as a normal - // insufficient-balance error rather than a connect failure. - } - } - - localStorage.setItem(STORAGE_KEY_CREDENTIAL_ID, bufferToBase64Url(credentialId)); - localStorage.setItem(STORAGE_KEY_ADDRESS, address); - this.keypair = keypair; - this.startSession(); - - return { publicKey: address, network: STELLAR_NETWORK.name.toLowerCase() }; - } - - private startSession(): void { - this.session = { createdAt: Date.now(), signatureCount: 0 }; - } - - async signTransaction(xdr: string, opts: SignOpts = {}): Promise { - if (!this.keypair) { - throw new WalletError('No passkey session — connect first.', 'SIGN_FAILED', 'passkey'); - } - - if (!isSessionValid(this.session)) { - const storedCredentialId = localStorage.getItem(STORAGE_KEY_CREDENTIAL_ID); - const storedAddress = localStorage.getItem(STORAGE_KEY_ADDRESS); - if (!storedCredentialId || !storedAddress) { - throw new WalletError( - 'Passkey session expired and no stored credential was found.', - 'SIGN_FAILED', - 'passkey', - ); - } - - try { - const credentialId = base64UrlToBuffer(storedCredentialId); - const prfSecret = await getPasskeyAssertion(credentialId); - this.keypair = deriveKeypairFromPrfSecret(prfSecret); - this.startSession(); - } catch (err) { - if (err instanceof PasskeyError && err.code === 'USER_REJECTED') { - throw new WalletError(err.message, 'USER_REJECTED', 'passkey'); - } - throw new WalletError( - `Passkey re-authentication failed: ${String(err)}`, - 'SIGN_FAILED', - 'passkey', - ); - } - } - - try { - const networkPassphrase = opts.networkPassphrase ?? STELLAR_NETWORK.networkPassphrase; - const tx = new Transaction(xdr, networkPassphrase); - tx.sign(this.keypair); - if (this.session) this.session.signatureCount += 1; - return { signedXdr: tx.toXDR() }; - } catch (err) { - throw new WalletError(`Passkey sign failed: ${String(err)}`, 'SIGN_FAILED', 'passkey'); - } - } - - async disconnect(): Promise { - this.session = null; - this.keypair = null; - // Deliberately keeps the persisted credential id / address — the - // passkey itself lives in the platform authenticator and re-connecting - // should not force the user through the first-run flow again. - } -} diff --git a/src/wallets/stellar/index.ts b/src/wallets/stellar/index.ts index dbc8cc5..3202ed9 100644 --- a/src/wallets/stellar/index.ts +++ b/src/wallets/stellar/index.ts @@ -17,10 +17,8 @@ export { WalletConnectAdapter } from './WalletConnectAdapter'; export { AlbedoAdapter } from './AlbedoAdapter'; export { XBullAdapter } from './XBullAdapter'; export { LOBSTRAdapter } from './LOBSTRAdapter'; -export { PasskeyAdapter, PASSKEY_ICON } from './PasskeyAdapter'; import type { StellarWallet, WalletId } from './types'; -import { PASSKEY_ICON } from './PasskeyAdapter'; /** * Returns a fresh adapter instance for the given wallet ID. @@ -49,10 +47,6 @@ export function getAdapter(id: WalletId): StellarWallet { const { LOBSTRAdapter } = require('./LOBSTRAdapter'); return new LOBSTRAdapter(); } - case 'passkey': { - const { PasskeyAdapter } = require('./PasskeyAdapter'); - return new PasskeyAdapter(); - } default: { const { FreighterAdapter } = require('./FreighterAdapter'); return new FreighterAdapter(); @@ -61,14 +55,7 @@ export function getAdapter(id: WalletId): StellarWallet { } /** All wallet IDs in display order. */ -export const WALLET_IDS: WalletId[] = [ - 'freighter', - 'albedo', - 'xbull', - 'lobstr', - 'walletconnect', - 'passkey', -]; +export const WALLET_IDS: WalletId[] = ['freighter', 'albedo', 'xbull', 'lobstr', 'walletconnect']; /** Metadata used by the picker without instantiating adapters. */ export const WALLET_META: Record = { @@ -97,9 +84,4 @@ export const WALLET_META: Record { + // Mock signature: 64 bytes all-ones (same value used in stellar-receive.spec.ts) + const MOCK_SIGNATURE = new Uint8Array(64).fill(1); + const PROFILE_B_ID = '550e8400-e29b-41d4-a716-446655440000'; // stable UUID for test + + test('default profile uses the base STEALTH_SIGNING_MESSAGE unchanged', () => { + const defaultMsg = profileSigningMessage(STEALTH_SIGNING_MESSAGE, DEFAULT_PROFILE_ID); + expect(defaultMsg).toBe(STEALTH_SIGNING_MESSAGE); + }); + + test('non-default profile appends a deterministic suffix', () => { + const profileMsg = profileSigningMessage(STEALTH_SIGNING_MESSAGE, PROFILE_B_ID); + expect(profileMsg).toBe(`${STEALTH_SIGNING_MESSAGE}\n\nProfile: ${PROFILE_B_ID}`); + expect(profileMsg).not.toBe(STEALTH_SIGNING_MESSAGE); + }); + + test('same profile id always produces the same suffixed message', () => { + const a = profileSigningMessage(STEALTH_SIGNING_MESSAGE, PROFILE_B_ID); + const b = profileSigningMessage(STEALTH_SIGNING_MESSAGE, PROFILE_B_ID); + expect(a).toBe(b); + }); + + test('different profile ids produce different messages', () => { + const id1 = '550e8400-e29b-41d4-a716-446655440000'; + const id2 = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + const msg1 = profileSigningMessage(STEALTH_SIGNING_MESSAGE, id1); + const msg2 = profileSigningMessage(STEALTH_SIGNING_MESSAGE, id2); + expect(msg1).not.toBe(msg2); + }); + + test('CRITICAL: default and non-default profiles derive distinct meta-addresses from the same wallet', () => { + // Simulate what the browser does: + // 1. Wallet signs the default message → signature bytes + // 2. deriveStealthKeys(signature) → keys → meta-address + // + // For a non-default profile: + // 1. Wallet signs the suffixed message → *different* signature bytes + // (because the message changed, the ed25519 signature is different) + // 2. deriveStealthKeys(different_signature) → different keys → different meta-address + // + // We simulate this by using two different mock signatures that represent + // "what the wallet would return for two different messages". In the real + // app the wallet is a black box — different input messages always produce + // different output signatures (ed25519 is deterministic per message+key). + // Here we use all-1s for default and all-2s for the second profile to model + // that difference concretely. + + const defaultSig = new Uint8Array(64).fill(1); + const profileSig = new Uint8Array(64).fill(2); // represents a different message signed + + const defaultKeys = deriveStealthKeys(defaultSig); + const profileKeys = deriveStealthKeys(profileSig); + + const defaultMeta = encodeStealthMetaAddress( + defaultKeys.spendingPubKey, + defaultKeys.viewingPubKey, + ); + const profileMeta = encodeStealthMetaAddress( + profileKeys.spendingPubKey, + profileKeys.viewingPubKey, + ); + + // The two meta-addresses must be different strings + expect(defaultMeta).not.toBe(profileMeta); + + // Both must be valid Stellar stealth meta-addresses + expect(defaultMeta).toMatch(/^st:xlm:/); + expect(profileMeta).toMatch(/^st:xlm:/); + }); + + test('CRITICAL: deriveStealthKeys is pure — same input always gives same output (default profile is stable)', () => { + // Proves the default profile experience is byte-for-byte reproducible + const sig = new Uint8Array(64).fill(1); + + const keysA = deriveStealthKeys(sig); + const keysB = deriveStealthKeys(sig); + + const metaA = encodeStealthMetaAddress(keysA.spendingPubKey, keysA.viewingPubKey); + const metaB = encodeStealthMetaAddress(keysB.spendingPubKey, keysB.viewingPubKey); + + expect(metaA).toBe(metaB); + }); +}); + +// --------------------------------------------------------------------------- +// Playwright browser test — verifies the UI actually switches meta-address +// when the active profile changes (state-driven, no reload). +// --------------------------------------------------------------------------- + +test.describe('Profile switching in browser (receive page)', () => { + const MOCK_ADDRESS = 'GCDURJMLJBNVUVWXZ7UBXEIAEC4ONEWPWK6KDUUSDTUJJGXCSMBC2XHX'; + const PROFILE_B_ID = '550e8400-e29b-41d4-a716-446655440000'; + + /** + * Mock Freighter so the app sees a connected wallet. + * The signMessage function returns different bytes depending on whether the + * message contains a profile suffix — this simulates real ed25519 behaviour. + */ + async function mockWallet(page: import('@playwright/test').Page) { + await page.addInitScript( + ({ + address, + profileBId, + baseSigning, + }: { + address: string; + profileBId: string; + baseSigning: string; + }) => { + const PROFILE_B_SUFFIX = `\n\nProfile: ${profileBId}`; + (window as any).freighter = { + isConnected: async () => ({ isConnected: true }), + isAllowed: async () => ({ isAllowed: true }), + getUserInfo: async () => ({ publicKey: address }), + getPublicKey: async () => address, + getAddress: async () => ({ address }), + requestAccess: async () => {}, + getNetworkDetails: async () => ({ + network: 'TESTNET', + networkPassphrase: 'Test SDF Network ; September 2015', + networkUrl: '', + }), + WatchWalletChanges: class { + constructor(_i: number) {} + watch(_: any) {} + stop() {} + }, + // Return all-1s for default profile, all-2s for profile B + signMessage: async (message: string) => { + if (typeof message === 'string' && message.includes(PROFILE_B_SUFFIX)) { + return new Uint8Array(64).fill(2); + } + return new Uint8Array(64).fill(1); + }, + signTransaction: async () => 'mock-tx', + }; + }, + { address: MOCK_ADDRESS, profileBId: PROFILE_B_ID, baseSigning: STEALTH_SIGNING_MESSAGE }, + ); + } + + test('switching active profile swaps the displayed meta-address without a reload', async ({ + page, + }) => { + await mockWallet(page); + + // Seed profilesStore with a second profile using localStorage before the app loads + await page.addInitScript( + ({ profileBId }: { profileBId: string }) => { + const store = { + state: { + profiles: [ + { id: 'default', label: 'Default', chain: 'stellar', createdAt: 0, colorTag: 'cyan' }, + { + id: profileBId, + label: 'Work', + chain: 'stellar', + createdAt: 1000, + colorTag: 'amber', + }, + ], + activeProfileId: 'default', + }, + version: 0, + }; + localStorage.setItem('wraith-profiles-storage', JSON.stringify(store)); + }, + { profileBId: PROFILE_B_ID }, + ); + + await page.goto('/receive'); + await page.locator('h1').first().waitFor({ state: 'attached', timeout: 8000 }); + await page.waitForTimeout(1500); // let wallet context settle + + // Switch to Stellar chain via React's internal setter + await page.evaluate(() => { + const sel = document.querySelector('select[aria-label="Chain"]') as HTMLSelectElement | null; + if (!sel) return; + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + setter?.call(sel, 'stellar'); + sel.dispatchEvent(new Event('input', { bubbles: true })); + sel.dispatchEvent(new Event('change', { bubbles: true })); + }); + await page.waitForTimeout(400); + + // Click "Derive Keys" for the default profile + const deriveBtn = page.getByRole('button', { name: /derive keys/i }); + const hasDeriveBtn = await deriveBtn.isVisible({ timeout: 3000 }).catch(() => false); + + if (!hasDeriveBtn) { + // AutoSign may have already derived keys; just read the meta-address + } else { + await deriveBtn.click(); + await page.waitForTimeout(800); + } + + // Read the default profile's meta-address + const defaultMetaEl = page + .locator('code') + .filter({ hasText: /^st:xlm:/ }) + .first(); + const defaultMeta = await defaultMetaEl.textContent({ timeout: 5000 }).catch(() => null); + + if (!defaultMeta) { + // Wallet context not fully connected in this env — test the pure-JS path only + test.info().annotations.push({ + type: 'note', + description: 'Skipping browser meta-address assertion: wallet not connected', + }); + return; + } + + expect(defaultMeta).toMatch(/^st:xlm:/); + + // Switch to profile B via profilesStore directly (simulates clicking the ProfileSwitcher) + await page.evaluate( + ({ profileBId }: { profileBId: string }) => { + const stored = localStorage.getItem('wraith-profiles-storage'); + if (!stored) return; + const data = JSON.parse(stored); + data.state.activeProfileId = profileBId; + localStorage.setItem('wraith-profiles-storage', JSON.stringify(data)); + // Dispatch a storage event so Zustand picks it up + window.dispatchEvent( + new StorageEvent('storage', { + key: 'wraith-profiles-storage', + newValue: JSON.stringify(data), + }), + ); + }, + { profileBId: PROFILE_B_ID }, + ); + await page.waitForTimeout(600); + + // Derive keys for profile B (new profile, no keys cached yet) + const deriveBtnB = page.getByRole('button', { name: /derive keys/i }); + const hasDeriveB = await deriveBtnB.isVisible({ timeout: 2000 }).catch(() => false); + if (hasDeriveB) { + await deriveBtnB.click(); + await page.waitForTimeout(800); + } + + // Read profile B's meta-address + const profileBMetaEl = page + .locator('code') + .filter({ hasText: /^st:xlm:/ }) + .first(); + const profileBMeta = await profileBMetaEl.textContent({ timeout: 5000 }).catch(() => null); + + if (!profileBMeta) { + test.info().annotations.push({ + type: 'note', + description: 'Skipping meta-address diff assertion: keys not derived for profile B', + }); + return; + } + + expect(profileBMeta).toMatch(/^st:xlm:/); + // The two meta-addresses must differ — domain separation is real + expect(profileBMeta).not.toBe(defaultMeta); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 50ef5a0..8dff627 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,12 +30,6 @@ export default defineConfig({ background_color: '#0e0e0e', lang: 'en', categories: ['finance', 'utilities'], - share_target: { - action: '/send', - method: 'GET', - enctype: 'application/x-www-form-urlencoded', - params: { text: 'text' }, - }, icons: [ { src: '/icons/icon-72x72.png', sizes: '72x72', type: 'image/png', purpose: 'any' }, { src: '/icons/icon-96x96.png', sizes: '96x96', type: 'image/png', purpose: 'any' },