mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Fix recovery rollback handling (#1905)
* Fix recovery rollback handling * Restore registration state on rollback * Restore selected document on rollback * fix(webview): clear both keys on partial rollback to prevent mnemonic/secret mismatch When restoreSnapshotBestEffort partially fails (e.g. mnemonic rollback fails but secret rollback succeeds), the stored mnemonic and private key can end up mismatched — deriving from the stored mnemonic produces a different key than what's stored. This is silent data corruption that could lock users out of recovery. Fix: when any rollback write fails, clear both keys so ensureSecret can regenerate a consistent pair from scratch. A missing pair is recoverable; a mismatched pair is not. Adds a test in restoreSecretFromMnemonic that proves the mismatch scenario and verifies both keys are cleared. * feat(new-common): add humanizeContractError utility with tests * fix: prettier formatting in secretManager test --------- Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
This commit is contained in:
65
new-common/src/blockchain/contractErrors.test.ts
Normal file
65
new-common/src/blockchain/contractErrors.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { humanizeContractError } from './contractErrors.js';
|
||||
|
||||
describe('humanizeContractError', () => {
|
||||
it('decodes a known custom error selector', () => {
|
||||
// 0xda7bd3a6 = InvalidVcAndDiscloseProof
|
||||
expect(humanizeContractError('0xda7bd3a6')).toBe('Invalid Vc And Disclose Proof');
|
||||
});
|
||||
|
||||
it('decodes a known SCREAMING_CASE selector', () => {
|
||||
// 0x034acfcc = REGISTERED_COMMITMENT
|
||||
expect(humanizeContractError('0x034acfcc')).toBe('Registered Commitment');
|
||||
});
|
||||
|
||||
it('decodes Error(string) standard revert', () => {
|
||||
// ABI encoding of Error("Insufficient balance")
|
||||
const encoded =
|
||||
'0x08c379a0' +
|
||||
'0000000000000000000000000000000000000000000000000000000000000020' +
|
||||
'0000000000000000000000000000000000000000000000000000000000000014' +
|
||||
'496e73756666696369656e742062616c616e636500000000000000000000000000';
|
||||
expect(humanizeContractError(encoded)).toBe('Insufficient balance');
|
||||
});
|
||||
|
||||
it('decodes Panic(uint256) arithmetic overflow', () => {
|
||||
const encoded =
|
||||
'0x4e487b71' + '0000000000000000000000000000000000000000000000000000000000000011';
|
||||
expect(humanizeContractError(encoded)).toBe('Arithmetic overflow or underflow');
|
||||
});
|
||||
|
||||
it('decodes Panic(uint256) division by zero', () => {
|
||||
const encoded =
|
||||
'0x4e487b71' + '0000000000000000000000000000000000000000000000000000000000000012';
|
||||
expect(humanizeContractError(encoded)).toBe('Division or modulo by zero');
|
||||
});
|
||||
|
||||
it('decodes Panic(uint256) array out of bounds', () => {
|
||||
const encoded =
|
||||
'0x4e487b71' + '0000000000000000000000000000000000000000000000000000000000000032';
|
||||
expect(humanizeContractError(encoded)).toBe('Array out of bounds');
|
||||
});
|
||||
|
||||
it('decodes Panic(uint256) unknown code gracefully', () => {
|
||||
const encoded =
|
||||
'0x4e487b71' + '00000000000000000000000000000000000000000000000000000000000000ff';
|
||||
expect(humanizeContractError(encoded)).toBe('Contract panic (code 255)');
|
||||
});
|
||||
|
||||
it('returns original string for unknown selector', () => {
|
||||
expect(humanizeContractError('0xdeadbeef')).toBe('0xdeadbeef');
|
||||
});
|
||||
|
||||
it('returns original string for non-hex input', () => {
|
||||
expect(humanizeContractError('something went wrong')).toBe('something went wrong');
|
||||
});
|
||||
|
||||
it('returns original string for empty input', () => {
|
||||
expect(humanizeContractError('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles mixed-case selector input', () => {
|
||||
expect(humanizeContractError('0xDA7BD3A6')).toBe('Invalid Vc And Disclose Proof');
|
||||
});
|
||||
});
|
||||
71
new-common/src/blockchain/contractErrors.ts
Normal file
71
new-common/src/blockchain/contractErrors.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { AbiCoder } from 'ethers';
|
||||
|
||||
import selectorMap from '../data/error-selector-map.json' with { type: 'json' };
|
||||
|
||||
const SELECTOR_RE = /^0x[0-9a-fA-F]{8}/;
|
||||
const ERROR_STRING_SELECTOR = '0x08c379a0';
|
||||
const PANIC_SELECTOR = '0x4e487b71';
|
||||
|
||||
const PANIC_CODES: Record<number, string> = {
|
||||
0x01: 'Assertion failed',
|
||||
0x11: 'Arithmetic overflow or underflow',
|
||||
0x12: 'Division or modulo by zero',
|
||||
0x21: 'Invalid enum value',
|
||||
0x22: 'Corrupted storage byte array',
|
||||
0x31: 'Pop on empty array',
|
||||
0x32: 'Array out of bounds',
|
||||
0x41: 'Out of memory',
|
||||
0x51: 'Invalid internal function call',
|
||||
};
|
||||
|
||||
function formatErrorName(name: string): string {
|
||||
if (name === name.toUpperCase() && name.includes('_')) {
|
||||
return name
|
||||
.split('_')
|
||||
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
return name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw Solidity error string into a human-readable message.
|
||||
*
|
||||
* Handles:
|
||||
* - Standard Error(string): extracts the revert message
|
||||
* - Standard Panic(uint256): maps panic code to description
|
||||
* - Known custom error selectors from our contracts (auto-generated map)
|
||||
* - Unknown input: returned unchanged
|
||||
*/
|
||||
export function humanizeContractError(raw: string): string {
|
||||
if (!raw) return raw;
|
||||
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
if (lower.startsWith(ERROR_STRING_SELECTOR)) {
|
||||
try {
|
||||
const [message] = AbiCoder.defaultAbiCoder().decode(['string'], '0x' + raw.slice(10));
|
||||
return message as string;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.startsWith(PANIC_SELECTOR)) {
|
||||
try {
|
||||
const [code] = AbiCoder.defaultAbiCoder().decode(['uint256'], '0x' + raw.slice(10));
|
||||
const codeNum = Number(code);
|
||||
return PANIC_CODES[codeNum] ?? `Contract panic (code ${codeNum})`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
if (SELECTOR_RE.test(raw)) {
|
||||
const selector = lower.slice(0, 10);
|
||||
const name = (selectorMap as Record<string, string>)[selector];
|
||||
if (name) return formatErrorName(name);
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
@@ -5,7 +5,13 @@
|
||||
import type { DocumentCategory, IDDocument } from '@selfxyz/common';
|
||||
import { isUserRegisteredWithAlternativeCSCA } from '@selfxyz/common/utils/passports/validate';
|
||||
|
||||
import { markCurrentDocumentAsRegistered, reStorePassportDataWithRightCSCA } from '../documents/utils';
|
||||
import { cloneForStorage } from '../adapters/browser/documents';
|
||||
import {
|
||||
markCurrentDocumentAsRegistered,
|
||||
reStorePassportDataWithRightCSCA,
|
||||
storePassportData,
|
||||
updateDocumentRegistrationState,
|
||||
} from '../documents/utils';
|
||||
import { getCommitmentTree } from '../stores';
|
||||
import type { SelfClient } from '../types/public';
|
||||
|
||||
@@ -28,11 +34,58 @@ export async function finalizeRecoveredDocumentRegistration(
|
||||
document: IDDocument,
|
||||
csca?: string,
|
||||
): Promise<void> {
|
||||
if (csca) {
|
||||
await reStorePassportDataWithRightCSCA(selfClient, document, csca);
|
||||
}
|
||||
const originalDocument = cloneForStorage(document);
|
||||
const originalCatalog = await selfClient.loadDocumentCatalog();
|
||||
const originalSelectedDocument = originalCatalog.selectedDocumentId
|
||||
? originalCatalog.documents.find(doc => doc.id === originalCatalog.selectedDocumentId)
|
||||
: undefined;
|
||||
const originalSelectedDocumentSnapshot = originalSelectedDocument
|
||||
? {
|
||||
id: originalSelectedDocument.id,
|
||||
isRegistered: originalSelectedDocument.isRegistered,
|
||||
registeredAt: originalSelectedDocument.registeredAt,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await markCurrentDocumentAsRegistered(selfClient);
|
||||
try {
|
||||
if (csca) {
|
||||
await reStorePassportDataWithRightCSCA(selfClient, document, csca);
|
||||
}
|
||||
|
||||
await markCurrentDocumentAsRegistered(selfClient);
|
||||
} catch (error) {
|
||||
if (csca) {
|
||||
try {
|
||||
await storePassportData(selfClient, originalDocument);
|
||||
} catch (rollbackError) {
|
||||
console.error('Rollback failed while restoring the original document during recovery:', rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalSelectedDocumentSnapshot) {
|
||||
try {
|
||||
const rollbackCatalog = await selfClient.loadDocumentCatalog();
|
||||
rollbackCatalog.selectedDocumentId = originalCatalog.selectedDocumentId;
|
||||
const rollbackDocument = rollbackCatalog.documents.find(doc => doc.id === originalSelectedDocumentSnapshot.id);
|
||||
|
||||
if (rollbackDocument) {
|
||||
rollbackDocument.isRegistered = originalSelectedDocumentSnapshot.isRegistered;
|
||||
rollbackDocument.registeredAt = originalSelectedDocumentSnapshot.registeredAt;
|
||||
await selfClient.saveDocumentCatalog(rollbackCatalog);
|
||||
} else {
|
||||
await updateDocumentRegistrationState(
|
||||
selfClient,
|
||||
originalSelectedDocumentSnapshot.id,
|
||||
Boolean(originalSelectedDocumentSnapshot.isRegistered),
|
||||
);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
console.error('Rollback failed while restoring the registration flag during recovery:', rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateRecoverySecretForDocument(
|
||||
|
||||
@@ -6,22 +6,53 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { IDDocument } from '@selfxyz/common';
|
||||
|
||||
import { validateRecoverySecretForDocument } from '../../src/proving/recoveryValidation';
|
||||
import {
|
||||
finalizeRecoveredDocumentRegistration,
|
||||
validateRecoverySecretForDocument,
|
||||
} from '../../src/proving/recoveryValidation';
|
||||
import type { SelfClient } from '../../src/types/public';
|
||||
|
||||
const isUserRegisteredWithAlternativeCSCAMock = vi.fn();
|
||||
const markCurrentDocumentAsRegisteredMock = vi.fn();
|
||||
const restorePassportDataWithRightCSCAmock = vi.fn();
|
||||
const storePassportDataMock = vi.fn();
|
||||
const updateDocumentRegistrationStateMock = vi.fn();
|
||||
const saveDocumentCatalogMock = vi.fn();
|
||||
|
||||
vi.mock('@selfxyz/common/utils/passports/validate', () => ({
|
||||
isUserRegisteredWithAlternativeCSCA: (...args: unknown[]) => isUserRegisteredWithAlternativeCSCAMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/documents/utils', () => ({
|
||||
markCurrentDocumentAsRegistered: (...args: unknown[]) => markCurrentDocumentAsRegisteredMock(...args),
|
||||
reStorePassportDataWithRightCSCA: (...args: unknown[]) => restorePassportDataWithRightCSCAmock(...args),
|
||||
storePassportData: (...args: unknown[]) => storePassportDataMock(...args),
|
||||
updateDocumentRegistrationState: (...args: unknown[]) => updateDocumentRegistrationStateMock(...args),
|
||||
}));
|
||||
|
||||
const documentFixture = {
|
||||
documentCategory: 'passport',
|
||||
documentType: 'passport',
|
||||
} as IDDocument;
|
||||
|
||||
function createSelfClient() {
|
||||
function createSelfClient({
|
||||
selectedDocumentId = 'doc-1',
|
||||
documents = [],
|
||||
}: {
|
||||
selectedDocumentId?: string | undefined;
|
||||
documents?: Array<Record<string, unknown>>;
|
||||
} = {}) {
|
||||
const catalog = {
|
||||
selectedDocumentId,
|
||||
documents: [...documents],
|
||||
};
|
||||
|
||||
return {
|
||||
loadDocumentCatalog: async () => ({
|
||||
...catalog,
|
||||
documents: catalog.documents.map(doc => ({ ...doc })),
|
||||
}),
|
||||
saveDocumentCatalog: saveDocumentCatalogMock,
|
||||
getProtocolState: () => ({
|
||||
passport: {
|
||||
commitment_tree: 'passport-tree',
|
||||
@@ -111,3 +142,161 @@ describe('validateRecoverySecretForDocument', () => {
|
||||
expect(isUserRegisteredWithAlternativeCSCAMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeRecoveredDocumentRegistration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
saveDocumentCatalogMock.mockReset();
|
||||
});
|
||||
|
||||
it('rolls back document state when registration marking fails after a CSCA restore', async () => {
|
||||
restorePassportDataWithRightCSCAmock.mockImplementation(async (_client, document) => {
|
||||
document.passportMetadata = {
|
||||
...(document.passportMetadata ?? {}),
|
||||
csca: 'new-csca',
|
||||
};
|
||||
});
|
||||
markCurrentDocumentAsRegisteredMock.mockRejectedValue(new Error('catalog save failed'));
|
||||
|
||||
const document = {
|
||||
...documentFixture,
|
||||
passportMetadata: {
|
||||
csca: 'old-csca',
|
||||
},
|
||||
} as IDDocument;
|
||||
|
||||
await expect(
|
||||
finalizeRecoveredDocumentRegistration(
|
||||
createSelfClient({
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: false,
|
||||
registeredAt: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
document,
|
||||
'new-csca',
|
||||
),
|
||||
).rejects.toThrow('catalog save failed');
|
||||
|
||||
expect(restorePassportDataWithRightCSCAmock).toHaveBeenCalledWith(expect.anything(), document, 'new-csca');
|
||||
expect(storePassportDataMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
passportMetadata: expect.objectContaining({
|
||||
csca: 'old-csca',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(saveDocumentCatalogMock).toHaveBeenCalledWith({
|
||||
selectedDocumentId: 'doc-1',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: false,
|
||||
registeredAt: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updateDocumentRegistrationStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not attempt document restore when mark fails without a CSCA override', async () => {
|
||||
markCurrentDocumentAsRegisteredMock.mockRejectedValue(new Error('catalog save failed'));
|
||||
|
||||
await expect(
|
||||
finalizeRecoveredDocumentRegistration(
|
||||
createSelfClient({
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: false,
|
||||
registeredAt: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
documentFixture,
|
||||
),
|
||||
).rejects.toThrow('catalog save failed');
|
||||
|
||||
expect(restorePassportDataWithRightCSCAmock).not.toHaveBeenCalled();
|
||||
expect(storePassportDataMock).not.toHaveBeenCalled();
|
||||
expect(saveDocumentCatalogMock).toHaveBeenCalledWith({
|
||||
selectedDocumentId: 'doc-1',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: false,
|
||||
registeredAt: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updateDocumentRegistrationStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the original registration state when mark fails for a previously registered document', async () => {
|
||||
markCurrentDocumentAsRegisteredMock.mockRejectedValue(new Error('catalog save failed'));
|
||||
|
||||
await expect(
|
||||
finalizeRecoveredDocumentRegistration(
|
||||
createSelfClient({
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: true,
|
||||
registeredAt: 1234,
|
||||
},
|
||||
],
|
||||
}),
|
||||
documentFixture,
|
||||
),
|
||||
).rejects.toThrow('catalog save failed');
|
||||
|
||||
expect(saveDocumentCatalogMock).toHaveBeenCalledWith({
|
||||
selectedDocumentId: 'doc-1',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: true,
|
||||
registeredAt: 1234,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updateDocumentRegistrationStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still restores the registration state when restoring the original document fails', async () => {
|
||||
storePassportDataMock.mockRejectedValue(new Error('document rollback failed'));
|
||||
markCurrentDocumentAsRegisteredMock.mockRejectedValue(new Error('catalog save failed'));
|
||||
|
||||
await expect(
|
||||
finalizeRecoveredDocumentRegistration(
|
||||
createSelfClient({
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: false,
|
||||
registeredAt: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
documentFixture,
|
||||
'new-csca',
|
||||
),
|
||||
).rejects.toThrow('catalog save failed');
|
||||
|
||||
expect(storePassportDataMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveDocumentCatalogMock).toHaveBeenCalledWith({
|
||||
selectedDocumentId: 'doc-1',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
isRegistered: false,
|
||||
registeredAt: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"nice": "prettier --write . && eslint . --fix && tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -47,7 +47,13 @@ const EMPTY_WORDS = Array.from<string>({ length: WORD_COUNT }).fill('');
|
||||
const instruction = 'Enter your recovery phrase to restore your account, registered IDs, and activity history';
|
||||
|
||||
class RecoveryFlowError extends Error {
|
||||
constructor(readonly reason: 'storage_write_failed' | 'document_finalization_failed' | 'unexpected_error') {
|
||||
constructor(
|
||||
readonly reason:
|
||||
| 'document_unavailable'
|
||||
| 'storage_write_failed'
|
||||
| 'document_finalization_failed'
|
||||
| 'unexpected_error',
|
||||
) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
@@ -180,7 +186,11 @@ export const SecretPhraseInputScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
const selectedDocument = await loadSelectedDocument(client);
|
||||
const hasRealDocument = selectedDocument && !selectedDocument.metadata.mock;
|
||||
if (selectedDocument === null) {
|
||||
throw new RecoveryFlowError('document_unavailable');
|
||||
}
|
||||
|
||||
const hasRealDocument = !selectedDocument.metadata.mock;
|
||||
|
||||
if (!hasRealDocument) {
|
||||
await restoreSecretFromMnemonic(storage, mnemonic);
|
||||
@@ -193,7 +203,9 @@ export const SecretPhraseInputScreen: React.FC = () => {
|
||||
|
||||
haptic.trigger('success');
|
||||
analytics.trackEvent('recovery_phrase_recovered', { documentCategory: 'none' });
|
||||
navigate('/tunnel/kyc', { replace: true });
|
||||
if (isMountedRef.current) {
|
||||
navigate('/tunnel/kyc', { replace: true });
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
derivedSecret = derivePrivateKey(mnemonic);
|
||||
@@ -250,10 +262,12 @@ export const SecretPhraseInputScreen: React.FC = () => {
|
||||
analytics.trackEvent('recovery_phrase_recovered', {
|
||||
documentCategory: selectedDocument.data.documentCategory,
|
||||
});
|
||||
if (returnTo) {
|
||||
navigate(returnTo, { replace: true });
|
||||
} else {
|
||||
navigate('/recovery/success');
|
||||
if (isMountedRef.current) {
|
||||
if (returnTo) {
|
||||
navigate(returnTo, { replace: true });
|
||||
} else {
|
||||
navigate('/recovery/success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof RecoveryFlowError ? error.reason : 'unexpected_error';
|
||||
@@ -262,10 +276,12 @@ export const SecretPhraseInputScreen: React.FC = () => {
|
||||
analytics.trackEvent('recovery_phrase_failed', {
|
||||
reason,
|
||||
});
|
||||
navigate(buildRecoveryTarget('/recovery/failure', returnTo), {
|
||||
replace: true,
|
||||
state: returnTo ? { returnTo } : undefined,
|
||||
});
|
||||
if (isMountedRef.current) {
|
||||
navigate(buildRecoveryTarget('/recovery/failure', returnTo), {
|
||||
replace: true,
|
||||
state: returnTo ? { returnTo } : undefined,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
derivedSecret = null;
|
||||
if (isMountedRef.current) {
|
||||
|
||||
@@ -24,31 +24,32 @@ export const TourScreen: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDoc = await loadSelectedDocument(client);
|
||||
|
||||
console.log('selected Doc', selectedDoc);
|
||||
const isRegisteredRealDoc = selectedDoc?.metadata?.isRegistered === true;
|
||||
|
||||
if (isRegisteredRealDoc) {
|
||||
navigate('/tunnel/proof/disclose');
|
||||
} else {
|
||||
navigate('/tunnel/kyc');
|
||||
try {
|
||||
const selectedDoc = await loadSelectedDocument(client);
|
||||
if (selectedDoc?.metadata?.isRegistered === true) {
|
||||
navigate('/tunnel/proof/disclose');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to KYC when document state is unavailable.
|
||||
}
|
||||
|
||||
navigate('/tunnel/kyc');
|
||||
}, [navigate, stepNum, client]);
|
||||
|
||||
const onResore = useCallback(() => {
|
||||
const onRestore = useCallback(() => {
|
||||
navigate('/recovery');
|
||||
}, []);
|
||||
|
||||
switch (step) {
|
||||
case '1':
|
||||
return <LaunchTour1Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onResore} />;
|
||||
return <LaunchTour1Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />;
|
||||
case '2':
|
||||
return <LaunchTour2Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onResore} />;
|
||||
return <LaunchTour2Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />;
|
||||
case '3':
|
||||
return <LaunchTour3Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onResore} />;
|
||||
return <LaunchTour3Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />;
|
||||
case '4':
|
||||
return <LaunchTour4Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onResore} />;
|
||||
return <LaunchTour4Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />;
|
||||
default:
|
||||
return <Navigate to="/tunnel/tour/1" replace />;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@ function withSecretLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return next;
|
||||
}
|
||||
|
||||
async function readStoredSecretSnapshotUnlocked(storage: BridgeStorageAdapter): Promise<StoredSecretSnapshot> {
|
||||
const [mnemonic, secret] = await Promise.all([storage.get(MNEMONIC_KEY), storage.get(PRIVATE_KEY_KEY)]);
|
||||
|
||||
return {
|
||||
mnemonic,
|
||||
secret,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureSecret(storage: BridgeStorageAdapter): Promise<void> {
|
||||
return withSecretLock(async () => {
|
||||
const existing = await storage.get(PRIVATE_KEY_KEY);
|
||||
@@ -72,12 +81,7 @@ export function ensureSecret(storage: BridgeStorageAdapter): Promise<void> {
|
||||
}
|
||||
|
||||
export async function readStoredSecretSnapshot(storage: BridgeStorageAdapter): Promise<StoredSecretSnapshot> {
|
||||
const [mnemonic, secret] = await Promise.all([storage.get(MNEMONIC_KEY), storage.get(PRIVATE_KEY_KEY)]);
|
||||
|
||||
return {
|
||||
mnemonic,
|
||||
secret,
|
||||
};
|
||||
return withSecretLock(() => readStoredSecretSnapshotUnlocked(storage));
|
||||
}
|
||||
|
||||
export function restoreSecretFromMnemonic(
|
||||
@@ -87,13 +91,13 @@ export function restoreSecretFromMnemonic(
|
||||
const secret = derivePrivateKey(mnemonic);
|
||||
|
||||
return withSecretLock(async () => {
|
||||
const previousSnapshot = await readStoredSecretSnapshot(storage);
|
||||
const previousSnapshot = await readStoredSecretSnapshotUnlocked(storage);
|
||||
|
||||
try {
|
||||
await storage.set(MNEMONIC_KEY, mnemonic);
|
||||
await storage.set(PRIVATE_KEY_KEY, secret);
|
||||
} catch (error) {
|
||||
await writeSnapshot(storage, previousSnapshot);
|
||||
await restoreSnapshotBestEffort(storage, previousSnapshot, 'secret restore from mnemonic');
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -105,19 +109,72 @@ export function restoreStoredSecretSnapshot(
|
||||
storage: BridgeStorageAdapter,
|
||||
snapshot: StoredSecretSnapshot,
|
||||
): Promise<void> {
|
||||
return withSecretLock(() => writeSnapshot(storage, snapshot));
|
||||
return withSecretLock(async () => {
|
||||
const previousSnapshot = await readStoredSecretSnapshotUnlocked(storage);
|
||||
|
||||
try {
|
||||
await writeSnapshot(storage, snapshot);
|
||||
} catch (error) {
|
||||
await restoreSnapshotBestEffort(storage, previousSnapshot, 'secret snapshot restore');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreSnapshotBestEffort(
|
||||
storage: BridgeStorageAdapter,
|
||||
snapshot: StoredSecretSnapshot,
|
||||
context: string,
|
||||
): Promise<void> {
|
||||
const rollbackFailures: Error[] = [];
|
||||
|
||||
try {
|
||||
await writeMnemonic(storage, snapshot.mnemonic);
|
||||
} catch (rollbackError) {
|
||||
rollbackFailures.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)));
|
||||
}
|
||||
|
||||
try {
|
||||
await writeSecret(storage, snapshot.secret);
|
||||
} catch (rollbackError) {
|
||||
rollbackFailures.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)));
|
||||
}
|
||||
|
||||
if (rollbackFailures.length > 0) {
|
||||
// A partial rollback leaves mnemonic and secret mismatched — the derived
|
||||
// key from the stored mnemonic would not equal the stored secret. Clear
|
||||
// both so ensureSecret can regenerate a consistent pair from scratch.
|
||||
console.error(`Rollback failed during ${context}, clearing both keys to prevent mismatch:`, rollbackFailures);
|
||||
try {
|
||||
await storage.remove(MNEMONIC_KEY);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
await storage.remove(PRIVATE_KEY_KEY);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(storage: BridgeStorageAdapter, snapshot: StoredSecretSnapshot): Promise<void> {
|
||||
if (snapshot.mnemonic === null) {
|
||||
await writeMnemonic(storage, snapshot.mnemonic);
|
||||
await writeSecret(storage, snapshot.secret);
|
||||
}
|
||||
|
||||
async function writeMnemonic(storage: BridgeStorageAdapter, mnemonic: string | null): Promise<void> {
|
||||
if (mnemonic === null) {
|
||||
await storage.remove(MNEMONIC_KEY);
|
||||
} else {
|
||||
await storage.set(MNEMONIC_KEY, snapshot.mnemonic);
|
||||
}
|
||||
|
||||
if (snapshot.secret === null) {
|
||||
await storage.remove(PRIVATE_KEY_KEY);
|
||||
} else {
|
||||
await storage.set(PRIVATE_KEY_KEY, snapshot.secret);
|
||||
await storage.set(MNEMONIC_KEY, mnemonic);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSecret(storage: BridgeStorageAdapter, secret: string | null): Promise<void> {
|
||||
if (secret === null) {
|
||||
await storage.remove(PRIVATE_KEY_KEY);
|
||||
} else {
|
||||
await storage.set(PRIVATE_KEY_KEY, secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SecretPhraseInputScreen } from '../../../src/screens/recovery/SecretPhraseInputScreen';
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const analytics = { trackEvent: vi.fn() };
|
||||
const haptic = { trigger: vi.fn() };
|
||||
const lifecycle = { dismiss: vi.fn(), setResult: vi.fn() };
|
||||
const client = { id: 'client' };
|
||||
|
||||
const loadSelectedDocumentMock = vi.fn();
|
||||
const validateRecoverySecretForDocumentMock = vi.fn();
|
||||
const finalizeRecoveredDocumentRegistrationMock = vi.fn();
|
||||
const restoreSecretFromMnemonicMock = vi.fn();
|
||||
const readStoredSecretSnapshotMock = vi.fn();
|
||||
const restoreStoredSecretSnapshotMock = vi.fn();
|
||||
|
||||
vi.mock('../../../src/providers/SelfClientProvider', () => ({
|
||||
useSelfClient: () => ({
|
||||
client,
|
||||
analytics,
|
||||
haptic,
|
||||
lifecycle,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/providers/BridgeProvider', () => ({
|
||||
useBridge: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('@selfxyz/webview-bridge/adapters', () => ({
|
||||
bridgeStorageAdapter: () => ({
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@selfxyz/mobile-sdk-alpha/browser', () => ({
|
||||
loadSelectedDocument: (...args: unknown[]) => loadSelectedDocumentMock(...args),
|
||||
validateRecoverySecretForDocument: (...args: unknown[]) => validateRecoverySecretForDocumentMock(...args),
|
||||
finalizeRecoveredDocumentRegistration: (...args: unknown[]) => finalizeRecoveredDocumentRegistrationMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/secretManager', () => ({
|
||||
derivePrivateKey: (mnemonic: string) => `derived-from-${mnemonic.split(' ')[0]}`,
|
||||
readStoredSecretSnapshot: (...args: unknown[]) => readStoredSecretSnapshotMock(...args),
|
||||
restoreSecretFromMnemonic: (...args: unknown[]) => restoreSecretFromMnemonicMock(...args),
|
||||
restoreStoredSecretSnapshot: (...args: unknown[]) => restoreStoredSecretSnapshotMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/insets', () => ({
|
||||
WEB_SAFE_AREA: {
|
||||
insets: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
},
|
||||
}));
|
||||
|
||||
const VALID_12_MNEMONIC =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
vi.mock('@selfxyz/euclid', () => ({
|
||||
Button: ({ text, onPress, disabled }: { text: string; onPress: () => void; disabled?: boolean }) => (
|
||||
<button onClick={onPress} disabled={disabled ?? false} type="button">
|
||||
{text}
|
||||
</button>
|
||||
),
|
||||
colors: { slate50: '#f8fafc', black: '#000', white: '#fff', red600: '#dc2626', slate300: '#cbd5e1' },
|
||||
fontFamily: { dinOT: 'DIN OT' },
|
||||
fontWeight: { medium: 500 },
|
||||
spacing: { md: 12, mdLg: 16, lg: 20, xl: 24, xlLg: 32, sm: 8 },
|
||||
LeftArrowIcon: () => null,
|
||||
SecretPhraseInput: ({ onWordChange }: { onWordChange: (index: number, word: string) => void }) => {
|
||||
useEffect(() => {
|
||||
VALID_12_MNEMONIC.split(' ').forEach((w, i) => onWordChange(i, w));
|
||||
}, []);
|
||||
return <div data-testid="phrase-input" />;
|
||||
},
|
||||
TopNavigationDialogue: () => <div />,
|
||||
}));
|
||||
|
||||
const LocationDisplay: React.FC = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{`${location.pathname}${location.search}`}</div>;
|
||||
};
|
||||
|
||||
const renderScreen = (initialEntry = '/recovery/phrase-input') =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/recovery/phrase-input" element={<SecretPhraseInputScreen />} />
|
||||
<Route path="/recovery/failure" element={<LocationDisplay />} />
|
||||
<Route path="/recovery/success" element={<LocationDisplay />} />
|
||||
<Route path="/tunnel/kyc" element={<LocationDisplay />} />
|
||||
<Route path="*" element={<LocationDisplay />} />
|
||||
</Routes>
|
||||
<LocationDisplay />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
async function fillWordsAndSubmit() {
|
||||
await act(async () => {
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
|
||||
}
|
||||
|
||||
describe('SecretPhraseInputScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
restoreSecretFromMnemonicMock.mockResolvedValue({ secret: 'derived-secret' });
|
||||
readStoredSecretSnapshotMock.mockResolvedValue({ mnemonic: null, secret: null });
|
||||
restoreStoredSecretSnapshotMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const expectLocation = (expected: string) => {
|
||||
const locations = screen.getAllByTestId('location');
|
||||
expect(locations.at(-1)?.textContent).toBe(expected);
|
||||
};
|
||||
|
||||
it('navigates to failure when loadSelectedDocument returns null', async () => {
|
||||
loadSelectedDocumentMock.mockResolvedValue(null);
|
||||
|
||||
renderScreen();
|
||||
await fillWordsAndSubmit();
|
||||
|
||||
await waitFor(() => {
|
||||
expectLocation('/recovery/failure');
|
||||
});
|
||||
|
||||
expect(analytics.trackEvent).toHaveBeenCalledWith('recovery_phrase_failed', {
|
||||
reason: 'document_unavailable',
|
||||
});
|
||||
expect(haptic.trigger).toHaveBeenCalledWith('error');
|
||||
});
|
||||
|
||||
it('navigates to /tunnel/kyc for a mock document', async () => {
|
||||
loadSelectedDocumentMock.mockResolvedValue({
|
||||
data: { documentCategory: 'passport' },
|
||||
metadata: { mock: true },
|
||||
});
|
||||
|
||||
renderScreen();
|
||||
await fillWordsAndSubmit();
|
||||
|
||||
await waitFor(() => {
|
||||
expectLocation('/tunnel/kyc');
|
||||
});
|
||||
|
||||
expect(restoreSecretFromMnemonicMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not navigate after unmount during async validation', async () => {
|
||||
let resolveDocument!: (value: unknown) => void;
|
||||
loadSelectedDocumentMock.mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveDocument = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = renderScreen();
|
||||
await fillWordsAndSubmit();
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
resolveDocument(null);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(haptic.trigger).toHaveBeenCalledWith('error');
|
||||
});
|
||||
|
||||
it('navigates to failure when storage write fails during recovery', async () => {
|
||||
loadSelectedDocumentMock.mockResolvedValue({
|
||||
data: { documentCategory: 'passport' },
|
||||
metadata: { mock: false, isRegistered: true },
|
||||
});
|
||||
validateRecoverySecretForDocumentMock.mockResolvedValue({
|
||||
isRegistered: true,
|
||||
csca: 'matching-csca',
|
||||
});
|
||||
restoreSecretFromMnemonicMock.mockRejectedValue(new Error('write failed'));
|
||||
|
||||
renderScreen();
|
||||
await fillWordsAndSubmit();
|
||||
|
||||
await waitFor(() => {
|
||||
expectLocation('/recovery/failure');
|
||||
});
|
||||
|
||||
expect(analytics.trackEvent).toHaveBeenCalledWith('recovery_phrase_failed', {
|
||||
reason: 'storage_write_failed',
|
||||
});
|
||||
expect(restoreStoredSecretSnapshotMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,13 @@
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { derivePrivateKey, ensureSecret, restoreSecretFromMnemonic } from '../../src/utils/secretManager';
|
||||
import {
|
||||
derivePrivateKey,
|
||||
ensureSecret,
|
||||
readStoredSecretSnapshot,
|
||||
restoreSecretFromMnemonic,
|
||||
restoreStoredSecretSnapshot,
|
||||
} from '../../src/utils/secretManager';
|
||||
|
||||
type Deferred = {
|
||||
promise: Promise<void>;
|
||||
@@ -105,6 +111,43 @@ describe('restoreSecretFromMnemonic', () => {
|
||||
expect(storageState.get('self_mnemonic')).toBe(mnemonic);
|
||||
expect(storageState.get('self_private_key')).toBe(derivePrivateKey(mnemonic));
|
||||
});
|
||||
|
||||
it('clears both keys when mnemonic rollback fails to prevent mismatch', async () => {
|
||||
const storageState = new Map<string, string>();
|
||||
const originalMnemonic = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||
const originalSecret = derivePrivateKey(originalMnemonic);
|
||||
|
||||
storageState.set('self_mnemonic', originalMnemonic);
|
||||
storageState.set('self_private_key', originalSecret);
|
||||
|
||||
const newMnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const newSecret = derivePrivateKey(newMnemonic);
|
||||
|
||||
let failPrivateKeyWrite = true;
|
||||
let failMnemonicRollback = true;
|
||||
const storage = {
|
||||
get: async (key: string) => storageState.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
if (key === 'self_private_key' && value === newSecret && failPrivateKeyWrite) {
|
||||
failPrivateKeyWrite = false;
|
||||
throw new Error('private key write failed');
|
||||
}
|
||||
if (key === 'self_mnemonic' && value === originalMnemonic && failMnemonicRollback) {
|
||||
failMnemonicRollback = false;
|
||||
throw new Error('mnemonic rollback failed');
|
||||
}
|
||||
storageState.set(key, value);
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
storageState.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(restoreSecretFromMnemonic(storage, newMnemonic)).rejects.toThrow('private key write failed');
|
||||
|
||||
expect(storageState.get('self_mnemonic')).toBeUndefined();
|
||||
expect(storageState.get('self_private_key')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureSecret', () => {
|
||||
@@ -154,3 +197,128 @@ describe('ensureSecret', () => {
|
||||
expect(storageState.get('self_private_key')).toBe('0xexisting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readStoredSecretSnapshot', () => {
|
||||
it('reads mnemonic and private key under the shared lock', async () => {
|
||||
const storageState = new Map<string, string>();
|
||||
const firstMnemonicWrite = createDeferred();
|
||||
let mnemonicSetCount = 0;
|
||||
|
||||
storageState.set(
|
||||
'self_mnemonic',
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
storageState.set('self_private_key', derivePrivateKey(storageState.get('self_mnemonic')!));
|
||||
|
||||
const storage = {
|
||||
get: async (key: string) => storageState.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
if (key === 'self_mnemonic') {
|
||||
mnemonicSetCount += 1;
|
||||
if (mnemonicSetCount === 1) {
|
||||
storageState.set(key, value);
|
||||
await firstMnemonicWrite.promise;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
storageState.set(key, value);
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
storageState.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
const nextMnemonic = 'legal winner thank year wave sausage worth useful legal winner thank yellow';
|
||||
const restorePromise = restoreSecretFromMnemonic(storage, nextMnemonic);
|
||||
const snapshotPromise = readStoredSecretSnapshot(storage);
|
||||
|
||||
await Promise.resolve();
|
||||
firstMnemonicWrite.resolve();
|
||||
|
||||
await restorePromise;
|
||||
const snapshot = await snapshotPromise;
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
mnemonic: nextMnemonic,
|
||||
secret: derivePrivateKey(nextMnemonic),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreStoredSecretSnapshot', () => {
|
||||
it('restores the previous snapshot when writing the replacement snapshot fails', async () => {
|
||||
const storageState = new Map<string, string>();
|
||||
const targetSnapshot = {
|
||||
mnemonic: 'legal winner thank year wave sausage worth useful legal winner thank yellow',
|
||||
secret: derivePrivateKey('legal winner thank year wave sausage worth useful legal winner thank yellow'),
|
||||
};
|
||||
|
||||
storageState.set(
|
||||
'self_mnemonic',
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
storageState.set('self_private_key', derivePrivateKey(storageState.get('self_mnemonic')!));
|
||||
|
||||
let failPrivateKeyWrite = true;
|
||||
const storage = {
|
||||
get: async (key: string) => storageState.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
storageState.set(key, value);
|
||||
if (key === 'self_private_key' && failPrivateKeyWrite) {
|
||||
failPrivateKeyWrite = false;
|
||||
throw new Error('write failed');
|
||||
}
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
storageState.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(restoreStoredSecretSnapshot(storage, targetSnapshot)).rejects.toThrow('write failed');
|
||||
expect(storageState.get('self_mnemonic')).toBe(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
expect(storageState.get('self_private_key')).toBe(
|
||||
derivePrivateKey('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears both keys when mnemonic rollback fails to prevent mismatch', async () => {
|
||||
const storageState = new Map<string, string>();
|
||||
const originalMnemonic =
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const originalSecret = derivePrivateKey(originalMnemonic);
|
||||
const targetSnapshot = {
|
||||
mnemonic: 'legal winner thank year wave sausage worth useful legal winner thank yellow',
|
||||
secret: derivePrivateKey('legal winner thank year wave sausage worth useful legal winner thank yellow'),
|
||||
};
|
||||
|
||||
storageState.set('self_mnemonic', originalMnemonic);
|
||||
storageState.set('self_private_key', originalSecret);
|
||||
|
||||
let failTargetPrivateKeyWrite = true;
|
||||
let failRollbackMnemonicWrite = true;
|
||||
const storage = {
|
||||
get: async (key: string) => storageState.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
storageState.set(key, value);
|
||||
if (key === 'self_private_key' && failTargetPrivateKeyWrite) {
|
||||
failTargetPrivateKeyWrite = false;
|
||||
throw new Error('write failed');
|
||||
}
|
||||
if (key === 'self_mnemonic' && value === originalMnemonic && failRollbackMnemonicWrite) {
|
||||
failRollbackMnemonicWrite = false;
|
||||
throw new Error('mnemonic rollback failed');
|
||||
}
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
storageState.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(restoreStoredSecretSnapshot(storage, targetSnapshot)).rejects.toThrow('write failed');
|
||||
expect(storageState.get('self_mnemonic')).toBeUndefined();
|
||||
expect(storageState.get('self_private_key')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user