From 74cf3124b937f0b5bf126a21d0415b48fa62de60 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:17:58 +0530 Subject: [PATCH] fix(wallet): reveal existing recovery phrase instead of regenerating the wallet (#3849) --- .../panels/RecoveryPhrasePanel.test.tsx | 33 +- .../settings/panels/RecoveryPhrasePanel.tsx | 994 +++++++++++++----- .../__tests__/RecoveryPhrasePanel.test.tsx | 662 +++++++++++- .../wallet/setupLocalWalletFromMnemonic.ts | 8 +- app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 12 + app/src/lib/i18n/de.ts | 12 + app/src/lib/i18n/en.ts | 12 + app/src/lib/i18n/es.ts | 12 + app/src/lib/i18n/fr.ts | 12 + app/src/lib/i18n/hi.ts | 12 + app/src/lib/i18n/id.ts | 12 + app/src/lib/i18n/it.ts | 12 + app/src/lib/i18n/ko.ts | 12 + app/src/lib/i18n/pl.ts | 12 + app/src/lib/i18n/pt.ts | 12 + app/src/lib/i18n/ru.ts | 12 + app/src/lib/i18n/zh-CN.ts | 11 + app/src/services/walletApi.ts | 25 + .../tools/impl/network/polymarket_tests.rs | 2 + src/openhuman/wallet/execution_tests.rs | 2 + src/openhuman/wallet/mod.rs | 3 +- src/openhuman/wallet/ops.rs | 284 ++++- src/openhuman/wallet/schemas.rs | 53 +- src/openhuman/wallet/test_support.rs | 2 + 25 files changed, 1960 insertions(+), 275 deletions(-) diff --git a/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx b/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx index d0f2ccade..9ca0c01a0 100644 --- a/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx +++ b/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import RecoveryPhrasePanel from './RecoveryPhrasePanel'; @@ -30,14 +30,43 @@ vi.mock('../../../features/wallet/setupLocalWalletFromMnemonic', () => ({ persistLocalWalletFromMnemonic: vi.fn(), })); +// Mock fetchWalletStatus to return unconfigured by default (no existing wallet). +vi.mock('../../../services/walletApi', () => ({ + fetchWalletStatus: vi.fn(async () => ({ + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, + })), + setupLocalWallet: vi.fn(async () => ({ + configured: true, + onboardingCompleted: true, + consentGranted: true, + secretStored: true, + source: 'generated', + mnemonicWordCount: 12, + accounts: [], + updatedAtMs: Date.now(), + })), +})); + describe('', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('initially hides the recovery phrase and reveals it when clicking the reveal button', () => { + it('initially hides the recovery phrase and reveals it when clicking the reveal button', async () => { render(); + // Wait for wallet status check to complete and enter generate mode + await waitFor(() => + expect(screen.queryByLabelText('mnemonic.revealPhrase')).toBeInTheDocument() + ); + const copyButton = screen.getByText('mnemonic.copyToClipboard').closest('button')!; expect(copyButton).toBeDisabled(); diff --git a/app/src/components/settings/panels/RecoveryPhrasePanel.tsx b/app/src/components/settings/panels/RecoveryPhrasePanel.tsx index 2a9883da7..db61fde7e 100644 --- a/app/src/components/settings/panels/RecoveryPhrasePanel.tsx +++ b/app/src/components/settings/panels/RecoveryPhrasePanel.tsx @@ -1,8 +1,13 @@ -import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import { persistLocalWalletFromMnemonic } from '../../../features/wallet/setupLocalWalletFromMnemonic'; import { useT } from '../../../lib/i18n/I18nContext'; import { useCoreState } from '../../../providers/CoreStateProvider'; +import { + fetchWalletStatus, + revealRecoveryPhrase, + type WalletStatus, +} from '../../../services/walletApi'; import { generateMnemonicPhrase, MNEMONIC_GENERATE_WORD_COUNT, @@ -18,28 +23,105 @@ const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const; const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT; +// Panel mode flow: +// - 'loading': initial — fetching wallet status. +// - 'view': existing wallet found — shows metadata, no mnemonic displayed. +// - 'replace-confirm': user clicked "Replace wallet" — shows warning dialog. +// - 'generate': no wallet (or post-confirm replace) — generate new phrase flow. +// - 'import': import an existing phrase. +type PanelMode = 'loading' | 'view' | 'replace-confirm' | 'generate' | 'import'; + const RecoveryPhrasePanel = () => { const { t } = useT(); const { navigateBack } = useSettingsNavigation(); const { snapshot, setEncryptionKey } = useCoreState(); const user = snapshot.currentUser; - const [mode, setMode] = useState<'generate' | 'import'>('generate'); + const [mode, setMode] = useState('loading'); + const [walletStatus, setWalletStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + + // Generate mode state + const [mnemonic, setMnemonic] = useState(null); const [copied, setCopied] = useState(false); const [confirmed, setConfirmed] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); const [revealed, setRevealed] = useState(false); - const mnemonic = useMemo(() => generateMnemonicPhrase(), []); - const words = useMemo(() => mnemonic.split(' '), [mnemonic]); + // Replace-mode state: tracks that the user went through the replace flow + const [isReplace, setIsReplace] = useState(false); + // View mode: reveal existing phrase + const [viewRevealed, setViewRevealed] = useState(false); + const [viewMnemonic, setViewMnemonic] = useState(null); + const [viewRevealLoading, setViewRevealLoading] = useState(false); + const [viewRevealError, setViewRevealError] = useState(null); + const [viewCopied, setViewCopied] = useState(false); + + // Import mode state const [selectedWordCount, setSelectedWordCount] = useState(IMPORT_SLOTS_INITIAL); const [importWords, setImportWords] = useState(Array(IMPORT_SLOTS_INITIAL).fill('')); const [importValid, setImportValid] = useState(null); + + // Shared + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + // ── On mount: check for existing wallet ────────────────────────────────── + useEffect(() => { + let cancelled = false; + const checkWallet = async () => { + try { + const status = await fetchWalletStatus(); + if (cancelled) return; + setWalletStatus(status); + if (status.configured && status.onboardingCompleted) { + setMode('view'); + } else { + // No configured wallet — generate mode. Generate phrase now. + const phrase = generateMnemonicPhrase(); + setMnemonic(phrase); + setMode('generate'); + } + } catch (e) { + if (cancelled) return; + // If status fetch fails, degrade gracefully: show error in view mode. + // Do NOT silently generate a phrase that could overwrite an existing wallet. + setStatusError( + e instanceof Error ? e.message : 'Failed to check wallet status. Please try again.' + ); + setMode('view'); + } + }; + void checkWallet(); + return () => { + cancelled = true; + }; + }, []); + + // ── Transition into generate mode after replace confirmation ───────────── + const handleConfirmReplace = useCallback(() => { + const phrase = generateMnemonicPhrase(); + setMnemonic(phrase); + setIsReplace(true); + setConfirmed(false); + setRevealed(false); + setError(null); + setMode('generate'); + }, []); + + // ── Transition into import mode after replace confirmation ──────────────── + const handleImportReplace = useCallback(() => { + setIsReplace(true); + setImportValid(null); + setError(null); + setSelectedWordCount(IMPORT_SLOTS_INITIAL); + setImportWords(Array(IMPORT_SLOTS_INITIAL).fill('')); + setMode('import'); + }, []); + useEffect(() => { if (copied) { const timer = setTimeout(() => setCopied(false), 3000); @@ -47,6 +129,30 @@ const RecoveryPhrasePanel = () => { } }, [copied]); + useEffect(() => { + if (viewCopied) { + const timer = setTimeout(() => setViewCopied(false), 3000); + return () => clearTimeout(timer); + } + }, [viewCopied]); + + // Security: clear plaintext phrase from state when unmounting. + useEffect(() => { + return () => { + setViewMnemonic(null); + setViewRevealed(false); + }; + }, []); + + // Clear phrase when navigating away from view mode. + useEffect(() => { + if (mode !== 'view') { + setViewMnemonic(null); + setViewRevealed(false); + setViewRevealError(null); + } + }, [mode]); + const switchMode = useCallback((nextMode: 'generate' | 'import') => { setMode(nextMode); setConfirmed(false); @@ -79,6 +185,7 @@ const RecoveryPhrasePanel = () => { }, [success, navigateBack]); const handleCopy = useCallback(async () => { + if (!mnemonic) return; try { await navigator.clipboard.writeText(mnemonic); setCopied(true); @@ -177,6 +284,10 @@ const RecoveryPhrasePanel = () => { setLoading(false); return; } + if (!mnemonic) { + setLoading(false); + return; + } phraseToUse = mnemonic; } @@ -188,6 +299,8 @@ const RecoveryPhrasePanel = () => { mnemonic: phraseToUse, source: mode === 'generate' ? 'generated' : 'imported', setEncryptionKey, + // Only pass force=true when the user has gone through the replace confirmation flow. + force: isReplace ? true : undefined, }); setSuccess(true); } catch (e) { @@ -197,12 +310,599 @@ const RecoveryPhrasePanel = () => { } }; + const handleViewCopy = useCallback(async () => { + if (!viewMnemonic) return; + try { + await navigator.clipboard.writeText(viewMnemonic); + setViewCopied(true); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = viewMnemonic; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (ok) setViewCopied(true); + } + }, [viewMnemonic]); + + const handleRevealExistingPhrase = useCallback(async () => { + setViewRevealLoading(true); + setViewRevealError(null); + setViewMnemonic(null); + setViewRevealed(false); + try { + const result = await revealRecoveryPhrase(); + setViewMnemonic(result.phrase); + setViewRevealed(true); + } catch (e) { + setViewRevealError(e instanceof Error ? e.message : t('mnemonic.somethingWentWrong')); + } finally { + setViewRevealLoading(false); + } + }, [t]); + + const words = mnemonic ? mnemonic.split(' ') : []; const importWordCount = importWords.filter(w => w.trim()).length; const isImportComplete = importWords.every(w => w.trim()) && BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]); const canSave = mode === 'generate' ? confirmed : isImportComplete; + // ── Render helpers ──────────────────────────────────────────────────────── + + const renderLoading = () => ( +
+ + + + +

+ {t('mnemonic.loadingWalletStatus')} +

+
+ ); + + const renderViewMode = () => ( +
+ {statusError ? ( +
+ + + +

+ {statusError} +

+
+ ) : ( + <> + {/* Wallet configured banner */} +
+ + + +

+ {t('mnemonic.walletAlreadyConfigured')} +

+
+ + {/* Wallet metadata */} + {walletStatus && ( +
+ {walletStatus.source && ( +
+ + {t('mnemonic.walletSource')} + + + {walletStatus.source} + +
+ )} + {walletStatus.mnemonicWordCount && ( +
+ + {t('mnemonic.walletWordCount')} + + + {walletStatus.mnemonicWordCount} words + +
+ )} + {walletStatus.updatedAtMs && ( +
+ + {t('mnemonic.walletLastUpdated')} + + + {new Date(walletStatus.updatedAtMs).toLocaleDateString()} + +
+ )} + {walletStatus.accounts.length > 0 && ( +
+ + {t('mnemonic.viewAccounts')} + +
+ {walletStatus.accounts.map(account => ( +
+ + {account.chain} + + + {account.address} + +
+ ))} +
+
+ )} +
+ )} + + {/* Reveal existing recovery phrase */} + {viewMnemonic ? ( +
+
+ + + +

+ {t('mnemonic.cannotRecover')} +

+
+
+
+ {viewMnemonic.split(' ').map((word, index) => ( +
+ + {index + 1}. + + {word} +
+ ))} +
+ {!viewRevealed && ( + + )} +
+ + +
+ ) : ( + <> + {viewRevealError && ( +
+ + + +

+ {viewRevealError} +

+
+ )} + + + )} + + {/* Replace wallet CTA */} + + + )} +
+ ); + + const renderReplaceConfirm = () => ( +
+ {/* Danger warning */} +
+ + + +

+ {t('mnemonic.replaceWalletWarning')} +

+
+ + {/* Replace confirmation button */} + + + {/* Import instead */} + + + {/* Cancel */} + +
+ ); + + const renderGenerateMode = () => ( + <> +
+

+ {t('mnemonic.writeDownWords')} {MNEMONIC_GENERATE_WORD_COUNT} {t('mnemonic.wordsInOrder')} +

+
+ + + +

+ {t('mnemonic.cannotRecover')} +

+
+
+ +
+
+ {words.map((word, index) => ( +
+ + {index + 1}. + + {word} +
+ ))} +
+ {!revealed && ( + + )} +
+ + + + + + + + ); + + const renderImportMode = () => ( + <> +
+

+ {t('mnemonic.enterPhraseToRestore')} +

+
+ +
+ + {t('mnemonic.words')}: + + {BIP39_IMPORT_LENGTHS.map(len => ( + + ))} +
+ +
+
+ {importWords.map((word, index) => ( +
+ + {index + 1}. + + { + inputRefs.current[index] = el; + }} + type="text" + value={word} + onChange={e => handleImportWordChange(index, e.target.value)} + onKeyDown={e => handleImportKeyDown(index, e)} + autoComplete="off" + spellCheck={false} + className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white dark:bg-neutral-900 text-neutral-800 dark:text-neutral-100 outline-none transition-colors ${ + importValid === false && word.trim() + ? 'border-coral-400 focus:border-coral-300 dark:border-coral-500/40' + : importValid === true + ? 'border-sage-400 focus:border-sage-300 dark:border-sage-500/40' + : 'border-neutral-200 dark:border-neutral-800 focus:border-primary-400' + }`} + /> +
+ ))} +
+
+ + {importValid === true && ( +
+ + + + {t('mnemonic.validPhrase')} +
+ )} + + + + ); + return ( { ) : ( <> - {mode === 'generate' ? ( + {mode === 'loading' && renderLoading()} + {mode === 'view' && renderViewMode()} + {mode === 'replace-confirm' && renderReplaceConfirm()} + {(mode === 'generate' || mode === 'import') && ( <> -
-

- {t('mnemonic.writeDownWords')} {MNEMONIC_GENERATE_WORD_COUNT}{' '} - {t('mnemonic.wordsInOrder')} -

-
+ {mode === 'generate' ? renderGenerateMode() : renderImportMode()} + + {error && ( +
{ d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> -

- {t('mnemonic.cannotRecover')} +

+ {error}

-
- -
-
- {words.map((word, index) => ( -
- - {index + 1}. - - {word} -
- ))} -
- {!revealed && ( - - )} -
- - - - - - - - ) : ( - <> -
-

- {t('mnemonic.enterPhraseToRestore')} -

-
- -
- - {t('mnemonic.words')}: - - {BIP39_IMPORT_LENGTHS.map(len => ( - - ))} -
- -
-
- {importWords.map((word, index) => ( -
- - {index + 1}. - - { - inputRefs.current[index] = el; - }} - type="text" - value={word} - onChange={e => handleImportWordChange(index, e.target.value)} - onKeyDown={e => handleImportKeyDown(index, e)} - autoComplete="off" - spellCheck={false} - className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white dark:bg-neutral-900 text-neutral-800 dark:text-neutral-100 outline-none transition-colors ${ - importValid === false && word.trim() - ? 'border-coral-400 focus:border-coral-300 dark:border-coral-500/40' - : importValid === true - ? 'border-sage-400 focus:border-sage-300 dark:border-sage-500/40' - : 'border-neutral-200 dark:border-neutral-800 focus:border-primary-400' - }`} - /> -
- ))} -
-
- - {importValid === true && ( -
- - - - {t('mnemonic.validPhrase')} -
)} - + variant="primary" + size="lg" + onClick={() => void handleSave()} + disabled={!canSave || loading} + className="w-full"> + {loading ? ( + <> + + + + + {t('mnemonic.securingData')} + + ) : ( + t('mnemonic.saveRecoveryPhrase') + )} + )} - - {error && ( -
- - - -

- {error} -

-
- )} - - )}
diff --git a/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx b/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx index 03d767f3a..275d084ee 100644 --- a/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/RecoveryPhrasePanel.test.tsx @@ -1,17 +1,56 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WalletStatus } from '../../../../services/walletApi'; import { renderWithProviders } from '../../../../test/test-utils'; import RecoveryPhrasePanel from '../RecoveryPhrasePanel'; +// Use vi.hoisted so the factory closures can reference these before module initialisation. +const { + mockGenerateMnemonicPhrase, + mockFetchWalletStatus, + mockPersistLocalWalletFromMnemonic, + mockRevealRecoveryPhrase, +} = vi.hoisted(() => ({ + mockGenerateMnemonicPhrase: vi.fn( + () => 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ), + mockFetchWalletStatus: vi.fn( + async (): Promise => ({ + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, + }) + ), + mockPersistLocalWalletFromMnemonic: vi.fn( + async (_args: { force?: boolean; mnemonic?: string; source?: string }) => undefined + ), + mockRevealRecoveryPhrase: vi.fn(async () => ({ + phrase: 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12', + wordCount: 12, + })), +})); + +vi.mock('../../../../utils/cryptoKeys', async importOriginal => { + const original = await importOriginal(); + return { ...original, generateMnemonicPhrase: mockGenerateMnemonicPhrase }; +}); + vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: () => ({ - snapshot: { currentUser: null }, + snapshot: { currentUser: { _id: 'test-user-id' } }, setEncryptionKey: vi.fn(async () => undefined), }), })); +// Default: no existing wallet. Individual describe blocks override in beforeEach. vi.mock('../../../../services/walletApi', () => ({ + fetchWalletStatus: mockFetchWalletStatus, setupLocalWallet: vi.fn(async () => ({ configured: true, onboardingCompleted: true, @@ -22,28 +61,69 @@ vi.mock('../../../../services/walletApi', () => ({ accounts: [], updatedAtMs: Date.now(), })), + revealRecoveryPhrase: mockRevealRecoveryPhrase, })); +vi.mock('../../../../features/wallet/setupLocalWalletFromMnemonic', () => ({ + persistLocalWalletFromMnemonic: mockPersistLocalWalletFromMnemonic, +})); + +// Helper: configured wallet status +const configuredWalletStatus = (): WalletStatus => ({ + configured: true, + onboardingCompleted: true, + consentGranted: true, + secretStored: true, + source: 'generated', + mnemonicWordCount: 12, + accounts: [ + { chain: 'evm', address: '0xabc123', derivationPath: "m/44'/60'/0'/0/0" }, + { chain: 'btc', address: 'bc1qxyz', derivationPath: "m/84'/0'/0'/0/0" }, + { chain: 'solana', address: 'SolAbc', derivationPath: "m/44'/501'/0'/0'" }, + { chain: 'tron', address: 'TronAbc', derivationPath: "m/44'/195'/0'/0/0" }, + ], + updatedAtMs: 1_700_000_000_000, +}); + +// Reset to unconfigured wallet between tests +const noWalletStatus = (): WalletStatus => ({ + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, +}); + describe('RecoveryPhrasePanel — trust-surface polish', () => { - it('renders the amber warning callout in generate mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue(noWalletStatus()); + }); + + it('renders the amber warning callout in generate mode', async () => { const { container } = renderWithProviders(); - expect(screen.getByText(/can never be recovered if lost/i)).toBeTruthy(); - // Polish guarantee: the disclaimer lives in its own amber callout, - // not buried in body text. + await waitFor(() => expect(screen.queryByText(/can never be recovered if lost/i)).toBeTruthy()); expect(container.querySelector('.bg-amber-50')).not.toBeNull(); }); - it('renders import-mode intro copy when switching modes', () => { + it('renders import-mode intro copy when switching modes', async () => { renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); expect(screen.getByText(/Enter your recovery phrase below/i)).toBeTruthy(); }); - it('uses palette token text-neutral-700 on the confirm-checkbox label (not opacity)', () => { + it('uses palette token text-neutral-700 on the confirm-checkbox label (not opacity)', async () => { const { container } = renderWithProviders(); + await waitFor(() => screen.getByText(/consent to using it for local wallet setup/i)); const label = screen.getByText(/consent to using it for local wallet setup/i); expect(label.className).toContain('text-neutral-700'); - // Sanity: the old opacity hack is gone from this label. expect(label.className).not.toContain('opacity-80'); expect(container).toBeTruthy(); }); @@ -51,42 +131,584 @@ describe('RecoveryPhrasePanel — trust-surface polish', () => { // Batch-5: recovery/mnemonic mode-switch state reset (pr#1646) describe('RecoveryPhrasePanel — mode-switch state reset', () => { - it('switches to import mode and shows import-mode UI', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue(noWalletStatus()); + }); + + it('switches to import mode and shows import-mode UI', async () => { renderWithProviders(); - // Default: generate mode — amber callout visible + await waitFor(() => screen.getByText(/can never be recovered if lost/i)); expect(screen.getByText(/can never be recovered if lost/i)).toBeTruthy(); - // Switch to import mode fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); expect(screen.getByText(/Enter your recovery phrase below/i)).toBeTruthy(); }); - it('resets confirmed checkbox when switching from generate to import', () => { + it('resets confirmed checkbox when switching from generate to import', async () => { renderWithProviders(); + await waitFor(() => screen.getByRole('checkbox')); - // Check the confirmed checkbox in generate mode const checkbox = screen.getByRole('checkbox'); fireEvent.click(checkbox); expect(checkbox).toBeChecked(); - // Switch to import mode — confirmed should reset fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); - // In import mode the "consent" checkbox is not shown, so confirmed state is reset expect(screen.queryByRole('checkbox')).toBeNull(); - // Switch back to generate — checkbox should be unchecked (reset to false) fireEvent.click(screen.getByText(/Generate a new recovery phrase instead/i)); const regeneratedCheckbox = screen.getByRole('checkbox'); expect(regeneratedCheckbox).not.toBeChecked(); }); - it('shows generate-mode UI again after switching back from import', () => { + it('shows generate-mode UI again after switching back from import', async () => { renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); expect(screen.getByText(/Enter your recovery phrase below/i)).toBeTruthy(); fireEvent.click(screen.getByText(/Generate a new recovery phrase instead/i)); - // Back in generate mode expect(screen.getByText(/can never be recovered if lost/i)).toBeTruthy(); }); }); + +// ── New wallet-safety tests ─────────────────────────────────────────────────── + +describe('RecoveryPhrasePanel — existing wallet → view mode (no regenerate)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue(configuredWalletStatus()); + }); + + it('shows view mode when wallet is already configured', async () => { + renderWithProviders(); + // "Your wallet is already set up." — the English translation of mnemonic.walletAlreadyConfigured + await waitFor(() => expect(screen.queryByText(/Your wallet is already set up/i)).toBeTruthy()); + // No mnemonic reveal button in view mode + expect(screen.queryByLabelText(/Reveal recovery phrase/i)).toBeNull(); + // No consent checkbox in view mode + expect(screen.queryByRole('checkbox')).toBeNull(); + }); + + it('does NOT call generateMnemonicPhrase when wallet exists', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.queryByText(/Your wallet is already set up/i)).toBeTruthy()); + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + }); + + it('shows wallet metadata: source and word count labels', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.queryByText(/Source/i)).toBeTruthy()); + expect(screen.getByText(/Recovery phrase length/i)).toBeTruthy(); + expect(screen.getByText(/Last updated/i)).toBeTruthy(); + }); + + it('shows account addresses in view mode', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText('0xabc123')); + expect(screen.getByText('0xabc123')).toBeTruthy(); + expect(screen.getByText('bc1qxyz')).toBeTruthy(); + }); +}); + +describe('RecoveryPhrasePanel — replace-wallet confirmation gate', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue(configuredWalletStatus()); + }); + + it('clicking Replace wallet shows confirmation dialog, not generate mode', async () => { + renderWithProviders(); + // Wait for view mode: "Replace wallet" button + await waitFor(() => screen.getByText(/Replace wallet/i)); + + fireEvent.click(screen.getByText(/Replace wallet/i)); + + // Warning text for replace + expect(screen.getByText(/permanently replace your current wallet/i)).toBeTruthy(); + // Confirm button present + expect(screen.getByText(/I understand, replace my wallet/i)).toBeTruthy(); + // Mnemonic grid not shown yet + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + }); + + it('confirming replace enters generate mode and generates a new phrase', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Replace wallet/i)); + + fireEvent.click(screen.getByText(/Replace wallet/i)); + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText(/I understand, replace my wallet/i)); + + expect(mockGenerateMnemonicPhrase).toHaveBeenCalledTimes(1); + await waitFor(() => screen.getByLabelText(/Reveal recovery phrase/i)); + }); + + it('cancel in replace-confirm returns to view mode', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Replace wallet/i)); + + fireEvent.click(screen.getByText(/Replace wallet/i)); + fireEvent.click(screen.getByText(/Cancel/i)); + + await waitFor(() => screen.getByText(/Your wallet is already set up/i)); + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + }); +}); + +describe('RecoveryPhrasePanel — replace save calls persistLocalWalletFromMnemonic with force=true', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue(configuredWalletStatus()); + }); + + it('after replace confirmation, save calls persistLocalWalletFromMnemonic with force=true', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Replace wallet/i)); + + fireEvent.click(screen.getByText(/Replace wallet/i)); + fireEvent.click(screen.getByText(/I understand, replace my wallet/i)); + + await waitFor(() => screen.getByLabelText(/Reveal recovery phrase/i)); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const saveButton = screen.getByText(/Save Recovery Phrase/i).closest('button')!; + fireEvent.click(saveButton); + + await waitFor(() => expect(mockPersistLocalWalletFromMnemonic).toHaveBeenCalled()); + const callArgs = mockPersistLocalWalletFromMnemonic.mock.calls[0][0]; + expect(callArgs.force).toBe(true); + }); +}); + +describe('RecoveryPhrasePanel — no wallet → generate calls persistLocalWalletFromMnemonic without force', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue(noWalletStatus()); + }); + + it('fresh setup saves without force flag', async () => { + renderWithProviders(); + await waitFor(() => screen.getByRole('checkbox')); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const saveButton = screen.getByText(/Save Recovery Phrase/i).closest('button')!; + fireEvent.click(saveButton); + + await waitFor(() => expect(mockPersistLocalWalletFromMnemonic).toHaveBeenCalled()); + const callArgs = mockPersistLocalWalletFromMnemonic.mock.calls[0][0]; + expect(callArgs.force).toBeUndefined(); + }); +}); + +describe('RecoveryPhrasePanel — loading state', () => { + it('shows loading spinner while checking wallet status', () => { + // fetchWalletStatus never resolves — simulates pending/loading state. + mockFetchWalletStatus.mockImplementation(() => new Promise(() => {})); + + renderWithProviders(); + // "Checking wallet status..." — English translation of mnemonic.loadingWalletStatus + expect(screen.getByText(/Checking wallet status/i)).toBeTruthy(); + }); +}); + +// ── Coverage gate additions ─────────────────────────────────────────────────── +// Covers diff-cover lines: 78,81-82,84 (status-fetch failure → view/statusError), +// 106-111 (handleImportReplace in replace-confirm), 153 (handleCopy guard), +// 253-254 (!mnemonic early-return), 312 (statusError alert in view mode), +// 538,541 (copy button onClick + copied state), 608 (word-count change), +// 633-634,638,640,650 (import word onChange/onKeyDown + styling + valid banner), +// 707 (error alert in generate/import mode). + +describe('RecoveryPhrasePanel — fetchWalletStatus rejection degrades to view with statusError', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Simulate a network / RPC failure on the status check (covers lines 78, 81-82, 84). + mockFetchWalletStatus.mockRejectedValue(new Error('Network error: wallet status unavailable')); + }); + + // Covers lines 78, 81-82, 84, 312. + it('shows statusError alert in view mode when fetchWalletStatus rejects', async () => { + renderWithProviders(); + // After rejection the component degrades to view mode and renders the coral error alert. + await waitFor(() => expect(screen.queryByRole('alert')).toBeTruthy()); + expect(screen.getByRole('alert').textContent).toContain( + 'Network error: wallet status unavailable' + ); + // Must NOT auto-generate a phrase (would risk overwriting an existing wallet). + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + // The recovery/consent UI must not be shown — only the error alert. + expect(screen.queryByRole('checkbox')).toBeNull(); + }); + + // Covers line 312 (statusError branch in renderViewMode). + it('statusError alert uses role="alert" and shows the error text', async () => { + renderWithProviders(); + await waitFor(() => screen.getByRole('alert')); + const alert = screen.getByRole('alert'); + expect(alert.textContent).toMatch(/Network error/i); + }); + + // Verify a generic error message appears when rejection value is not an Error instance. + it('falls back to generic message when rejection is a plain string', async () => { + mockFetchWalletStatus.mockRejectedValue('unexpected failure'); + renderWithProviders(); + await waitFor(() => screen.getByRole('alert')); + expect(screen.getByRole('alert').textContent).toContain( + 'Failed to check wallet status. Please try again.' + ); + }); +}); + +describe('RecoveryPhrasePanel — replace-confirm → import path (handleImportReplace)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue({ + configured: true, + onboardingCompleted: true, + consentGranted: true, + secretStored: true, + source: 'generated', + mnemonicWordCount: 12, + accounts: [], + updatedAtMs: 1_700_000_000_000, + }); + }); + + // Covers lines 106-111 (handleImportReplace sets isReplace=true and enters import mode). + it('clicking "I already have a recovery phrase" in replace-confirm enters import mode', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Replace wallet/i)); + + // Enter replace-confirm mode. + fireEvent.click(screen.getByText(/Replace wallet/i)); + expect(screen.getByText(/permanently replace your current wallet/i)).toBeTruthy(); + + // Click the "I already have a recovery phrase" link inside replace-confirm. + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + + // Must arrive in import mode — the intro copy is the marker. + await waitFor(() => + expect(screen.queryByText(/Enter your recovery phrase below/i)).toBeTruthy() + ); + // No mnemonic was generated (since we went to import, not generate). + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + }); + + // Covers line 608 (word-count change buttons in import mode after handleImportReplace). + it('changing word count in import mode (after replace flow) updates the word slots', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Replace wallet/i)); + + fireEvent.click(screen.getByText(/Replace wallet/i)); + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + await waitFor(() => screen.getByText(/Enter your recovery phrase below/i)); + + // Default is 12 word slots; switch to 24. + fireEvent.click(screen.getByRole('button', { name: '24' })); + + // 24 labelled inputs should now be visible. + const inputs = screen.getAllByRole('textbox'); + // The count may exceed 24 if there are other textboxes, but there must be at least 24. + expect(inputs.length).toBeGreaterThanOrEqual(24); + }); +}); + +describe('RecoveryPhrasePanel — copy button in revealed generate mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue({ + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, + }); + // Stub clipboard so the copy path doesn't throw. + Object.assign(navigator, { clipboard: { writeText: vi.fn(async () => undefined) } }); + }); + + // Covers lines 538, 541 (copy button onClick triggers handleCopy; copied state renders "Copied"). + it('reveals phrase then copies — shows Copied state after clicking copy button', async () => { + renderWithProviders(); + await waitFor(() => screen.getByLabelText(/Reveal recovery phrase/i)); + + // Reveal the phrase first (otherwise the copy button is disabled). + fireEvent.click(screen.getByLabelText(/Reveal recovery phrase/i)); + + // Copy button should now be enabled. Click it. + const copyButton = screen.getByText(/Copy to Clipboard/i).closest('button')!; + expect(copyButton).not.toBeDisabled(); + fireEvent.click(copyButton); + + // navigator.clipboard.writeText must have been called with the mnemonic. + await waitFor(() => + expect(vi.mocked(navigator.clipboard.writeText)).toHaveBeenCalledWith( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ) + ); + + // The button label switches to "Copied" (common.copied translation). + await waitFor(() => expect(screen.queryByText(/^Copied$/i)).toBeTruthy()); + }); + + // Covers line 153 (handleCopy early-return when !mnemonic — guard branch). + // This can only happen if mnemonic is null; we simulate it by making generateMnemonicPhrase + // return an empty string so the split produces no words, but the guard is at the function level. + // We test the guard indirectly: with no mnemonic the copy button is disabled and clipboard is + // never written. + it('copy button is disabled before phrase is revealed (mnemonic guard path)', async () => { + renderWithProviders(); + await waitFor(() => screen.getByLabelText(/Reveal recovery phrase/i)); + + // The copy button exists but is disabled before reveal. + const copyButton = screen.getByText(/Copy to Clipboard/i).closest('button')!; + expect(copyButton).toBeDisabled(); + fireEvent.click(copyButton); + + // Clipboard should not have been written. + expect(vi.mocked(navigator.clipboard.writeText)).not.toHaveBeenCalled(); + }); +}); + +describe('RecoveryPhrasePanel — !mnemonic early-return in handleSave (lines 253-254)', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Return empty string so mnemonic state is set to '' — falsy, triggers the guard. + mockGenerateMnemonicPhrase.mockReturnValue(''); + mockFetchWalletStatus.mockResolvedValue({ + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, + }); + }); + + // Covers lines 253-254: confirmed=true but mnemonic is falsy → early return, no persist call. + it('does not call persistLocalWalletFromMnemonic when mnemonic is empty', async () => { + renderWithProviders(); + // The checkbox is present once generate mode initialises. + await waitFor(() => screen.getByRole('checkbox')); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const saveButton = screen.getByText(/Save Recovery Phrase/i).closest('button')!; + fireEvent.click(saveButton); + + // With an empty mnemonic, persistLocalWalletFromMnemonic must NOT be called. + await waitFor(() => expect(mockPersistLocalWalletFromMnemonic).not.toHaveBeenCalled()); + }); +}); + +describe('RecoveryPhrasePanel — import mode word inputs and valid/invalid styling', () => { + // Known-valid 12-word BIP39 test vector. + const VALID_12_WORDS = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split( + ' ' + ); + + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateMnemonicPhrase.mockReturnValue( + 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12' + ); + mockFetchWalletStatus.mockResolvedValue({ + configured: false, + onboardingCompleted: false, + consentGranted: false, + secretStored: false, + source: null, + mnemonicWordCount: null, + accounts: [], + updatedAtMs: null, + }); + }); + + // Covers lines 633-634 (onChange on word inputs updates importWords). + it('typing into a word input updates the value (onChange coverage)', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); + + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + await waitFor(() => screen.getByText(/Enter your recovery phrase below/i)); + + const wordInputs = screen.getAllByLabelText(/Recovery phrase word/i); + fireEvent.change(wordInputs[0], { target: { value: 'abandon' } }); + expect((wordInputs[0] as HTMLInputElement).value).toBe('abandon'); + }); + + // Covers line 638 (onKeyDown on word inputs — Backspace on empty field). + it('pressing Backspace on an empty word input (onKeyDown coverage)', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); + + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + await waitFor(() => screen.getByText(/Enter your recovery phrase below/i)); + + const wordInputs = screen.getAllByLabelText(/Recovery phrase word/i); + // Word 1 is empty; Backspace on word slot 2 (index 1) when it is empty. + fireEvent.keyDown(wordInputs[1], { key: 'Backspace' }); + // No crash — focus attempt is the side-effect; just verify the inputs remain. + expect(wordInputs[1]).toBeTruthy(); + }); + + // Covers lines 640 (importValid===false invalid border), 638, and 707 (error alert). + it('clicking Save with an invalid phrase shows the error alert (line 707)', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); + + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + await waitFor(() => screen.getByText(/Enter your recovery phrase below/i)); + + // Fill all 12 slots with an invalid word so the phrase is structurally complete but invalid. + const wordInputs = screen.getAllByLabelText(/Recovery phrase word/i); + for (const input of wordInputs.slice(0, 12)) { + fireEvent.change(input, { target: { value: 'invalid' } }); + } + + // All slots filled — Save should now be enabled. + const saveButton = screen.getByText(/Save Recovery Phrase/i).closest('button')!; + fireEvent.click(saveButton); + + // The error alert (line 707) must appear. + await waitFor(() => expect(screen.queryByRole('alert')).toBeTruthy()); + // persistLocalWalletFromMnemonic should NOT have been called. + expect(mockPersistLocalWalletFromMnemonic).not.toHaveBeenCalled(); + }); + + // Covers line 650 (importValid===true banner), line 640 (sage border on valid inputs). + it('filling a valid BIP39 phrase and saving shows the valid-phrase banner then persists', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); + + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + await waitFor(() => screen.getByText(/Enter your recovery phrase below/i)); + + const wordInputs = screen.getAllByLabelText(/Recovery phrase word/i); + // Type the 12-word valid BIP39 vector into each slot. + VALID_12_WORDS.forEach((word, i) => { + fireEvent.change(wordInputs[i], { target: { value: word } }); + }); + + // All slots filled — Save button should be enabled. + const saveButton = screen.getByText(/Save Recovery Phrase/i).closest('button')!; + expect(saveButton).not.toBeDisabled(); + fireEvent.click(saveButton); + + // After validation, importValid becomes true → "Valid recovery phrase" banner (line 650). + await waitFor(() => expect(screen.queryByText(/Valid recovery phrase/i)).toBeTruthy()); + // Wallet persist must have been called with the correct phrase. + await waitFor(() => expect(mockPersistLocalWalletFromMnemonic).toHaveBeenCalled()); + const callArgs = mockPersistLocalWalletFromMnemonic.mock.calls[0][0]; + expect(callArgs.mnemonic).toBe(VALID_12_WORDS.join(' ')); + expect(callArgs.source).toBe('imported'); + }); + + // Covers line 608 (handleWordCountChange via the word-count toggle buttons). + it('switching from 12 to 15 word slots adjusts the import grid', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/I already have a recovery phrase/i)); + + fireEvent.click(screen.getByText(/I already have a recovery phrase/i)); + await waitFor(() => screen.getByText(/Enter your recovery phrase below/i)); + + // Initially 12 slots. + expect(screen.getAllByLabelText(/Recovery phrase word/i).length).toBe(12); + + // Click the "15" word-count button. + fireEvent.click(screen.getByRole('button', { name: '15' })); + + // Now 15 slots. + expect(screen.getAllByLabelText(/Recovery phrase word/i).length).toBe(15); + }); +}); + +describe('RecoveryPhrasePanel — view mode: reveal existing recovery phrase', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchWalletStatus.mockResolvedValue(configuredWalletStatus()); + mockRevealRecoveryPhrase.mockResolvedValue({ + phrase: 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12', + wordCount: 12, + }); + }); + + it('shows "Reveal recovery phrase" button in view mode', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Your wallet is already set up/i)); + expect(screen.getByText(/Reveal recovery phrase/i)).toBeTruthy(); + }); + + it('clicking reveal button calls revealRecoveryPhrase and shows word grid', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Reveal recovery phrase/i)); + fireEvent.click(screen.getByText(/Reveal recovery phrase/i).closest('button')!); + await waitFor(() => expect(mockRevealRecoveryPhrase).toHaveBeenCalled()); + // After reveal, the hide button appears + await waitFor(() => expect(screen.queryByText(/Hide phrase/i)).toBeTruthy()); + // The amber warning is shown + expect(screen.getByText(/can never be recovered if lost/i)).toBeTruthy(); + }); + + it('shows error message when revealRecoveryPhrase rejects', async () => { + mockRevealRecoveryPhrase.mockRejectedValue(new Error('No recovery phrase available')); + renderWithProviders(); + await waitFor(() => screen.getByText(/Reveal recovery phrase/i)); + fireEvent.click(screen.getByText(/Reveal recovery phrase/i).closest('button')!); + await waitFor(() => expect(screen.queryByRole('alert')).toBeTruthy()); + expect(screen.getByRole('alert').textContent).toContain('No recovery phrase available'); + }); + + it('does NOT call generateMnemonicPhrase or persistLocalWalletFromMnemonic in view mode', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Reveal recovery phrase/i)); + fireEvent.click(screen.getByText(/Reveal recovery phrase/i).closest('button')!); + await waitFor(() => expect(mockRevealRecoveryPhrase).toHaveBeenCalled()); + expect(mockGenerateMnemonicPhrase).not.toHaveBeenCalled(); + expect(mockPersistLocalWalletFromMnemonic).not.toHaveBeenCalled(); + }); + + it('clicking Hide phrase hides the word grid', async () => { + renderWithProviders(); + await waitFor(() => screen.getByText(/Reveal recovery phrase/i)); + fireEvent.click(screen.getByText(/Reveal recovery phrase/i).closest('button')!); + await waitFor(() => screen.getByText(/Hide phrase/i)); + fireEvent.click(screen.getByText(/Hide phrase/i)); + await waitFor(() => expect(screen.queryByText(/Hide phrase/i)).toBeNull()); + expect(screen.getByText(/Reveal recovery phrase/i)).toBeTruthy(); + }); +}); diff --git a/app/src/features/wallet/setupLocalWalletFromMnemonic.ts b/app/src/features/wallet/setupLocalWalletFromMnemonic.ts index d135f2ef7..9d36a8c62 100644 --- a/app/src/features/wallet/setupLocalWalletFromMnemonic.ts +++ b/app/src/features/wallet/setupLocalWalletFromMnemonic.ts @@ -10,8 +10,13 @@ export async function persistLocalWalletFromMnemonic(args: { mnemonic: string; source: WalletSetupSource; setEncryptionKey: (value: string | null) => Promise; + /** + * Set to `true` only when the user has explicitly confirmed wallet replacement + * through the double-confirmation dialog. Defaults to `false`. + */ + force?: boolean; }): Promise { - const { mnemonic, source, setEncryptionKey } = args; + const { mnemonic, source, setEncryptionKey, force } = args; const words = mnemonic.trim().split(/\s+/).filter(Boolean); if (words.length === 0) { throw new Error('Recovery phrase is required.'); @@ -30,5 +35,6 @@ export async function persistLocalWalletFromMnemonic(args: { mnemonicWordCount: words.length, encryptedMnemonic, accounts: deriveWalletAccountsFromMnemonic(normalizedMnemonic), + force, }); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 8fb0a83b5..d063798ed 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1552,6 +1552,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'لم يتم تحميل المستخدم. يرجى تسجيل الدخول مرة أخرى أو تحديث الصفحة.', 'mnemonic.invalidPhrase': 'عبارة الاسترداد غير صالحة. يرجى مراجعة كلماتك والمحاولة مرة أخرى.', 'mnemonic.somethingWentWrong': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', + 'mnemonic.walletAlreadyConfigured': 'محفظتك مضبوطة بالفعل.', + 'mnemonic.walletSource': 'المصدر', + 'mnemonic.walletWordCount': 'عدد الكلمات', + 'mnemonic.walletLastUpdated': 'آخر تحديث', + 'mnemonic.replaceWallet': 'استبدال المحفظة', + 'mnemonic.replaceWalletWarning': + 'سيؤدي هذا إلى استبدال محفظتك الحالية نهائيًا. تأكد من احتفاظك بنسخة احتياطية من عبارة الاسترداد قبل المتابعة.', + 'mnemonic.replaceWalletConfirm': 'أفهم، استبدل محفظتي', + 'mnemonic.loadingWalletStatus': 'جارٍ التحقق من حالة المحفظة...', + 'mnemonic.viewAccounts': 'حسابات المحفظة', + 'mnemonic.revealRecoveryPhrase': 'الكشف عن عبارة الاسترداد', + 'mnemonic.hidePhrase': 'إخفاء العبارة', 'team.failedToCreate': 'فشل إنشاء الفريق', 'team.invalidInviteCode': 'رمز دعوة غير صالح أو منتهي الصلاحية', 'team.failedToSwitch': 'فشل تبديل الفريق', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 982ca063d..13fde335e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1585,6 +1585,18 @@ const messages: TranslationMap = { 'ব্যবহারকারী লোড হয়নি। দয়া করে আবার সাইন ইন করুন বা পেজ রিফ্রেশ করুন।', 'mnemonic.invalidPhrase': 'অবৈধ রিকভারি ফ্রেজ। আপনার শব্দগুলো পরীক্ষা করে আবার চেষ্টা করুন।', 'mnemonic.somethingWentWrong': 'কিছু একটা ভুল হয়েছে। আবার চেষ্টা করুন।', + 'mnemonic.walletAlreadyConfigured': 'আপনার ওয়ালেট ইতিমধ্যে সেট আপ করা আছে।', + 'mnemonic.walletSource': 'উৎস', + 'mnemonic.walletWordCount': 'রিকভারি ফ্রেজের দৈর্ঘ্য', + 'mnemonic.walletLastUpdated': 'সর্বশেষ আপডেট', + 'mnemonic.replaceWallet': 'ওয়ালেট প্রতিস্থাপন করুন', + 'mnemonic.replaceWalletWarning': + 'এটি আপনার বর্তমান ওয়ালেটকে স্থায়ীভাবে প্রতিস্থাপন করবে। এগিয়ে যাওয়ার আগে নিশ্চিত করুন যে আপনি আপনার রিকভারি ফ্রেজ ব্যাকআপ করেছেন।', + 'mnemonic.replaceWalletConfirm': 'আমি বুঝতে পারছি, আমার ওয়ালেট প্রতিস্থাপন করুন', + 'mnemonic.loadingWalletStatus': 'ওয়ালেটের স্থিতি পরীক্ষা করা হচ্ছে...', + 'mnemonic.viewAccounts': 'ওয়ালেট অ্যাকাউন্ট', + 'mnemonic.revealRecoveryPhrase': 'পুনরুদ্ধার বাক্যাংশ প্রকাশ করুন', + 'mnemonic.hidePhrase': 'বাক্যাংশ লুকান', 'team.failedToCreate': 'টিম তৈরি করতে ব্যর্থ', 'team.invalidInviteCode': 'অবৈধ বা মেয়াদোত্তীর্ণ আমন্ত্রণ কোড', 'team.failedToSwitch': 'টিম পরিবর্তন করতে ব্যর্থ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0ed17f678..f0ad51d91 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1628,6 +1628,18 @@ const messages: TranslationMap = { 'mnemonic.invalidPhrase': 'Ungültige Wiederherstellungsphrase. Bitte prüfe deine Wörter und versuche es erneut.', 'mnemonic.somethingWentWrong': 'Etwas ist schief gelaufen. Bitte versuche es erneut.', + 'mnemonic.walletAlreadyConfigured': 'Deine Wallet ist bereits eingerichtet.', + 'mnemonic.walletSource': 'Quelle', + 'mnemonic.walletWordCount': 'Länge der Recovery-Phrase', + 'mnemonic.walletLastUpdated': 'Zuletzt aktualisiert', + 'mnemonic.replaceWallet': 'Wallet ersetzen', + 'mnemonic.replaceWalletWarning': + 'Dies wird deine aktuelle Wallet dauerhaft ersetzen. Stelle sicher, dass du deine Recovery-Phrase gesichert hast, bevor du fortfährst.', + 'mnemonic.replaceWalletConfirm': 'Ich verstehe, meine Wallet ersetzen', + 'mnemonic.loadingWalletStatus': 'Wallet-Status wird geprüft...', + 'mnemonic.viewAccounts': 'Wallet-Konten', + 'mnemonic.revealRecoveryPhrase': 'Wiederherstellungsphrase anzeigen', + 'mnemonic.hidePhrase': 'Phrase ausblenden', 'team.failedToCreate': 'Team konnte nicht erstellt werden', 'team.invalidInviteCode': 'Ungültiger oder abgelaufener Einladungscode', 'team.failedToSwitch': 'Teamwechsel fehlgeschlagen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 00f2c59ac..33d4d6ee1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1942,6 +1942,18 @@ const en: TranslationMap = { 'mnemonic.userNotLoaded': 'User not loaded. Please sign in again or refresh the page.', 'mnemonic.invalidPhrase': 'Invalid recovery phrase. Please check your words and try again.', 'mnemonic.somethingWentWrong': 'Something went wrong. Please try again.', + 'mnemonic.walletAlreadyConfigured': 'Your wallet is already set up.', + 'mnemonic.walletSource': 'Source', + 'mnemonic.walletWordCount': 'Recovery phrase length', + 'mnemonic.walletLastUpdated': 'Last updated', + 'mnemonic.replaceWallet': 'Replace wallet', + 'mnemonic.replaceWalletWarning': + 'This will permanently replace your current wallet. Make sure you have backed up your recovery phrase before proceeding.', + 'mnemonic.replaceWalletConfirm': 'I understand, replace my wallet', + 'mnemonic.loadingWalletStatus': 'Checking wallet status...', + 'mnemonic.viewAccounts': 'Wallet accounts', + 'mnemonic.revealRecoveryPhrase': 'Reveal recovery phrase', + 'mnemonic.hidePhrase': 'Hide phrase', // Team 'team.failedToCreate': 'Failed to create team', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index e88461abf..6461831f1 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1619,6 +1619,18 @@ const messages: TranslationMap = { 'mnemonic.invalidPhrase': 'Frase de recuperación inválida. Revisa las palabras e inténtalo de nuevo.', 'mnemonic.somethingWentWrong': 'Algo salió mal. Inténtalo de nuevo.', + 'mnemonic.walletAlreadyConfigured': 'Tu billetera ya está configurada.', + 'mnemonic.walletSource': 'Origen', + 'mnemonic.walletWordCount': 'Longitud de la frase de recuperación', + 'mnemonic.walletLastUpdated': 'Última actualización', + 'mnemonic.replaceWallet': 'Reemplazar billetera', + 'mnemonic.replaceWalletWarning': + 'Esto reemplazará permanentemente tu billetera actual. Asegúrate de haber guardado tu frase de recuperación antes de continuar.', + 'mnemonic.replaceWalletConfirm': 'Entiendo, reemplazar mi billetera', + 'mnemonic.loadingWalletStatus': 'Verificando estado de la billetera...', + 'mnemonic.viewAccounts': 'Cuentas de la billetera', + 'mnemonic.revealRecoveryPhrase': 'Revelar frase de recuperación', + 'mnemonic.hidePhrase': 'Ocultar frase', 'team.failedToCreate': 'No se pudo crear el equipo', 'team.invalidInviteCode': 'Código de invitación inválido o expirado', 'team.failedToSwitch': 'No se pudo cambiar de equipo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3b7242df8..e411d6f95 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1629,6 +1629,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'Utilisateur non chargé. Reconnecte-toi ou actualise la page.', 'mnemonic.invalidPhrase': 'Phrase de récupération invalide. Vérifie tes mots et réessaie.', 'mnemonic.somethingWentWrong': "Une erreur s'est produite. Réessaie.", + 'mnemonic.walletAlreadyConfigured': 'Ton portefeuille est déjà configuré.', + 'mnemonic.walletSource': 'Source', + 'mnemonic.walletWordCount': 'Nombre de mots', + 'mnemonic.walletLastUpdated': 'Dernière mise à jour', + 'mnemonic.replaceWallet': 'Remplacer le portefeuille', + 'mnemonic.replaceWalletWarning': + "Cela remplacera définitivement ton portefeuille actuel. Assure-toi d'avoir sauvegardé ta phrase de récupération avant de continuer.", + 'mnemonic.replaceWalletConfirm': 'Je comprends, remplacer mon portefeuille', + 'mnemonic.loadingWalletStatus': 'Vérification du statut du portefeuille...', + 'mnemonic.viewAccounts': 'Voir les comptes du portefeuille', + 'mnemonic.revealRecoveryPhrase': 'Révéler la phrase de récupération', + 'mnemonic.hidePhrase': 'Masquer la phrase', 'team.failedToCreate': "Échec de la création de l'équipe", 'team.invalidInviteCode': "Code d'invitation invalide ou expiré", 'team.failedToSwitch': "Échec du changement d'équipe", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index a99a077e6..e96d14da6 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1582,6 +1582,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'यूज़र लोड नहीं हुआ। दोबारा साइन इन करें या पेज रिफ्रेश करें।', 'mnemonic.invalidPhrase': 'गलत रिकवरी फ्रेज़। अपने शब्द चेक करके दोबारा कोशिश करें।', 'mnemonic.somethingWentWrong': 'कुछ गड़बड़ हो गई। दोबारा कोशिश करें।', + 'mnemonic.walletAlreadyConfigured': 'आपका वॉलेट पहले से सेट अप है।', + 'mnemonic.walletSource': 'स्रोत', + 'mnemonic.walletWordCount': 'रिकवरी फ्रेज़ की लंबाई', + 'mnemonic.walletLastUpdated': 'अंतिम अपडेट', + 'mnemonic.replaceWallet': 'वॉलेट बदलें', + 'mnemonic.replaceWalletWarning': + 'यह आपके मौजूदा वॉलेट को स्थायी रूप से बदल देगा। आगे बढ़ने से पहले सुनिश्चित करें कि आपने अपनी रिकवरी फ्रेज़ का बैकअप ले लिया है।', + 'mnemonic.replaceWalletConfirm': 'मैं समझता/समझती हूँ, मेरा वॉलेट बदलें', + 'mnemonic.loadingWalletStatus': 'वॉलेट की स्थिति जाँची जा रही है...', + 'mnemonic.viewAccounts': 'वॉलेट खाते', + 'mnemonic.revealRecoveryPhrase': 'रिकवरी वाक्यांश प्रकट करें', + 'mnemonic.hidePhrase': 'वाक्यांश छुपाएं', 'team.failedToCreate': 'टीम नहीं बन पाई', 'team.invalidInviteCode': 'गलत या एक्सपायर्ड इनवाइट कोड', 'team.failedToSwitch': 'टीम स्विच नहीं हो पाई', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 355ea6920..e6339ccb3 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1591,6 +1591,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'Pengguna belum dimuat. Silakan masuk lagi atau segarkan halaman.', 'mnemonic.invalidPhrase': 'Frasa pemulihan tidak valid. Periksa kata-kata Anda dan coba lagi.', 'mnemonic.somethingWentWrong': 'Terjadi kesalahan. Silakan coba lagi.', + 'mnemonic.walletAlreadyConfigured': 'Dompetmu sudah diatur.', + 'mnemonic.walletSource': 'Sumber', + 'mnemonic.walletWordCount': 'Jumlah kata frasa pemulihan', + 'mnemonic.walletLastUpdated': 'Terakhir diperbarui', + 'mnemonic.replaceWallet': 'Ganti dompet', + 'mnemonic.replaceWalletWarning': + 'Ini akan mengganti dompetmu saat ini secara permanen. Pastikan kamu telah mencadangkan frasa pemulihan sebelum melanjutkan.', + 'mnemonic.replaceWalletConfirm': 'Saya mengerti, ganti dompet saya', + 'mnemonic.loadingWalletStatus': 'Memeriksa status dompet...', + 'mnemonic.viewAccounts': 'Akun dompet', + 'mnemonic.revealRecoveryPhrase': 'Tampilkan frasa pemulihan', + 'mnemonic.hidePhrase': 'Sembunyikan frasa', 'team.failedToCreate': 'Gagal membuat tim', 'team.invalidInviteCode': 'Kode undangan tidak valid atau sudah kedaluwarsa', 'team.failedToSwitch': 'Gagal berpindah tim', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index be3a5a795..f690bf118 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1614,6 +1614,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'Utente non caricato. Accedi di nuovo o ricarica la pagina.', 'mnemonic.invalidPhrase': 'Frase di recupero non valida. Controlla le parole e riprova.', 'mnemonic.somethingWentWrong': 'Qualcosa è andato storto. Riprova.', + 'mnemonic.walletAlreadyConfigured': 'Il tuo portafoglio è già configurato.', + 'mnemonic.walletSource': 'Fonte', + 'mnemonic.walletWordCount': 'Lunghezza della frase di recupero', + 'mnemonic.walletLastUpdated': 'Ultimo aggiornamento', + 'mnemonic.replaceWallet': 'Sostituisci portafoglio', + 'mnemonic.replaceWalletWarning': + 'Questo sostituirà definitivamente il tuo portafoglio attuale. Assicurati di aver salvato la tua frase di recupero prima di procedere.', + 'mnemonic.replaceWalletConfirm': 'Capisco, sostituisci il mio portafoglio', + 'mnemonic.loadingWalletStatus': 'Verifica dello stato del portafoglio...', + 'mnemonic.viewAccounts': 'Conti del portafoglio', + 'mnemonic.revealRecoveryPhrase': 'Rivela la frase di recupero', + 'mnemonic.hidePhrase': 'Nascondi frase', 'team.failedToCreate': 'Creazione team fallita', 'team.invalidInviteCode': 'Codice invito non valido o scaduto', 'team.failedToSwitch': 'Cambio team fallito', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index dadae98a2..985f01a51 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1570,6 +1570,18 @@ const messages: TranslationMap = { '사용자가 로드되지 않았습니다. 다시 로그인하거나 페이지를 새로고침하세요.', 'mnemonic.invalidPhrase': '유효하지 않은 복구 문구입니다. 단어를 확인하고 다시 시도하세요.', 'mnemonic.somethingWentWrong': '문제가 발생했습니다. 다시 시도해 주세요.', + 'mnemonic.walletAlreadyConfigured': '지갑이 이미 설정되어 있습니다.', + 'mnemonic.walletSource': '출처', + 'mnemonic.walletWordCount': '복구 구문 길이', + 'mnemonic.walletLastUpdated': '마지막 업데이트', + 'mnemonic.replaceWallet': '지갑 교체', + 'mnemonic.replaceWalletWarning': + '현재 지갑이 영구적으로 교체됩니다. 진행하기 전에 복구 구문을 백업했는지 확인하세요.', + 'mnemonic.replaceWalletConfirm': '이해합니다. 지갑을 교체합니다', + 'mnemonic.loadingWalletStatus': '지갑 상태 확인 중...', + 'mnemonic.viewAccounts': '지갑 계정', + 'mnemonic.revealRecoveryPhrase': '복구 구문 표시', + 'mnemonic.hidePhrase': '구문 숨기기', 'team.failedToCreate': '팀 생성에 실패했습니다', 'team.invalidInviteCode': '유효하지 않거나 만료된 초대 코드입니다', 'team.failedToSwitch': '팀 전환에 실패했습니다', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index d7ff4abdf..b1ce94d9c 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1604,6 +1604,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'Użytkownik nie wczytany. Zaloguj się ponownie lub odśwież stronę.', 'mnemonic.invalidPhrase': 'Nieprawidłowa fraza odzyskiwania. Sprawdź słowa i spróbuj ponownie.', 'mnemonic.somethingWentWrong': 'Coś poszło nie tak. Spróbuj ponownie.', + 'mnemonic.walletAlreadyConfigured': 'Twój portfel jest już skonfigurowany.', + 'mnemonic.walletSource': 'Źródło', + 'mnemonic.walletWordCount': 'Długość frazy odzyskiwania', + 'mnemonic.walletLastUpdated': 'Ostatnia aktualizacja', + 'mnemonic.replaceWallet': 'Zastąp portfel', + 'mnemonic.replaceWalletWarning': + 'Spowoduje to trwałe zastąpienie Twojego obecnego portfela. Upewnij się, że masz kopię zapasową frazy odzyskiwania przed kontynuowaniem.', + 'mnemonic.replaceWalletConfirm': 'Rozumiem, zastąp mój portfel', + 'mnemonic.loadingWalletStatus': 'Sprawdzanie statusu portfela...', + 'mnemonic.viewAccounts': 'Konta portfela', + 'mnemonic.revealRecoveryPhrase': 'Ujawnij frazę odzyskiwania', + 'mnemonic.hidePhrase': 'Ukryj frazę', 'team.failedToCreate': 'Nie udało się utworzyć zespołu', 'team.invalidInviteCode': 'Nieprawidłowy lub wygasły kod zaproszenia', 'team.failedToSwitch': 'Nie udało się przełączyć zespołu', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d39a594e5..eaafd605b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1622,6 +1622,18 @@ const messages: TranslationMap = { 'mnemonic.invalidPhrase': 'Frase de recuperação inválida. Verifique as palavras e tente novamente.', 'mnemonic.somethingWentWrong': 'Algo deu errado. Por favor, tente novamente.', + 'mnemonic.walletAlreadyConfigured': 'Sua carteira já está configurada.', + 'mnemonic.walletSource': 'Fonte', + 'mnemonic.walletWordCount': 'Comprimento da frase de recuperação', + 'mnemonic.walletLastUpdated': 'Última atualização', + 'mnemonic.replaceWallet': 'Substituir carteira', + 'mnemonic.replaceWalletWarning': + 'Isso substituirá permanentemente sua carteira atual. Certifique-se de ter feito backup da sua frase de recuperação antes de prosseguir.', + 'mnemonic.replaceWalletConfirm': 'Entendo, substituir minha carteira', + 'mnemonic.loadingWalletStatus': 'Verificando status da carteira...', + 'mnemonic.viewAccounts': 'Contas da carteira', + 'mnemonic.revealRecoveryPhrase': 'Revelar frase de recuperação', + 'mnemonic.hidePhrase': 'Ocultar frase', 'team.failedToCreate': 'Falha ao criar equipe', 'team.invalidInviteCode': 'Código de convite inválido ou expirado', 'team.failedToSwitch': 'Falha ao trocar de equipe', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 544fd3648..8a3431979 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1599,6 +1599,18 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': 'Пользователь не загружен. Войди снова или обнови страницу.', 'mnemonic.invalidPhrase': 'Неверная фраза восстановления. Проверь слова и попробуй снова.', 'mnemonic.somethingWentWrong': 'Что-то пошло не так. Попробуй ещё раз.', + 'mnemonic.walletAlreadyConfigured': 'Ваш кошелёк уже настроен.', + 'mnemonic.walletSource': 'Источник', + 'mnemonic.walletWordCount': 'Длина фразы восстановления', + 'mnemonic.walletLastUpdated': 'Последнее обновление', + 'mnemonic.replaceWallet': 'Заменить кошелёк', + 'mnemonic.replaceWalletWarning': + 'Это навсегда заменит ваш текущий кошелёк. Убедитесь, что вы сделали резервную копию фразы восстановления, прежде чем продолжить.', + 'mnemonic.replaceWalletConfirm': 'Я понимаю, заменить мой кошелёк', + 'mnemonic.loadingWalletStatus': 'Проверка статуса кошелька...', + 'mnemonic.viewAccounts': 'Счета кошелька', + 'mnemonic.revealRecoveryPhrase': 'Показать фразу восстановления', + 'mnemonic.hidePhrase': 'Скрыть фразу', 'team.failedToCreate': 'Не удалось создать команду', 'team.invalidInviteCode': 'Неверный или устаревший код приглашения', 'team.failedToSwitch': 'Не удалось переключить команду', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f83cfb6f7..b64bee450 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1501,6 +1501,17 @@ const messages: TranslationMap = { 'mnemonic.userNotLoaded': '用户未加载。请重新登录或刷新页面。', 'mnemonic.invalidPhrase': '恢复短语无效。请检查你的单词后重试。', 'mnemonic.somethingWentWrong': '出了点问题。请重试。', + 'mnemonic.walletAlreadyConfigured': '您的钱包已设置好。', + 'mnemonic.walletSource': '来源', + 'mnemonic.walletWordCount': '助记词长度', + 'mnemonic.walletLastUpdated': '最后更新', + 'mnemonic.replaceWallet': '替换钱包', + 'mnemonic.replaceWalletWarning': '这将永久替换您当前的钱包。在继续之前,请确保您已备份恢复短语。', + 'mnemonic.replaceWalletConfirm': '我明白,替换我的钱包', + 'mnemonic.loadingWalletStatus': '正在检查钱包状态...', + 'mnemonic.viewAccounts': '钱包账户', + 'mnemonic.revealRecoveryPhrase': '显示恢复短语', + 'mnemonic.hidePhrase': '隐藏短语', 'team.failedToCreate': '创建团队失败', 'team.invalidInviteCode': '无效或已过期的邀请码', 'team.failedToSwitch': '切换团队失败', diff --git a/app/src/services/walletApi.ts b/app/src/services/walletApi.ts index 5b8f70d5b..651078f92 100644 --- a/app/src/services/walletApi.ts +++ b/app/src/services/walletApi.ts @@ -46,6 +46,13 @@ export interface SetupWalletParams { mnemonicWordCount: number; encryptedMnemonic?: string; accounts: WalletAccount[]; + /** + * Must be `true` to overwrite an existing wallet. + * Only pass after explicit double-confirmation by the user. + * Defaults to `false` — the backend rejects the call without it when a + * wallet already exists. + */ + force?: boolean; } export const fetchWalletStatus = async (): Promise => { @@ -167,3 +174,21 @@ export const executePrepared = async (quoteId: string): Promise }); return response.result; }; + +export interface RevealRecoveryPhraseResult { + phrase: string; + wordCount: number; +} + +/** + * Reveal the plaintext recovery phrase for the currently configured wallet. + * + * The phrase is held only in transient React state — never written to disk. + * Calls openhuman.wallet_reveal_recovery_phrase on the Rust core. + */ +export const revealRecoveryPhrase = async (): Promise => { + const response = await callCoreRpc<{ result: RevealRecoveryPhraseResult }>({ + method: 'openhuman.wallet_reveal_recovery_phrase', + }); + return response.result; +}; diff --git a/src/openhuman/tools/impl/network/polymarket_tests.rs b/src/openhuman/tools/impl/network/polymarket_tests.rs index 49c7b97a6..0e3ab3a5d 100644 --- a/src/openhuman/tools/impl/network/polymarket_tests.rs +++ b/src/openhuman/tools/impl/network/polymarket_tests.rs @@ -410,6 +410,8 @@ async fn configure_wallet_for_place_order_test( mnemonic_word_count: 12, encrypted_mnemonic: Some(encrypted_mnemonic), accounts: test_wallet_accounts(&evm_address, evm_derivation_path), + // Test helper: force=true allows setup in fresh temp environments. + force: true, }) .await .map_err(|e| format!("failed to configure wallet state: {e}"))?; diff --git a/src/openhuman/wallet/execution_tests.rs b/src/openhuman/wallet/execution_tests.rs index 10d3fff80..cfed835ca 100644 --- a/src/openhuman/wallet/execution_tests.rs +++ b/src/openhuman/wallet/execution_tests.rs @@ -124,6 +124,8 @@ pub(crate) async fn setup_wallet_in(temp: &TempDir) -> Result<(), String> { .into_iter() .map(sample_account) .collect(), + // Test helper: force=true allows re-setup in tests that start from a fresh temp dir. + force: true, }) .await?; Ok(()) diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index d824f3b86..20d4c830e 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -37,7 +37,8 @@ pub use execution::{ pub(crate) use execution::{sign_and_broadcast_evm, sign_and_broadcast_solana}; pub(crate) use ops::secret_material; pub use ops::{ - setup, status, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, + reveal_recovery_phrase, setup, status, RevealRecoveryPhraseResult, WalletAccount, WalletChain, + WalletSetupParams, WalletSetupSource, WalletStatus, }; pub use schemas::{ all_controller_schemas, all_registered_controllers, all_wallet_controller_schemas, diff --git a/src/openhuman/wallet/ops.rs b/src/openhuman/wallet/ops.rs index ce7507ffd..b476d1929 100644 --- a/src/openhuman/wallet/ops.rs +++ b/src/openhuman/wallet/ops.rs @@ -161,6 +161,11 @@ pub struct WalletSetupParams { #[serde(default)] pub encrypted_mnemonic: Option, pub accounts: Vec, + /// When `true`, allows overwriting an existing wallet. + /// Requires explicit user confirmation in the frontend. + /// Defaults to `false` — a guard against silent overwrites. + #[serde(default)] + pub force: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -337,17 +342,39 @@ fn load_stored_wallet_state_unlocked(config: &Config) -> Result Result "wallet setup requires encrypted mnemonic material for signing-enabled local wallets" .to_string() })?; + + // ── Idempotency guard: reject overwrite unless caller explicitly set force=true ── + // Acquire lock before the existence check so no TOCTOU race is possible. + let _guard = WALLET_STATE_FILE_LOCK.lock(); + if let Some(_existing) = load_stored_wallet_state_unlocked(&config)? { + if !params.force { + debug!("{LOG_PREFIX} setup rejected: wallet already configured and force=false"); + return Err("wallet is already configured; pass force=true to overwrite".to_string()); + } + debug!("{LOG_PREFIX} setup: overwriting existing wallet (force=true)"); + } + let state = StoredWalletState { consent_granted: params.consent_granted, source: params.source, @@ -573,7 +612,6 @@ pub async fn setup(params: WalletSetupParams) -> Result updated_at_ms: current_time_ms(), }; - let _guard = WALLET_STATE_FILE_LOCK.lock(); save_stored_wallet_state_unlocked(&config, &state)?; let status = to_status(&config, Some(state)); @@ -591,6 +629,97 @@ pub async fn setup(params: WalletSetupParams) -> Result )) } +/// Result returned by `reveal_recovery_phrase`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealRecoveryPhraseResult { + pub phrase: String, + pub word_count: usize, +} + +/// Decrypt and return the stored recovery phrase for the current wallet. +/// +/// This is a read-only operation — it never writes to disk or the keychain. +/// The plaintext phrase is returned only in the RPC response and must be kept +/// in transient React state on the frontend; it must never be logged or persisted. +pub async fn reveal_recovery_phrase() -> Result, String> { + debug!("{LOG_PREFIX} reveal_recovery_phrase ENTRY"); + + let config = config_rpc::load_config_with_timeout().await.map_err(|e| { + log::warn!("{LOG_PREFIX} reveal_recovery_phrase config load failed: {e}"); + e + })?; + + // Acquire the lock to load state, then drop it before any await point. + // parking_lot::MutexGuard is not Send, so it must not be held across awaits. + let ciphertext = { + let _guard = WALLET_STATE_FILE_LOCK.lock(); + debug!("{LOG_PREFIX} reveal_recovery_phrase state lock acquired"); + + let state = match load_stored_wallet_state_unlocked(&config)? { + Some(s) => s, + None => { + debug!("{LOG_PREFIX} reveal_recovery_phrase no wallet state found"); + return Err( + "No recovery phrase is available to reveal. Set up or unlock your wallet first." + .to_string(), + ); + } + }; + + // Primary path: mnemonic is in the state returned by load (either from + // the JSON field or merged in from the OS keychain by + // load_stored_wallet_state_unlocked). Fallback: probe the keychain + // directly in case the mnemonic is stored there but was not merged into + // `state` (e.g. headless / CI keychain that was transiently unavailable + // during the initial probe inside load_stored_wallet_state_unlocked, or + // any environment where the mnemonic lives only in the keychain). + let enc_mnemonic_opt = state + .encrypted_mnemonic + .as_ref() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| { + debug!( + "{LOG_PREFIX} reveal_recovery_phrase: mnemonic absent from state, \ + falling back to direct keychain probe" + ); + keychain_load_mnemonic(&config) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }); + + enc_mnemonic_opt.ok_or_else(|| { + debug!("{LOG_PREFIX} reveal_recovery_phrase encrypted mnemonic missing from state"); + "No recovery phrase is available to reveal. Set up or unlock your wallet first." + .to_string() + })? + // _guard dropped here — before the decrypt await below + }; + + debug!("{LOG_PREFIX} reveal_recovery_phrase decrypting mnemonic"); + + let phrase = crate::openhuman::credentials::ops::decrypt_secret(&config, &ciphertext) + .await + .map_err(|e| { + log::warn!("{LOG_PREFIX} reveal_recovery_phrase decrypt failed: {e}"); + format!("Failed to decrypt recovery phrase: {e}") + })? + .value; + + let word_count = phrase.split_whitespace().count(); + + debug!( + "{LOG_PREFIX} reveal_recovery_phrase OK word_count={}", + word_count + ); + + Ok(RpcOutcome::new( + RevealRecoveryPhraseResult { phrase, word_count }, + vec!["recovery phrase revealed".to_string()], + )) +} + pub(crate) async fn secret_material(chain: WalletChain) -> Result { debug!( "{LOG_PREFIX} secret_material loading config chain={}", @@ -667,6 +796,7 @@ mod tests { mnemonic_word_count: 12, encrypted_mnemonic: Some("enc2:abc".to_string()), accounts: WalletChain::ALL.into_iter().map(sample_account).collect(), + force: false, } } @@ -742,4 +872,152 @@ mod tests { assert_eq!(status.accounts.len(), 4); assert_eq!(status.updated_at_ms, Some(123)); } + + // ── Overwrite-guard unit tests ──────────────────────────────────────────── + // These exercise the guard logic directly via the unlocked helpers so that + // we don't need a tokio runtime or a live config-RPC call. + + fn make_temp_config() -> (Config, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir"); + let mut config = Config::default(); + config.workspace_dir = dir.path().join("workspace"); + std::fs::create_dir_all(&config.workspace_dir).expect("workspace dir"); + (config, dir) + } + + fn stored_state() -> StoredWalletState { + StoredWalletState { + consent_granted: true, + source: WalletSetupSource::Generated, + mnemonic_word_count: 12, + encrypted_mnemonic: Some("enc2:test-existing".to_string()), + accounts: WalletChain::ALL.into_iter().map(sample_account).collect(), + updated_at_ms: 1_000_000, + } + } + + #[test] + fn setup_rejects_overwrite_without_force() { + let (config, _dir) = make_temp_config(); + // Pre-populate wallet state to simulate an existing wallet. + let existing = stored_state(); + save_stored_wallet_state_unlocked(&config, &existing).expect("save existing state"); + + // Build params WITHOUT force=true. + let mut params = sample_params(); + params.force = false; + + // The guard should detect the existing wallet and the validate+guard + // path should fail BEFORE we even try to save. + // We test the guard directly here: load the state and check guard logic. + let _guard = WALLET_STATE_FILE_LOCK.lock(); + let loaded = load_stored_wallet_state_unlocked(&config).expect("load ok"); + assert!(loaded.is_some(), "existing wallet must be loaded"); + // Guard: if existing && !force → error + let would_error = loaded.is_some() && !params.force; + assert!( + would_error, + "setup without force must be rejected when wallet exists" + ); + } + + #[test] + fn setup_allows_overwrite_with_force() { + let (config, _dir) = make_temp_config(); + // Pre-populate wallet state. + let existing = stored_state(); + save_stored_wallet_state_unlocked(&config, &existing).expect("save existing state"); + + // Build params WITH force=true. + let mut params = sample_params(); + params.force = true; + + let _guard = WALLET_STATE_FILE_LOCK.lock(); + let loaded = load_stored_wallet_state_unlocked(&config).expect("load ok"); + // Guard: if existing && force → proceed (no error) + let would_error = loaded.is_some() && !params.force; + assert!( + !would_error, + "setup with force must be allowed when wallet exists" + ); + + // Actually write the new state to confirm save works. + let new_state = StoredWalletState { + consent_granted: true, + source: WalletSetupSource::Imported, + mnemonic_word_count: 12, + encrypted_mnemonic: Some("enc2:new-mnemonic".to_string()), + accounts: WalletChain::ALL.into_iter().map(sample_account).collect(), + updated_at_ms: 2_000_000, + }; + save_stored_wallet_state_unlocked(&config, &new_state).expect("save new state"); + let reloaded = load_stored_wallet_state_unlocked(&config) + .expect("reload ok") + .expect("state present after overwrite"); + assert_eq!(reloaded.updated_at_ms, 2_000_000); + } + + #[test] + fn setup_allows_fresh_without_force() { + let (config, _dir) = make_temp_config(); + // No existing wallet — fresh setup. + let params = sample_params(); // force defaults to false + + let _guard = WALLET_STATE_FILE_LOCK.lock(); + let loaded = load_stored_wallet_state_unlocked(&config).expect("load ok"); + assert!(loaded.is_none(), "no existing wallet on fresh config"); + // Guard: if None → proceed regardless of force + let would_error = loaded.is_some() && !params.force; + assert!(!would_error, "fresh setup without force must be allowed"); + + // Write initial state. + let new_state = StoredWalletState { + consent_granted: true, + source: WalletSetupSource::Generated, + mnemonic_word_count: 12, + encrypted_mnemonic: Some("enc2:fresh".to_string()), + accounts: WalletChain::ALL.into_iter().map(sample_account).collect(), + updated_at_ms: 3_000_000, + }; + save_stored_wallet_state_unlocked(&config, &new_state).expect("save fresh state"); + let reloaded = load_stored_wallet_state_unlocked(&config) + .expect("reload ok") + .expect("state present after fresh setup"); + assert_eq!(reloaded.updated_at_ms, 3_000_000); + } + + // ── reveal_recovery_phrase unit tests ──────────────────────────────────── + // These use tokio::test and OPENHUMAN_WORKSPACE env var to wire up the full + // async path including config loading. The TEST_LOCK from test_support + // serialises all wallet tests that mutate env vars. + + #[tokio::test] + async fn reveal_recovery_phrase_returns_error_when_no_wallet() { + let temp = tempfile::tempdir().expect("temp dir"); + let _lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); + std::env::set_var("OPENHUMAN_WORKSPACE", temp.path()); + let result = reveal_recovery_phrase().await; + let err = result.expect_err("should error when no wallet configured"); + assert!( + err.contains("No recovery phrase is available"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn reveal_recovery_phrase_returns_phrase_for_existing_wallet() { + let temp = tempfile::tempdir().expect("temp dir"); + let _lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); + crate::openhuman::wallet::test_support::setup_wallet_in(&temp) + .await + .expect("setup wallet"); + let result = reveal_recovery_phrase() + .await + .expect("reveal should succeed"); + assert_eq!( + result.value.phrase, + crate::openhuman::wallet::test_support::TEST_MNEMONIC + ); + assert_eq!(result.value.word_count, 12); + } } diff --git a/src/openhuman/wallet/schemas.rs b/src/openhuman/wallet/schemas.rs index 1eec97f4f..b33ece2a7 100644 --- a/src/openhuman/wallet/schemas.rs +++ b/src/openhuman/wallet/schemas.rs @@ -28,6 +28,10 @@ struct SetupWalletParams { mnemonic_word_count: u8, encrypted_mnemonic: String, accounts: Vec, + /// Explicit overwrite permission — must be `true` to replace an existing wallet. + /// Defaults to `false`; set by the frontend only after double-confirmation. + #[serde(default)] + force: bool, } #[derive(Debug, Deserialize)] @@ -64,6 +68,7 @@ pub fn all_wallet_controller_schemas() -> Vec { wallet_schemas("tx_status"), wallet_schemas("tx_receipt"), wallet_schemas("lookup_tx"), + wallet_schemas("reveal_recovery_phrase"), ] } @@ -117,6 +122,10 @@ pub fn all_wallet_registered_controllers() -> Vec { schema: wallet_schemas("lookup_tx"), handler: handle_lookup_tx, }, + RegisteredController { + schema: wallet_schemas("reveal_recovery_phrase"), + handler: handle_reveal_recovery_phrase, + }, ] } @@ -157,6 +166,14 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { "accounts", "Exactly one derived account for each supported chain: EVM, BTC, Solana, and Tron.", ), + FieldSchema { + name: "force", + ty: TypeSchema::Option(Box::new(TypeSchema::Json)), + comment: + "Optional boolean. Must be true to overwrite an existing wallet. Defaults to false. \ + Only pass true after the user has explicitly confirmed wallet replacement.", + required: false, + }, ], outputs: vec![FieldSchema { name: "result", @@ -318,6 +335,20 @@ pub fn wallet_schemas(function: &str) -> ControllerSchema { required: true, }], }, + "reveal_recovery_phrase" => ControllerSchema { + namespace: "wallet", + function: "reveal_recovery_phrase", + description: "Reveal the plaintext recovery phrase for an existing configured wallet. \ + The phrase is decrypted in-core and returned only in the RPC response; \ + it must be held in transient React state and never written to disk.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "{phrase: string, wordCount: number} — the decrypted BIP-39 mnemonic.", + required: true, + }], + }, _ => ControllerSchema { namespace: "wallet", function: "unknown", @@ -351,6 +382,7 @@ fn handle_setup(params: Map) -> ControllerFuture { mnemonic_word_count: payload.mnemonic_word_count, encrypted_mnemonic: Some(payload.encrypted_mnemonic), accounts: payload.accounts, + force: payload.force, }) .await? .into_cli_compatible_json() @@ -434,6 +466,14 @@ fn handle_lookup_tx(params: Map) -> ControllerFuture { }) } +fn handle_reveal_recovery_phrase(_params: Map) -> ControllerFuture { + Box::pin(async move { + crate::openhuman::wallet::reveal_recovery_phrase() + .await? + .into_cli_compatible_json() + }) +} + /// Shared input schema for the tx_status / tx_receipt / lookup_tx readers. fn tx_query_inputs() -> Vec { vec![ @@ -464,12 +504,12 @@ mod tests { #[test] fn all_schemas_lists_every_controller() { - assert_eq!(all_wallet_controller_schemas().len(), 12); + assert_eq!(all_wallet_controller_schemas().len(), 13); } #[test] fn all_controllers_lists_every_handler() { - assert_eq!(all_wallet_registered_controllers().len(), 12); + assert_eq!(all_wallet_registered_controllers().len(), 13); } #[test] @@ -496,13 +536,20 @@ mod tests { #[test] fn setup_schema_requires_all_inputs() { let schema = wallet_schemas("setup"); - assert_eq!(schema.inputs.len(), 5); + // 5 original fields + 1 optional `force` field = 6 total + assert_eq!(schema.inputs.len(), 6); let encrypted = schema .inputs .iter() .find(|field| field.name == "encryptedMnemonic") .expect("encryptedMnemonic input present"); assert!(encrypted.required); + let force = schema + .inputs + .iter() + .find(|field| field.name == "force") + .expect("force input present"); + assert!(!force.required, "force must be optional"); } #[test] diff --git a/src/openhuman/wallet/test_support.rs b/src/openhuman/wallet/test_support.rs index e69695a0f..f629cff90 100644 --- a/src/openhuman/wallet/test_support.rs +++ b/src/openhuman/wallet/test_support.rs @@ -92,6 +92,8 @@ pub(crate) async fn setup_wallet_in(temp: &TempDir) -> Result<(), String> { .into_iter() .map(sample_account) .collect(), + // Test helper: force=true allows re-setup in tests that already have a wallet. + force: true, }) .await?; Ok(())