diff --git a/src/components/wallet/L3/modals/AddressManagerModal.tsx b/src/components/wallet/L3/modals/AddressManagerModal.tsx index 1df5449a..661aac9a 100644 --- a/src/components/wallet/L3/modals/AddressManagerModal.tsx +++ b/src/components/wallet/L3/modals/AddressManagerModal.tsx @@ -5,6 +5,7 @@ import { ModalHeader } from '../../ui'; import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; import type { TrackedAddress } from '@unicitylabs/sphere-sdk'; import { truncateId } from '../../../../utils/identifiers'; +import { NewAddressModal } from '../../shared/modals/NewAddressModal'; interface AddressManagerModalProps { isOpen: boolean; @@ -15,7 +16,8 @@ export function AddressManagerModal({ isOpen, onClose }: AddressManagerModalProp const { sphere } = useSphereContext(); const [addresses, setAddresses] = useState([]); const [togglingIndex, setTogglingIndex] = useState(null); - const [isDeriving, setIsDeriving] = useState(false); + // #413: derivation + Unicity ID prompt live in the shared NewAddressModal + const [showNewAddress, setShowNewAddress] = useState(false); const currentIndex = sphere?.getCurrentAddressIndex() ?? 0; @@ -46,21 +48,17 @@ export function AddressManagerModal({ isOpen, onClose }: AddressManagerModalProp } }, [sphere, refreshAddresses]); - const handleDeriveNew = useCallback(async () => { - if (!sphere || isDeriving) return; - setIsDeriving(true); - try { - const nextIndex = addresses.length > 0 - ? Math.max(...addresses.map(a => a.index)) + 1 - : 1; - await sphere.switchToAddress(nextIndex); - refreshAddresses(); - } catch (e) { - console.error('[AddressManager] Failed to derive new address:', e); - } finally { - setIsDeriving(false); - } - }, [sphere, addresses, isDeriving, refreshAddresses]); + // #413: open the shared derive-address flow — it derives, prompts for a + // Unicity ID (with an explicit Skip) and handles nametag recovery itself. + const handleDeriveNew = useCallback(() => { + if (!sphere) return; + setShowNewAddress(true); + }, [sphere]); + + const handleNewAddressClose = useCallback(() => { + setShowNewAddress(false); + refreshAddresses(); + }, [refreshAddresses]); return ( @@ -151,17 +149,14 @@ export function AddressManagerModal({ isOpen, onClose }: AddressManagerModalProp {/* Derive new address button */} + + ); } diff --git a/src/components/wallet/WalletPanel.tsx b/src/components/wallet/WalletPanel.tsx index cff19298..1db4ddf7 100644 --- a/src/components/wallet/WalletPanel.tsx +++ b/src/components/wallet/WalletPanel.tsx @@ -6,6 +6,7 @@ import { useIdentity, useWalletStatus, useSphereContext } from '../../sdk'; import { useIncomingPaymentRequests } from './L3/hooks/useIncomingPaymentRequests'; import { useUIState } from '../../hooks/useUIState'; import { RegisterNametagModal } from './shared/components/RegisterNametagModal'; +import { NewAddressModal } from './shared/modals/NewAddressModal'; import { AddressSelector } from './shared/components'; import { CreateWalletFlow } from './onboarding/CreateWalletFlow'; @@ -17,6 +18,9 @@ export function WalletPanel() { const [isRequestsOpen, setIsRequestsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isNametagModalOpen, setIsNametagModalOpen] = useState(false); + // #413: derive-address flow — hosted at panel level (like RegisterNametagModal) + // so the slide-in screen paints above the wallet content in DOM order. + const [isNewAddressOpen, setIsNewAddressOpen] = useState(false); const { isLoading: isWalletLoading, walletExists, error: walletError } = useWalletStatus(); const { identity, nametag, isLoading: isLoadingIdentity } = useIdentity(); const { initProgress } = useSphereContext(); @@ -196,7 +200,7 @@ export function WalletPanel() { )} - + setIsNewAddressOpen(true)} /> @@ -262,6 +266,12 @@ export function WalletPanel() { isOpen={isNametagModalOpen} onClose={() => setIsNametagModalOpen(false)} /> + + {/* New Address flow (#413) */} + setIsNewAddressOpen(false)} + /> ); } diff --git a/src/components/wallet/shared/components/AddressSelector.tsx b/src/components/wallet/shared/components/AddressSelector.tsx index def66cba..71b38c73 100644 --- a/src/components/wallet/shared/components/AddressSelector.tsx +++ b/src/components/wallet/shared/components/AddressSelector.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { ChevronDown, Plus, Loader2, Check, Copy, X, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { ChevronDown, Plus, Loader2, Check, Copy } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useIdentity } from '../../../../sdk'; import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; @@ -7,6 +7,7 @@ import { SPHERE_KEYS } from '../../../../sdk/queryKeys'; import { useQueryClient } from '@tanstack/react-query'; import type { TrackedAddress } from '@unicitylabs/sphere-sdk'; import { truncateId } from '../../../../utils/identifiers'; +import { NewAddressModal } from '../modals/NewAddressModal'; /** Truncate long nametags: show first 6 chars + ... + last 3 chars */ function truncateNametag(nametag: string, maxLength: number = 20): string { @@ -17,21 +18,25 @@ function truncateNametag(nametag: string, maxLength: number = 20): string { interface AddressSelectorProps { /** Compact mode - just show nametag with small dropdown trigger */ compact?: boolean; + /** + * #413: preferred hosting for the derive-address flow. When provided, the + * "New" button delegates to the host (WalletPanel renders NewAddressModal + * at panel level so the slide-in screen paints above the wallet content). + * Without it the selector renders the flow itself, anchored to the nearest + * positioned ancestor. + */ + onNewAddress?: () => void; } -export function AddressSelector({ compact = true }: AddressSelectorProps) { +export function AddressSelector({ compact = true, onNewAddress }: AddressSelectorProps) { const [showDropdown, setShowDropdown] = useState(false); const [copied, setCopied] = useState<'nametag' | 'address' | false>(false); const [isSwitching, setIsSwitching] = useState(false); - // Nametag modal state - const [showNametagModal, setShowNametagModal] = useState(false); - const [newNametag, setNewNametag] = useState(''); - const [nametagError, setNametagError] = useState(null); - const [nametagAvailability, setNametagAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle'); - const nametagInputRef = useRef(null); + // #413: derivation + Unicity ID prompt live in the shared NewAddressModal + const [showNewAddressModal, setShowNewAddressModal] = useState(false); - const { sphere, resolveNametag, isDiscoveringAddresses } = useSphereContext(); + const { sphere, isDiscoveringAddresses } = useSphereContext(); const { nametag, chainPubkey } = useIdentity(); const queryClient = useQueryClient(); @@ -55,34 +60,6 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { return () => { unsub1(); unsub2(); }; }, [sphere, currentAddressIndex, nametag, isDiscoveringAddresses]); - // Focus nametag input when modal opens - useEffect(() => { - if (showNametagModal) { - setTimeout(() => nametagInputRef.current?.focus(), 100); - } - }, [showNametagModal]); - - // Debounced nametag availability check - useEffect(() => { - const cleanTag = newNametag.trim().replace(/^@/, ''); - if (!cleanTag || cleanTag.length < 2) { - setNametagAvailability('idle'); - return; - } - - setNametagAvailability('checking'); - const timer = setTimeout(async () => { - try { - const existing = await resolveNametag(cleanTag); - setNametagAvailability(existing ? 'taken' : 'available'); - } catch { - setNametagAvailability('idle'); - } - }, 500); - - return () => clearTimeout(timer); - }, [newNametag, resolveNametag]); - const displayNametag = nametag; const refreshAfterSwitch = useCallback(() => { @@ -148,76 +125,22 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { } }, [sphere, isSwitching, currentAddressIndex, refreshAfterSwitch]); - // Step 1: Create new address, then check if nametag already exists (local + network) - const handleNewClick = useCallback(async () => { - if (!sphere || isSwitching) return; - - // Keep dropdown open to show "Switching..." indicator - setIsSwitching(true); - - try { - const nextIndex = addresses.length > 0 - ? Math.max(...addresses.map(a => a.index)) + 1 - : 1; - - // Create and switch to the new address - // Timeout guards against SDK hanging on Nostr publish when relay is not connected - try { - await Promise.race([ - sphere.switchToAddress(nextIndex), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)), - ]); - } catch (e) { - if (!(e instanceof Error && e.message === 'timeout')) throw e; - } - refreshAfterSwitch(); - setShowDropdown(false); - // Nametag recovery happens async in the SDK background. - // If recovered, 'nametag:recovered' event updates UI via useSphereEvents. - // If not, user can click "Register ID" in the header. - } catch (e) { - console.error('[AddressSelector] Failed to create new address:', e); - setShowDropdown(false); - } finally { - setIsSwitching(false); - } - }, [sphere, isSwitching, addresses, refreshAfterSwitch]); - - // Step 2a: Register nametag on the current address (already created in handleNewClick) - const handleCreateWithNametag = useCallback(async () => { + // #413: open the shared derive-address flow — it derives, prompts for a + // Unicity ID (with an explicit Skip) and handles nametag recovery itself. + const handleNewClick = useCallback(() => { if (!sphere || isSwitching) return; - const cleanTag = newNametag.trim().replace(/^@/, ''); - if (!cleanTag) return; - - setIsSwitching(true); - setNametagError(null); - - try { - // Check availability via Nostr transport - const existing = await resolveNametag(cleanTag); - if (existing) { - setNametagError(`@${cleanTag} is already taken`); - setIsSwitching(false); - return; - } - - // Register nametag on the current address - await sphere.registerNametag(cleanTag); - setShowNametagModal(false); - refreshAfterSwitch(); - } catch (e) { - const msg = e instanceof Error ? e.message : 'Failed to register nametag'; - console.error('[AddressSelector] Failed to register nametag:', e); - setNametagError(msg); - } finally { - setIsSwitching(false); + setShowDropdown(false); + if (onNewAddress) { + onNewAddress(); + } else { + setShowNewAddressModal(true); } - }, [sphere, isSwitching, newNametag, resolveNametag, refreshAfterSwitch]); + }, [sphere, isSwitching, onNewAddress]); - // Step 2b: Skip nametag (address already created in handleNewClick) - const handleCreateWithoutNametag = useCallback(() => { - setShowNametagModal(false); - }, []); + const handleNewAddressModalClose = useCallback(() => { + setShowNewAddressModal(false); + refreshAfterSwitch(); + }, [refreshAfterSwitch]); const sortedAddresses = useMemo(() => { return [...addresses].sort((a, b) => a.index - b.index); @@ -228,111 +151,9 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { return addr.chainPubkey; }, []); - // ========================================================================= - // Nametag Modal (shared between compact and full modes) - // ========================================================================= - const nametagModal = ( - - {showNametagModal && ( - <> - !isSwitching && setShowNametagModal(false)} - /> - -
- {/* Header */} -
-

- New Address -

- -
- - {/* Description */} -

- Choose a Unicity ID for this address, or skip for now. -

- - {/* Nametag input */} -
- @ - { - setNewNametag(e.target.value.toLowerCase()); - setNametagError(null); - }} - onKeyDown={e => { - if (e.key === 'Enter' && newNametag.trim() && nametagAvailability === 'available') handleCreateWithNametag(); - }} - placeholder="nametag" - disabled={isSwitching} - className="w-full pl-7 pr-8 py-2.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:border-orange-500 disabled:opacity-50 transition-colors" - /> - {/* Availability indicator */} - - {nametagAvailability === 'checking' && } - {nametagAvailability === 'available' && } - {nametagAvailability === 'taken' && } - -
- - {/* Availability / Error — fixed height to prevent layout shift */} -
- {nametagAvailability === 'taken' && !nametagError && ( -

@{newNametag.trim().replace(/^@/, '')} is already taken

- )} - {nametagError && ( -

{nametagError}

- )} - {nametagAvailability === 'available' && ( -

@{newNametag.trim().replace(/^@/, '')} is available

- )} -
- - {/* Buttons */} -
- - -
-
-
- - )} -
+ // New-address flow (#413) — fallback hosting when the parent doesn't take it + const newAddressModal = onNewAddress ? null : ( + ); // No sphere — show minimal nametag if available @@ -357,8 +178,11 @@ export function AddressSelector({ compact = true }: AddressSelectorProps) { if (compact) { return ( + <> + {/* Slide-in screen: rendered outside the relative wrapper so it fills + the wallet panel (nearest positioned ancestor), like Register ID. */} + {newAddressModal}
- {nametagModal}
+ ); } // Full mode return ( + <> + {newAddressModal}
- {nametagModal}
+ ); } diff --git a/src/components/wallet/shared/hooks/useNewAddressFlow.ts b/src/components/wallet/shared/hooks/useNewAddressFlow.ts new file mode 100644 index 00000000..4d98ba2a --- /dev/null +++ b/src/components/wallet/shared/hooks/useNewAddressFlow.ts @@ -0,0 +1,379 @@ +/** + * useNewAddressFlow — shared flow for deriving a new address and prompting + * for a Unicity ID (#413). Used by AddressSelector ("New") and + * AddressManagerModal ("Derive New Address") via NewAddressModal. + * + * Derivation switches the wallet to the new address; the nametag prompt is a + * follow-up on the already-active address. Skipping leaves the address usable + * and ID-less. + * + * The switch is only trusted once sphere.getCurrentAddressIndex() actually + * reports the new index: the SDK assigns sphere.identity AFTER awaited + * network work, so on a slow relay a bare timeout would leave us reading the + * PREVIOUS address's identity — and registering the ID onto the wrong key. + */ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { isValidNametag, normalizeNametag } from '@unicitylabs/sphere-sdk'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; +import { useSphereContext } from '../../../../sdk/hooks/core/useSphere'; +import { SPHERE_KEYS } from '../../../../sdk/queryKeys'; + +export type NewAddressStep = + | 'idle' + | 'deriving' + | 'nametag_input' + | 'registering' + | 'complete' + | 'error'; + +/** 'unknown' = transport could not verify — NOT the same as available. */ +export type NametagAvailability = 'idle' | 'checking' | 'available' | 'taken' | 'unknown'; + +export type NewAddressOutcome = 'registered' | 'recovered' | 'skipped'; + +export interface NewAddressState { + step: NewAddressStep; + /** Fatal derivation error (step === 'error') */ + error: string | null; + /** Inline registration error (step stays 'nametag_input') */ + registerError: string | null; + newAddress: { chainPubkey: string; index: number } | null; + /** Set when step === 'complete' (null for the skipped outcome) */ + completedNametag: string | null; + outcome: NewAddressOutcome | null; + /** The switch outlived the quick timeout — surface a "network is slow" hint. */ + slow: boolean; +} + +const INITIAL: NewAddressState = { + step: 'idle', + error: null, + registerError: null, + newAddress: null, + completedNametag: null, + outcome: null, + slow: false, +}; + +/** How long we wait before showing the "network is slow" hint. */ +const SWITCH_QUICK_MS = 5000; +/** Total time the switch may take before we give up with an error. */ +const SWITCH_EXTENDED_MS = 30000; +/** Poll interval while waiting for the switch to land (event is the fast path). */ +const SWITCH_POLL_MS = 500; + +/** Mirrors the SDK's isValidNametag error message (sphere-sdk core/Sphere.ts). */ +export const NAMETAG_FORMAT_HINT = + 'Use a-z, 0-9, _ or - (3-20 chars), or a phone number.'; + +export function cleanNametagInput(raw: string): string { + return raw.trim().replace(/^@/, '').toLowerCase(); +} + +/** + * The exact form the SDK registers and resolves: strip @, then + * normalizeNametag (lowercase, strip @unicity suffix, phone → E.164). + * Availability checks and success copy must use THIS, not the raw input. + */ +export function canonicalNametag(raw: string): string { + const cleaned = cleanNametagInput(raw); + try { + return normalizeNametag(cleaned); + } catch { + return cleaned; + } +} + +/** + * Resolve once the wallet is actually on `index`: fast path via the + * 'identity:changed' event, plus polling as a belt-and-braces fallback + * (the event may have fired before we subscribed). + */ +function waitForSwitch(sphere: Sphere, index: number, timeoutMs: number): Promise { + if (sphere.getCurrentAddressIndex() === index) return Promise.resolve(true); + return new Promise(resolve => { + let unsubscribe = () => {}; + const finish = (ok: boolean) => { + unsubscribe(); + clearInterval(poll); + clearTimeout(deadline); + resolve(ok); + }; + unsubscribe = sphere.on('identity:changed', ({ addressIndex }) => { + if (addressIndex === index) finish(true); + }); + const poll = setInterval(() => { + if (sphere.getCurrentAddressIndex() === index) finish(true); + }, SWITCH_POLL_MS); + const deadline = setTimeout(() => finish(false), timeoutMs); + }); +} + +export interface UseNewAddressFlowReturn { + state: NewAddressState; + /** Derive the next address and enter the nametag prompt (or complete via recovery). */ + start: () => Promise; + /** Validate + register the Unicity ID on the freshly derived (now current) address. */ + register: (nametag: string) => Promise; + /** Leave the address ID-less. */ + skip: () => void; + /** + * Resolve the nametag against the relay. 'unknown' means the availability + * could not be verified (transport missing/unreachable) — callers must not + * present it as free; registration is still allowed since the relay + * enforces uniqueness at publish time. + */ + checkAvailability: (nametag: string) => Promise<'available' | 'taken' | 'unknown'>; + /** Dismiss the inline registration error (e.g. when the user edits the input). */ + clearRegisterError: () => void; + reset: () => void; +} + +export function useNewAddressFlow(): UseNewAddressFlowReturn { + const { sphere, providers, resolveNametag } = useSphereContext(); + const queryClient = useQueryClient(); + const [state, setState] = useState(INITIAL); + // Index of a derivation attempt that errored out: the SDK may have already + // tracked it, so "Try Again" must reuse it instead of leaking an address. + const pendingIndexRef = useRef(null); + + // Don't lose the in-flight Nostr publish to a page close. + useEffect(() => { + if (state.step !== 'registering') return; + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = 'Your Unicity ID is being registered. Closing now may cause issues.'; + return e.returnValue; + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [state.step]); + + // Recovery race: the SDK's background sync may recover a previously + // registered nametag from the relay while the prompt is open — complete + // the flow instead of letting the user double-register. + useEffect(() => { + if (!sphere || state.step !== 'nametag_input') return; + const unsubscribe = sphere.on('nametag:recovered', ({ nametag }) => { + pendingIndexRef.current = null; + setState(prev => + prev.step === 'nametag_input' + ? { ...prev, step: 'complete', completedNametag: nametag, outcome: 'recovered' } + : prev, + ); + }); + return unsubscribe; + }, [sphere, state.step]); + + const refreshIdentityCaches = useCallback( + (nametag?: string) => { + // Write fresh identity directly — avoids the race where invalidation + // refetches before sphere.identity has been updated by the SDK. + if (sphere?.identity) { + const fresh = { ...sphere.identity }; + if (nametag && !fresh.nametag) fresh.nametag = nametag; + queryClient.setQueryData(SPHERE_KEYS.identity.current, fresh); + } + queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.identity.all }); + queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.payments.all }); + window.dispatchEvent(new Event('wallet-updated')); + }, + [sphere, queryClient], + ); + + const checkAvailability = useCallback( + async (nametagRaw: string): Promise<'available' | 'taken' | 'unknown'> => { + const cleanTag = canonicalNametag(nametagRaw); + if (!cleanTag) return 'unknown'; + // A transport without nametag resolution can't verify — never claim "free". + if (!providers?.transport?.resolveNametagInfo) return 'unknown'; + try { + const existing = await resolveNametag(cleanTag); + if (existing) return 'taken'; + // null can also mean the relay query silently timed out — only trust + // it as "free" while the transport is actually connected. + return providers.transport.isConnected?.() ? 'available' : 'unknown'; + } catch { + return 'unknown'; + } + }, + [providers, resolveNametag], + ); + + const start = useCallback(async () => { + if (!sphere) { + setState({ ...INITIAL, step: 'error', error: 'Wallet not initialized' }); + return; + } + setState({ ...INITIAL, step: 'deriving' }); + try { + // Include hidden addresses so a hidden index is never re-derived. + const tracked = sphere.getAllTrackedAddresses(); + const computedIndex = tracked.length > 0 + ? Math.max(...tracked.map(a => a.index)) + 1 + : 1; + const nextIndex = pendingIndexRef.current ?? computedIndex; + pendingIndexRef.current = nextIndex; + + const switchPromise = sphere.switchToAddress(nextIndex); + // Keep a timeout-outlived rejection from becoming unhandled, and + // remember it so the extended wait can surface the real failure. + const switchFailure: { error: Error | null } = { error: null }; + switchPromise.catch(e => { + switchFailure.error = e instanceof Error ? e : new Error(String(e)); + }); + + try { + await Promise.race([ + switchPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), SWITCH_QUICK_MS)), + ]); + } catch (e) { + if (!(e instanceof Error && e.message === 'timeout')) throw e; + // Slow network: the switch is still running. sphere.identity still + // points at the PREVIOUS address until the SDK finishes — keep + // waiting instead of reading (or registering onto) the wrong one. + setState(prev => ({ ...prev, slow: true })); + const switched = await waitForSwitch( + sphere, + nextIndex, + SWITCH_EXTENDED_MS - SWITCH_QUICK_MS, + ); + if (switchFailure.error) throw switchFailure.error; + if (!switched) { + throw new Error( + 'The address switch is taking too long — check your connection and try again.', + ); + } + } + + const derived = sphere.deriveAddress(nextIndex); + refreshIdentityCaches(); + + // Recovery race: switchToAddress restores a previously registered + // nametag from the local cache — no prompt needed then. The pubkey + // comparison guards against ever attributing another address's tag. + const identity = sphere.identity; + if (identity?.nametag && identity.chainPubkey === derived.publicKey) { + pendingIndexRef.current = null; + setState({ + ...INITIAL, + step: 'complete', + newAddress: { chainPubkey: derived.publicKey, index: nextIndex }, + completedNametag: identity.nametag, + outcome: 'recovered', + }); + return; + } + + setState({ + ...INITIAL, + step: 'nametag_input', + newAddress: { chainPubkey: derived.publicKey, index: nextIndex }, + }); + } catch (err) { + console.error('[useNewAddressFlow] Failed to derive address:', err); + // pendingIndexRef intentionally survives: "Try Again" reuses the index. + setState({ + ...INITIAL, + step: 'error', + error: err instanceof Error ? err.message : 'Failed to create address', + }); + } + }, [sphere, refreshIdentityCaches]); + + const register = useCallback( + async (nametagRaw: string) => { + if (!sphere) return; + const cleanTag = canonicalNametag(nametagRaw); + // Same gate the SDK enforces at registration — fail here, before any + // network round-trip, instead of after a green availability check. + if (!isValidNametag(cleanTag)) { + setState(prev => ({ + ...prev, + registerError: `Invalid Unicity ID format. ${NAMETAG_FORMAT_HINT}`, + })); + return; + } + // registerNametag acts on the CURRENT address — refuse if the wallet + // is no longer (or not yet) on the one this prompt was opened for. + if (state.newAddress && sphere.getCurrentAddressIndex() !== state.newAddress.index) { + setState(prev => ({ + ...prev, + registerError: 'The wallet is no longer on this address — close this dialog and try again.', + })); + return; + } + setState(prev => ({ ...prev, step: 'registering', registerError: null })); + try { + // Fresh pre-check; 'unknown' proceeds — the relay enforces uniqueness + // at publish time and registerNametag throws if the ID is taken. + const availability = await checkAvailability(cleanTag); + if (availability === 'taken') { + setState(prev => ({ + ...prev, + step: 'nametag_input', + registerError: `@${cleanTag} is already taken`, + })); + return; + } + + await sphere.registerNametag(cleanTag); + // The SDK stores the normalized form on the identity — show that. + const registered = sphere.identity?.nametag ?? cleanTag; + refreshIdentityCaches(registered); + pendingIndexRef.current = null; + setState(prev => ({ + ...prev, + step: 'complete', + completedNametag: registered, + outcome: 'registered', + })); + } catch (err) { + // Lost the recovery race: the address got its nametag while the + // prompt was open (SDK throws ALREADY_INITIALIZED). + if ((err as { code?: string })?.code === 'ALREADY_INITIALIZED') { + const recovered = sphere.identity?.nametag ?? cleanTag; + refreshIdentityCaches(); + pendingIndexRef.current = null; + setState(prev => ({ + ...prev, + step: 'complete', + completedNametag: recovered, + outcome: 'recovered', + })); + return; + } + console.error('[useNewAddressFlow] Failed to register Unicity ID:', err); + setState(prev => ({ + ...prev, + step: 'nametag_input', + registerError: err instanceof Error ? err.message : 'Failed to register Unicity ID', + })); + } + }, + [sphere, state.newAddress, checkAvailability, refreshIdentityCaches], + ); + + const skip = useCallback(() => { + pendingIndexRef.current = null; + setState(prev => ({ + ...prev, + step: 'complete', + completedNametag: null, + outcome: 'skipped', + })); + }, []); + + const clearRegisterError = useCallback(() => { + setState(prev => (prev.registerError ? { ...prev, registerError: null } : prev)); + }, []); + + const reset = useCallback(() => { + pendingIndexRef.current = null; + setState(INITIAL); + }, []); + + return { state, start, register, skip, checkAvailability, clearRegisterError, reset }; +} diff --git a/src/components/wallet/shared/modals/NewAddressModal.tsx b/src/components/wallet/shared/modals/NewAddressModal.tsx new file mode 100644 index 00000000..6898d07d --- /dev/null +++ b/src/components/wallet/shared/modals/NewAddressModal.tsx @@ -0,0 +1,328 @@ +/** + * NewAddressModal — the single Unicity ID prompt shown after deriving a new + * address (#413). Shared by AddressSelector ("New") and AddressManagerModal + * ("Derive New Address"). + * + * A slide-in screen INSIDE the wallet panel — the same presentation as the + * onboarding NametagScreen / RegisterNametagModal (@unicity-suffixed input, + * availability-colored border, gradient Register, "Skip for now"). Hosts must + * render it where the wallet panel shell is the nearest positioned ancestor. + */ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, ArrowRight } from 'lucide-react'; +import { isValidNametag } from '@unicitylabs/sphere-sdk'; +import { WalletScreen } from '../../ui/WalletScreen'; +import { ModalHeader } from '../../ui'; +import { truncateId } from '../../../../utils/identifiers'; +import { + useNewAddressFlow, + canonicalNametag, + NAMETAG_FORMAT_HINT, + type NametagAvailability, +} from '../hooks/useNewAddressFlow'; + +interface NewAddressModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function NewAddressModal({ isOpen, onClose }: NewAddressModalProps) { + const { state, start, register, skip, checkAvailability, clearRegisterError, reset } = + useNewAddressFlow(); + const [input, setInput] = useState(''); + const [availability, setAvailability] = useState('idle'); + const inputRef = useRef(null); + const startedRef = useRef(false); + + // The canonical (SDK-registered) form: lowercased, @-stripped, phone → E.164. + const cleanTag = canonicalNametag(input); + const isValid = isValidNametag(cleanTag); + + // Derive on open; full reset on close. + useEffect(() => { + if (isOpen && !startedRef.current) { + startedRef.current = true; + start(); + } + if (!isOpen) { + startedRef.current = false; + setInput(''); + setAvailability('idle'); + reset(); + } + }, [isOpen, start, reset]); + + // Focus the input when the prompt appears (after the dialog spring-in). + useEffect(() => { + if (state.step === 'nametag_input') { + const timer = setTimeout(() => inputRef.current?.focus(), 400); + return () => clearTimeout(timer); + } + }, [state.step]); + + // Debounced availability check — only for IDs the SDK would actually accept, + // so a green check can never precede a VALIDATION_ERROR. + useEffect(() => { + if (state.step !== 'nametag_input') return; + if (!cleanTag || !isValidNametag(cleanTag)) { + setAvailability('idle'); + return; + } + setAvailability('checking'); + let cancelled = false; + const timer = setTimeout(async () => { + const result = await checkAvailability(cleanTag); + if (!cancelled) setAvailability(result); + }, 500); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [cleanTag, state.step, checkAvailability]); + + // Auto-close on completion; skipped closes instantly, success flashes briefly. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => { + if (state.step !== 'complete') return; + if (state.outcome === 'skipped') { + onCloseRef.current(); + return; + } + const timer = setTimeout(() => onCloseRef.current(), 1400); + return () => clearTimeout(timer); + }, [state.step, state.outcome]); + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value.toLowerCase(); + // Permissive typing charset (+ and . are for phone numbers); the SDK + // format gate below decides whether Register is enabled. + if (/^[a-z0-9_\-+.]*$/.test(value)) { + setInput(value); + clearRegisterError(); + } + }; + + const canRegister = + state.step === 'nametag_input' && + isValid && + availability !== 'taken' && + availability !== 'checking'; + + const handleRegister = useCallback(() => { + if (!canRegister) return; + register(input); + }, [canRegister, register, input]); + + const handleSkip = useCallback(() => { + skip(); + }, [skip]); + + // Closing is a "skip" while the prompt is up; blocked while deriving/registering. + const canClose = state.step === 'nametag_input' || state.step === 'error'; + const handleRequestClose = useCallback(() => { + if (!canClose) return; + onClose(); + }, [canClose, onClose]); + + // Escape mirrors the backdrop/X close (and is equally blocked mid-work). + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleRequestClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, handleRequestClose]); + + const showFormatHint = cleanTag.length > 0 && !isValid; + + const inputBorderClass = + availability === 'taken' + ? 'border-red-400 dark:border-red-500/50 focus:border-red-500' + : availability === 'available' + ? 'border-emerald-400 dark:border-emerald-500/50 focus:border-emerald-500' + : availability === 'unknown' + ? 'border-amber-400 dark:border-amber-500/50 focus:border-amber-500' + : 'border-neutral-200 dark:border-white/8 focus:border-orange-500'; + + return ( + + + +
+ {state.step === 'deriving' && ( +
+ +

+ Generating new address... +

+ {state.slow && ( +

+ Network is slow — still working... +

+ )} +
+ )} + + {state.step === 'nametag_input' && ( + <> + {state.newAddress && ( +
+ + Address #{state.newAddress.index} + + + {truncateId(state.newAddress.chainPubkey)} + +
+ )} + +

+ Choose a unique Unicity ID for this + address to receive tokens easily, or skip for now. +

+ +
+
+
+ {availability === 'checking' && ( + + )} + {availability === 'available' && ( + + )} + {availability === 'taken' && ( + + )} + {availability === 'unknown' && ( + + )} + + @unicity + +
+ { + if (e.key === 'Enter') handleRegister(); + }} + placeholder="id" + maxLength={32} + className={`w-full bg-neutral-100 dark:bg-white/4 border-2 rounded-xl py-3 pl-4 pr-28 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-600 focus:outline-none focus:bg-white dark:focus:bg-white/6 transition-all ${inputBorderClass}`} + /> +
+ + {/* Availability / validation status — fixed height to prevent layout shift */} +
+ {showFormatHint && ( +

+ {NAMETAG_FORMAT_HINT} +

+ )} + {!showFormatHint && availability === 'taken' && ( +

+ @{cleanTag} is already taken +

+ )} + {!showFormatHint && availability === 'available' && ( +

+ @{cleanTag} is available +

+ )} + {!showFormatHint && availability === 'unknown' && ( +

+ Can't verify availability right now — registration will fail if the ID + is taken. +

+ )} +
+ + + + + + {state.registerError && ( +

+ {state.registerError} +

+ )} +
+ + )} + + {state.step === 'registering' && ( +
+ +

+ Registering @{cleanTag}... +

+

+ + Don't close this window +

+
+ )} + + {state.step === 'complete' && state.outcome !== 'skipped' && ( +
+ +

+ {state.outcome === 'recovered' ? ( + <> + @{state.completedNametag} was + recovered for this address + + ) : ( + <> + @{state.completedNametag} is ready + + )} +

+
+ )} + + {state.step === 'error' && ( +
+ +

{state.error}

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/components/wallet/shared/modals/index.ts b/src/components/wallet/shared/modals/index.ts index 30fb1d9a..3285007e 100644 --- a/src/components/wallet/shared/modals/index.ts +++ b/src/components/wallet/shared/modals/index.ts @@ -1,2 +1,3 @@ export { BackupWalletModal } from "./BackupWalletModal"; export { LogoutConfirmModal } from "./LogoutConfirmModal"; +export { NewAddressModal } from "./NewAddressModal"; diff --git a/tests/unit/components/NewAddressModal.test.tsx b/tests/unit/components/NewAddressModal.test.tsx new file mode 100644 index 00000000..7edd3247 --- /dev/null +++ b/tests/unit/components/NewAddressModal.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { NewAddressModal } from '../../../src/components/wallet/shared/modals/NewAddressModal'; +import type { + NewAddressState, + UseNewAddressFlowReturn, +} from '../../../src/components/wallet/shared/hooks/useNewAddressFlow'; + +const NEW_PUBKEY = '02' + 'cd'.repeat(32); + +let flow: UseNewAddressFlowReturn; + +// Keep the real cleanNametagInput/NAMETAG_FORMAT_HINT exports; fake only the hook. +vi.mock( + '../../../src/components/wallet/shared/hooks/useNewAddressFlow', + async importOriginal => { + const actual = await importOriginal>(); + return { ...actual, useNewAddressFlow: () => flow }; + }, +); + +function makeState(overrides: Partial = {}): NewAddressState { + return { + step: 'nametag_input', + error: null, + registerError: null, + newAddress: { chainPubkey: NEW_PUBKEY, index: 4 }, + completedNametag: null, + outcome: null, + slow: false, + ...overrides, + }; +} + +beforeEach(() => { + flow = { + state: makeState(), + start: vi.fn(async () => {}), + register: vi.fn(async () => {}), + skip: vi.fn(), + checkAvailability: vi.fn(async () => 'available' as const), + clearRegisterError: vi.fn(), + reset: vi.fn(), + }; +}); + +function renderModal() { + return render(); +} + +describe('NewAddressModal — nametag prompt', () => { + it('shows the new address pubkey-first and starts the flow on open', () => { + renderModal(); + expect(flow.start).toHaveBeenCalledTimes(1); + expect(screen.getByText('Address #4')).toBeDefined(); + // MetaMask-style 6+4 truncation of the chain pubkey + expect( + screen.getByText(`${NEW_PUBKEY.slice(0, 6)}...${NEW_PUBKEY.slice(-4)}`), + ).toBeDefined(); + }); + + it('disables Register and shows the SDK format hint for an invalid ID', () => { + renderModal(); + fireEvent.change(screen.getByPlaceholderText('id'), { target: { value: 'ab' } }); + + expect(screen.getByText(/use a-z, 0-9/i)).toBeDefined(); + const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; + expect(register.disabled).toBe(true); + }); + + it('treats an unverifiable availability as "cannot verify", not as free', async () => { + flow.checkAvailability = vi.fn(async () => 'unknown' as const); + renderModal(); + fireEvent.change(screen.getByPlaceholderText('id'), { + target: { value: 'validname' }, + }); + + await waitFor( + () => expect(screen.getByText(/can't verify availability/i)).toBeDefined(), + { timeout: 2000 }, + ); + expect(screen.queryByText(/is available/i)).toBeNull(); + // Registration stays possible — the relay is the final uniqueness gate. + const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; + expect(register.disabled).toBe(false); + fireEvent.click(register); + expect(flow.register).toHaveBeenCalledWith('validname'); + }); + + it('blocks Register when the ID is taken', async () => { + flow.checkAvailability = vi.fn(async () => 'taken' as const); + renderModal(); + fireEvent.change(screen.getByPlaceholderText('id'), { + target: { value: 'takenname' }, + }); + + await waitFor( + () => expect(screen.getByText('@takenname is already taken')).toBeDefined(), + { timeout: 2000 }, + ); + const register = screen.getByRole('button', { name: 'Register' }) as HTMLButtonElement; + expect(register.disabled).toBe(true); + }); + + it('Skip hands off to the flow', () => { + renderModal(); + fireEvent.click(screen.getByRole('button', { name: 'Skip for now' })); + expect(flow.skip).toHaveBeenCalledTimes(1); + }); + + it('cannot be closed while registering — back button and Escape are inert', () => { + flow.state = makeState({ step: 'registering' }); + const onClose = vi.fn(); + render(); + + expect(screen.getByText(/don't close this window/i)).toBeDefined(); + + const backButton = document.querySelector('button:disabled') as HTMLButtonElement; + expect(backButton).not.toBeNull(); + fireEvent.click(backButton); + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('Escape closes the prompt while input is possible', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('never renders a DIRECT:// value', () => { + const { container } = renderModal(); + expect(container.innerHTML).not.toContain('DIRECT://'); + expect(document.body.innerHTML).not.toContain('DIRECT://'); + }); +}); diff --git a/tests/unit/components/addressEntryPoints.test.tsx b/tests/unit/components/addressEntryPoints.test.tsx new file mode 100644 index 00000000..b8be14e0 --- /dev/null +++ b/tests/unit/components/addressEntryPoints.test.tsx @@ -0,0 +1,96 @@ +/** + * #413 wiring: both derive-address entry points must open the shared + * NewAddressModal (and refresh their data when it closes) instead of + * deriving silently. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AddressSelector } from '../../../src/components/wallet/shared/components/AddressSelector'; +import { AddressManagerModal } from '../../../src/components/wallet/L3/modals/AddressManagerModal'; + +const PUBKEY = '02' + 'ab'.repeat(32); + +// The shared flow itself is covered by its own tests — stub it here. +vi.mock('../../../src/components/wallet/shared/modals/NewAddressModal', () => ({ + NewAddressModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +let fakeSphere: Record; +vi.mock('../../../src/sdk/hooks/core/useSphere', () => ({ + useSphereContext: () => ({ sphere: fakeSphere, isDiscoveringAddresses: false }), +})); + +vi.mock('../../../src/sdk', () => ({ + useIdentity: () => ({ nametag: null, chainPubkey: PUBKEY }), +})); + +function Wrapper({ children }: { children: ReactNode }) { + const [qc] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return {children}; +} + +beforeEach(() => { + fakeSphere = { + identity: { chainPubkey: PUBKEY }, + getCurrentAddressIndex: vi.fn(() => 0), + getActiveAddresses: vi.fn(() => [{ index: 0, chainPubkey: PUBKEY, hidden: false }]), + getAllTrackedAddresses: vi.fn(() => [{ index: 0, chainPubkey: PUBKEY, hidden: false }]), + setAddressHidden: vi.fn(async () => {}), + on: vi.fn(() => () => {}), + }; +}); + +describe('AddressSelector — "New" opens the Unicity ID flow', () => { + it('opens NewAddressModal instead of deriving silently', () => { + render(, { wrapper: Wrapper }); + + // Open the dropdown (trigger shows the truncated pubkey), then hit "New". + fireEvent.click(screen.getByText(`${PUBKEY.slice(0, 6)}...${PUBKEY.slice(-4)}`)); + expect(screen.queryByTestId('new-address-modal')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(screen.getByTestId('new-address-modal')).toBeDefined(); + // No direct derivation from the selector anymore. + expect(fakeSphere.switchToAddress).toBeUndefined(); + }); + + it('delegates to the panel host when onNewAddress is provided', () => { + const onNewAddress = vi.fn(); + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByText(`${PUBKEY.slice(0, 6)}...${PUBKEY.slice(-4)}`)); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(onNewAddress).toHaveBeenCalledTimes(1); + // The selector must not double-host the flow in this mode. + expect(screen.queryByTestId('new-address-modal')).toBeNull(); + }); +}); + +describe('AddressManagerModal — "Derive New Address" opens the Unicity ID flow', () => { + it('opens NewAddressModal and refreshes the list when the flow closes', () => { + render(, { wrapper: Wrapper }); + + const callsBefore = (fakeSphere.getAllTrackedAddresses as ReturnType).mock + .calls.length; + + fireEvent.click(screen.getByRole('button', { name: /derive new address/i })); + expect(screen.getByTestId('new-address-modal')).toBeDefined(); + + fireEvent.click(screen.getByRole('button', { name: 'close-flow' })); + expect(screen.queryByTestId('new-address-modal')).toBeNull(); + + const callsAfter = (fakeSphere.getAllTrackedAddresses as ReturnType).mock + .calls.length; + expect(callsAfter).toBeGreaterThan(callsBefore); + }); +}); diff --git a/tests/unit/hooks/useNewAddressFlow.test.tsx b/tests/unit/hooks/useNewAddressFlow.test.tsx new file mode 100644 index 00000000..f9ef3f49 --- /dev/null +++ b/tests/unit/hooks/useNewAddressFlow.test.tsx @@ -0,0 +1,430 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { createElement, useState, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + useNewAddressFlow, + cleanNametagInput, + canonicalNametag, +} from '../../../src/components/wallet/shared/hooks/useNewAddressFlow'; + +const OLD_PUBKEY = '02' + 'ab'.repeat(32); +const NEW_PUBKEY = '02' + 'cd'.repeat(32); + +type EventHandler = (data: Record) => void; + +interface FakeSphere extends Record { + currentIndex: number; + identity: Record | null; + _handlers: Record; +} + +// The hook reads sphere/providers/resolveNametag via useSphereContext — swap in fakes. +let fakeContext: { + sphere: FakeSphere | null; + providers: Record | null; + resolveNametag: (nametag: string) => Promise; +}; +vi.mock('../../../src/sdk/hooks/core/useSphere', () => ({ + useSphereContext: () => fakeContext, +})); + +function makeSphere(overrides: Record = {}): FakeSphere { + const handlers: Record = {}; + const sphere: FakeSphere = { + currentIndex: 0, + identity: null, + _handlers: handlers, + getCurrentAddressIndex: vi.fn(() => sphere.currentIndex), + getAllTrackedAddresses: vi.fn(() => [{ index: 0 }, { index: 3, hidden: true }]), + switchToAddress: vi.fn(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: NEW_PUBKEY }; + }), + deriveAddress: vi.fn((index: number) => ({ + publicKey: NEW_PUBKEY, + privateKey: 'priv', + path: `m/44'/1'/0'/0/${index}`, + index, + })), + registerNametag: vi.fn(async () => {}), + on: vi.fn((event: string, cb: EventHandler) => { + handlers[event] = cb; + return () => { + delete handlers[event]; + }; + }), + }; + return Object.assign(sphere, overrides); +} + +function Wrapper({ children }: { children: ReactNode }) { + const [qc] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return createElement(QueryClientProvider, { client: qc }, children); +} + +function setup() { + return renderHook(() => useNewAddressFlow(), { wrapper: Wrapper }); +} + +beforeEach(() => { + fakeContext = { + sphere: makeSphere(), + providers: { + transport: { resolveNametagInfo: vi.fn(), isConnected: vi.fn(() => true) }, + }, + resolveNametag: vi.fn(async () => null), + }; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('nametag input normalization', () => { + it('cleanNametagInput trims, strips the leading @ and lowercases', () => { + expect(cleanNametagInput(' @Alice_1 ')).toBe('alice_1'); + expect(cleanNametagInput('BOB')).toBe('bob'); + }); + + it('canonicalNametag converts phone numbers to E.164 like the SDK does', () => { + expect(canonicalNametag('415.555.2671')).toBe('+14155552671'); + expect(canonicalNametag('@Bob_1')).toBe('bob_1'); + }); +}); + +describe('useNewAddressFlow — derivation', () => { + it('derives the next index across ALL tracked addresses (hidden included)', async () => { + const { result } = setup(); + await act(() => result.current.start()); + + const sphere = fakeContext.sphere!; + // max(0, 3-hidden) + 1 = 4 — a hidden index must never be re-derived + expect(sphere.switchToAddress).toHaveBeenCalledWith(4); + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.newAddress).toEqual({ chainPubkey: NEW_PUBKEY, index: 4 }); + }); + + it('starts from index 1 when nothing is tracked', async () => { + fakeContext.sphere = makeSphere({ getAllTrackedAddresses: vi.fn(() => []) }); + const { result } = setup(); + await act(() => result.current.start()); + expect(fakeContext.sphere.switchToAddress).toHaveBeenCalledWith(1); + }); + + it('completes as "recovered" without a prompt when the SDK restored a nametag during the switch', async () => { + const sphere = makeSphere({ + switchToAddress: vi.fn(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: NEW_PUBKEY, nametag: 'alice' }; + }), + }); + fakeContext.sphere = sphere; + const { result } = setup(); + await act(() => result.current.start()); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('recovered'); + expect(result.current.state.completedNametag).toBe('alice'); + }); + + it('never attributes a nametag from an identity with a different pubkey', async () => { + // Identity claims a nametag but belongs to another key — must prompt, not "recover". + const sphere = makeSphere({ + switchToAddress: vi.fn(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: OLD_PUBKEY, nametag: 'not_mine' }; + }), + }); + fakeContext.sphere = sphere; + const { result } = setup(); + await act(() => result.current.start()); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.completedNametag).toBeNull(); + }); + + it('lands on the error step when derivation fails', async () => { + fakeContext.sphere = makeSphere({ + switchToAddress: vi.fn(async () => { + throw new Error('boom'); + }), + }); + const { result } = setup(); + await act(() => result.current.start()); + expect(result.current.state.step).toBe('error'); + expect(result.current.state.error).toBe('boom'); + }); +}); + +describe('useNewAddressFlow — slow switch (timeout) handling', () => { + it('keeps waiting after the quick timeout instead of trusting the OLD identity', async () => { + vi.useFakeTimers(); + // Old address has a nametag — the buggy pattern would flash a false "recovered". + const sphere = makeSphere({ + identity: { chainPubkey: OLD_PUBKEY, nametag: 'old_tag' }, + switchToAddress: vi.fn(() => new Promise(() => {})), // hangs + }); + fakeContext.sphere = sphere; + const { result } = setup(); + + let startPromise: Promise | undefined; + act(() => { + startPromise = result.current.start(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + // Still deriving — no prompt, no false recovered, slow hint on. + expect(result.current.state.step).toBe('deriving'); + expect(result.current.state.slow).toBe(true); + expect(result.current.state.outcome).toBeNull(); + + // The SDK finishes the switch in the background. + sphere.currentIndex = 4; + sphere.identity = { chainPubkey: NEW_PUBKEY }; + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + await startPromise; + }); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.completedNametag).toBeNull(); + }); + + it('errors out when the switch never completes within the extended window', async () => { + vi.useFakeTimers(); + const sphere = makeSphere({ + switchToAddress: vi.fn(() => new Promise(() => {})), // hangs forever + }); + fakeContext.sphere = sphere; + const { result } = setup(); + + let startPromise: Promise | undefined; + act(() => { + startPromise = result.current.start(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + await startPromise; + }); + + expect(result.current.state.step).toBe('error'); + expect(result.current.state.error).toMatch(/taking too long/i); + }); + + it('reuses the same index on retry after a failure — no address leak', async () => { + const sphere = makeSphere({ + switchToAddress: vi + .fn() + .mockRejectedValueOnce(new Error('provider init failed')) + .mockImplementation(async (index: number) => { + sphere.currentIndex = index; + sphere.identity = { chainPubkey: NEW_PUBKEY }; + }), + // After the failed attempt the SDK has already tracked index 4. + getAllTrackedAddresses: vi + .fn() + .mockReturnValueOnce([{ index: 0 }, { index: 3, hidden: true }]) + .mockReturnValue([{ index: 0 }, { index: 3, hidden: true }, { index: 4 }]), + }); + fakeContext.sphere = sphere; + const { result } = setup(); + + await act(() => result.current.start()); + expect(result.current.state.step).toBe('error'); + + await act(() => result.current.start()); + expect(result.current.state.step).toBe('nametag_input'); + // Both attempts used index 4 — a naive max+1 would have leaked to 5. + expect(sphere.switchToAddress).toHaveBeenNthCalledWith(1, 4); + expect(sphere.switchToAddress).toHaveBeenNthCalledWith(2, 4); + }); +}); + +describe('useNewAddressFlow — availability', () => { + it('reports taken / available from the resolver (canonical form)', async () => { + fakeContext.resolveNametag = vi.fn(async (tag: string) => + tag === 'taken_tag' ? { nametag: tag } : null, + ); + const { result } = setup(); + expect(await result.current.checkAvailability('taken_tag')).toBe('taken'); + expect(await result.current.checkAvailability('free_tag')).toBe('available'); + // Phone input is resolved in its E.164 form — the key the SDK registers. + expect(await result.current.checkAvailability('415.555.2671')).toBe('available'); + expect(fakeContext.resolveNametag).toHaveBeenCalledWith('+14155552671'); + }); + + it('reports "unknown" (cannot verify) when the resolver throws', async () => { + fakeContext.resolveNametag = vi.fn(async () => { + throw new Error('relay down'); + }); + const { result } = setup(); + expect(await result.current.checkAvailability('any_tag')).toBe('unknown'); + }); + + it('does not trust a null result while the transport is disconnected', async () => { + (fakeContext.providers as { transport: { isConnected: () => boolean } }).transport.isConnected = + () => false; + const { result } = setup(); + expect(await result.current.checkAvailability('any_tag')).toBe('unknown'); + }); + + it('reports "unknown" when the transport cannot resolve nametags at all', async () => { + fakeContext.providers = { transport: {} }; + const { result } = setup(); + expect(await result.current.checkAvailability('any_tag')).toBe('unknown'); + }); +}); + +describe('useNewAddressFlow — registration', () => { + async function startToPrompt() { + const rendered = setup(); + await act(() => rendered.result.current.start()); + expect(rendered.result.current.state.step).toBe('nametag_input'); + return rendered; + } + + it('registers a valid, available ID', async () => { + const { result } = await startToPrompt(); + await act(() => result.current.register('@Bob_1')); + + expect(fakeContext.sphere!.registerNametag).toHaveBeenCalledWith('bob_1'); + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('registered'); + expect(result.current.state.completedNametag).toBe('bob_1'); + }); + + it('registers phone numbers in the E.164 form the SDK uses', async () => { + const { result } = await startToPrompt(); + await act(() => result.current.register('415.555.2671')); + expect(fakeContext.sphere!.registerNametag).toHaveBeenCalledWith('+14155552671'); + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.completedNametag).toBe('+14155552671'); + }); + + it('shows the SDK-stored nametag on completion when the SDK normalized it', async () => { + const sphere = fakeContext.sphere!; + sphere.registerNametag = vi.fn(async () => { + sphere.identity = { ...(sphere.identity ?? {}), nametag: 'sdk_normalized' }; + }); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob_1')); + expect(result.current.state.completedNametag).toBe('sdk_normalized'); + }); + + it('rejects IDs the SDK would reject — before any network call', async () => { + const { result } = await startToPrompt(); + + for (const bad of ['ab', 'my.name', 'a'.repeat(21)]) { + await act(() => result.current.register(bad)); + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.registerError).toContain('Invalid Unicity ID format'); + } + expect(fakeContext.resolveNametag).not.toHaveBeenCalled(); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('refuses to register when the wallet is no longer on the new address', async () => { + const { result } = await startToPrompt(); + fakeContext.sphere!.currentIndex = 0; + await act(() => result.current.register('bob_1')); + + expect(result.current.state.registerError).toMatch(/no longer on this address/i); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('shows "already taken" and returns to the prompt when the pre-check hits', async () => { + fakeContext.resolveNametag = vi.fn(async () => ({ nametag: 'bob' })); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob')); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.registerError).toBe('@bob is already taken'); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('still registers when availability cannot be verified (relay enforces uniqueness)', async () => { + fakeContext.resolveNametag = vi.fn(async () => { + throw new Error('relay down'); + }); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob_2')); + + expect(fakeContext.sphere!.registerNametag).toHaveBeenCalledWith('bob_2'); + expect(result.current.state.step).toBe('complete'); + }); + + it('surfaces registration failures inline and keeps the prompt', async () => { + fakeContext.sphere = makeSphere({ + registerNametag: vi.fn(async () => { + throw new Error('Failed to register Unicity ID. It may already be taken.'); + }), + }); + const { result } = await startToPrompt(); + await act(() => result.current.register('bob_3')); + + expect(result.current.state.step).toBe('nametag_input'); + expect(result.current.state.registerError).toContain('may already be taken'); + }); + + it('treats ALREADY_INITIALIZED as a lost recovery race, not an error', async () => { + const err = Object.assign(new Error('already has a nametag'), { + code: 'ALREADY_INITIALIZED', + }); + fakeContext.sphere = makeSphere({ + registerNametag: vi.fn(async () => { + throw err; + }), + }); + const { result } = await startToPrompt(); + fakeContext.sphere.identity = { chainPubkey: NEW_PUBKEY, nametag: 'raced' }; + await act(() => result.current.register('bob_4')); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('recovered'); + expect(result.current.state.completedNametag).toBe('raced'); + }); +}); + +describe('useNewAddressFlow — skip & recovery race', () => { + it('skip completes without registering', async () => { + const { result } = setup(); + await act(() => result.current.start()); + act(() => result.current.skip()); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('skipped'); + expect(result.current.state.completedNametag).toBeNull(); + expect(fakeContext.sphere!.registerNametag).not.toHaveBeenCalled(); + }); + + it('a background nametag:recovered event while the prompt is open completes the flow', async () => { + const { result } = setup(); + await act(() => result.current.start()); + expect(result.current.state.step).toBe('nametag_input'); + + const handlers = fakeContext.sphere!._handlers; + await waitFor(() => expect(handlers['nametag:recovered']).toBeDefined()); + + act(() => handlers['nametag:recovered']({ nametag: 'zoe' })); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.outcome).toBe('recovered'); + expect(result.current.state.completedNametag).toBe('zoe'); + }); + + it('clearRegisterError dismisses the inline error only', async () => { + const { result } = setup(); + await act(() => result.current.start()); + await act(() => result.current.register('ab')); + expect(result.current.state.registerError).not.toBeNull(); + + act(() => result.current.clearRegisterError()); + expect(result.current.state.registerError).toBeNull(); + expect(result.current.state.step).toBe('nametag_input'); + }); +}); diff --git a/tests/unit/no-direct-in-ui.test.ts b/tests/unit/no-direct-in-ui.test.ts index b9ab38da..cdeaadc2 100644 --- a/tests/unit/no-direct-in-ui.test.ts +++ b/tests/unit/no-direct-in-ui.test.ts @@ -3,10 +3,13 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -// Files converted to pubkey-first display by #411. LookupModal (My Public Keys), -// DocsPage and DevelopersPage are intentionally NOT listed — DIRECT lives there. +// Files converted to pubkey-first display by #411 (+ the #413 derive-address +// flow). LookupModal (My Public Keys), DocsPage and DevelopersPage are +// intentionally NOT listed — DIRECT lives there. const CHANGED_UI_FILES = [ 'src/components/wallet/shared/components/AddressSelector.tsx', + 'src/components/wallet/shared/modals/NewAddressModal.tsx', + 'src/components/wallet/shared/hooks/useNewAddressFlow.ts', 'src/components/wallet/L3/modals/AddressManagerModal.tsx', 'src/components/wallet/L3/modals/SendModal.tsx', 'src/components/wallet/L3/modals/SendPaymentRequestModal.tsx',