mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(wallet): reveal existing recovery phrase instead of regenerating the wallet (#3849)
This commit is contained in:
@@ -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('<RecoveryPhrasePanel />', () => {
|
||||
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(<RecoveryPhrasePanel />);
|
||||
|
||||
// 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();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<WalletStatus> => ({
|
||||
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<typeof import('../../../../utils/cryptoKeys')>();
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
// 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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
// "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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
// 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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
// "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(<RecoveryPhrasePanel />);
|
||||
// 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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
// 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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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(<RecoveryPhrasePanel />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,13 @@ export async function persistLocalWalletFromMnemonic(args: {
|
||||
mnemonic: string;
|
||||
source: WalletSetupSource;
|
||||
setEncryptionKey: (value: string | null) => Promise<void>;
|
||||
/**
|
||||
* Set to `true` only when the user has explicitly confirmed wallet replacement
|
||||
* through the double-confirmation dialog. Defaults to `false`.
|
||||
*/
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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': 'فشل تبديل الفريق',
|
||||
|
||||
@@ -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': 'টিম পরিবর্তন করতে ব্যর্থ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': 'टीम स्विच नहीं हो पाई',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '팀 전환에 실패했습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Не удалось переключить команду',
|
||||
|
||||
@@ -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': '切换团队失败',
|
||||
|
||||
@@ -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<WalletStatus> => {
|
||||
@@ -167,3 +174,21 @@ export const executePrepared = async (quoteId: string): Promise<ExecutionResult>
|
||||
});
|
||||
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<RevealRecoveryPhraseResult> => {
|
||||
const response = await callCoreRpc<{ result: RevealRecoveryPhraseResult }>({
|
||||
method: 'openhuman.wallet_reveal_recovery_phrase',
|
||||
});
|
||||
return response.result;
|
||||
};
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
+281
-3
@@ -161,6 +161,11 @@ pub struct WalletSetupParams {
|
||||
#[serde(default)]
|
||||
pub encrypted_mnemonic: Option<String>,
|
||||
pub accounts: Vec<WalletAccount>,
|
||||
/// 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<Option<StoredWal
|
||||
|
||||
// ── Step 4: Validate (allows encrypted_mnemonic to be None when in keychain) ──
|
||||
// Build validation params treating keychain-held mnemonic as present.
|
||||
// Re-probe the keychain here so that headless / CI environments where
|
||||
// keychain_load_mnemonic returned None at the top of this function (e.g.
|
||||
// because the keychain entry was written by a concurrent task and was not
|
||||
// yet visible to the earlier read) get one more chance to find the secret.
|
||||
let effective_mnemonic = state.encrypted_mnemonic.clone().or_else(|| {
|
||||
// Mnemonic was just wiped from state; re-probe keychain.
|
||||
keychain_load_mnemonic(config)
|
||||
let reprobe = keychain_load_mnemonic(config);
|
||||
if reprobe.is_some() {
|
||||
debug!(
|
||||
"{LOG_PREFIX} load: re-probe found mnemonic in keychain \
|
||||
that was absent on initial probe; merging into state"
|
||||
);
|
||||
}
|
||||
reprobe
|
||||
});
|
||||
|
||||
// Propagate whatever was found (initial probe, migration, or re-probe) back
|
||||
// into `state` so that callers (reveal_recovery_phrase, secret_material)
|
||||
// always receive a complete state when the mnemonic is accessible.
|
||||
if state.encrypted_mnemonic.is_none() {
|
||||
if let Some(ref m) = effective_mnemonic {
|
||||
debug!("{LOG_PREFIX} load: setting encrypted_mnemonic from effective_mnemonic");
|
||||
state.encrypted_mnemonic = Some(m.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let validation_params = WalletSetupParams {
|
||||
consent_granted: state.consent_granted,
|
||||
source: state.source,
|
||||
mnemonic_word_count: state.mnemonic_word_count,
|
||||
encrypted_mnemonic: effective_mnemonic,
|
||||
accounts: state.accounts.clone(),
|
||||
// force is irrelevant for validation; always false here.
|
||||
force: false,
|
||||
};
|
||||
if let Err(validation_error) = validate_setup(&validation_params) {
|
||||
warn!(
|
||||
@@ -564,6 +591,18 @@ pub async fn setup(params: WalletSetupParams) -> Result<RpcOutcome<WalletStatus>
|
||||
"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<RpcOutcome<WalletStatus>
|
||||
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<RpcOutcome<WalletStatus>
|
||||
))
|
||||
}
|
||||
|
||||
/// 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<RpcOutcome<RevealRecoveryPhraseResult>, 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<WalletSecretMaterial, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ struct SetupWalletParams {
|
||||
mnemonic_word_count: u8,
|
||||
encrypted_mnemonic: String,
|
||||
accounts: Vec<WalletAccount>,
|
||||
/// 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<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
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<String, Value>) -> 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<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_reveal_recovery_phrase(_params: Map<String, Value>) -> 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<FieldSchema> {
|
||||
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]
|
||||
|
||||
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user