From 79747f1ee3cef72738033ed2a382ccc01f73c034 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:12:43 +0800 Subject: [PATCH 1/2] feat: add dataType migration for seedless onboarding secrets --- .../controllers/app-state-controller.ts | 25 ++ app/scripts/metamask-controller.js | 120 +++++++++- app/scripts/metamask-controller.test.js | 213 +++++++++++++++++- shared/constants/app-state.ts | 8 + 4 files changed, 358 insertions(+), 8 deletions(-) diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index 355bf14e0920..a451c1dfc8de 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -160,6 +160,12 @@ export type AppStateControllerState = { * Whether the wallet reset is in progress. */ isWalletResetInProgress: boolean; + + /** + * Tracks which seedless onboarding migrations have been applied. + * See `SeedlessOnboardingMigrationVersion` for version details. + */ + seedlessOnboardingMigrationVersion: number; }; const controllerName = 'AppStateController'; @@ -312,6 +318,7 @@ const getDefaultAppStateControllerState = (): AppStateControllerState => ({ pendingShieldCohortTxType: null, isWalletResetInProgress: false, dappSwapComparisonData: {}, + seedlessOnboardingMigrationVersion: 0, ...getInitialStateOverrides(), }); @@ -691,6 +698,12 @@ const controllerMetadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + seedlessOnboardingMigrationVersion: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, }; export class AppStateController extends BaseController< @@ -1630,6 +1643,18 @@ export class AppStateController extends BaseController< return this.state.isWalletResetInProgress; } + /** + * Set the seedless onboarding migration version. + * This tracks which migrations have been applied to prevent re-running them. + * + * @param version - The migration version to set. + */ + setSeedlessOnboardingMigrationVersion(version: number): void { + this.update((state) => { + state.seedlessOnboardingMigrationVersion = version; + }); + } + setDefaultSubscriptionPaymentOptions( defaultSubscriptionPaymentOptions: DefaultSubscriptionPaymentOptions, ): void { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 8f6764bba955..ded2db5fe650 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -128,6 +128,7 @@ import { SeedlessOnboardingControllerErrorMessage, SecretType, RecoveryError, + EncAccountDataType, } from '@metamask/seedless-onboarding-controller'; import { COHORT_NAMES, PRODUCT_TYPES } from '@metamask/subscription-controller'; import { isSnapId } from '@metamask/snaps-utils'; @@ -169,6 +170,7 @@ import { MetaMetricsEventName, MetaMetricsRequestedThrough, } from '../../shared/constants/metametrics'; +import { SeedlessOnboardingMigrationVersion } from '../../shared/constants/app-state'; import { getStorageItem, @@ -452,6 +454,7 @@ export const METAMASK_CONTROLLER_EVENTS = { /** * @typedef {import('../../ui/store/store').MetaMaskReduxState} MetaMaskReduxState + * @typedef {import('@metamask/seedless-onboarding-controller').SecretMetadata} SecretMetadata */ // Types of APIs @@ -3976,6 +3979,11 @@ export default class MetamaskController extends EventEmitter { ); createSeedPhraseBackupSuccess = true; + // Set migration version for new users so migration never runs + this.appStateController.setSeedlessOnboardingMigrationVersion( + SeedlessOnboardingMigrationVersion.DataType, + ); + await this.syncKeyringEncryptionKey(); } catch (error) { const errorMessage = @@ -4001,10 +4009,10 @@ export default class MetamaskController extends EventEmitter { } /** - * Fetches and restores all the backed-up Secret Data (SRPs and Private keys) + * Fetches all backed-up Secret Data (SRPs and Private keys) from the server. * * @param {string} password - The user's password. - * @returns {Promise} The seed phrase. + * @returns {Promise} Array of secret metadata items. */ async fetchAllSecretData(password) { let fetchAllSeedPhrasesSuccess = false; @@ -4321,6 +4329,7 @@ export default class MetamaskController extends EventEmitter { SecretType.Mnemonic, { keyringId, + dataType: EncAccountDataType.ImportedSrp, }, ); addNewSeedPhraseBackupSuccess = true; @@ -4670,11 +4679,9 @@ export default class MetamaskController extends EventEmitter { } /** - * Restores an array of seed phrases to the vault and updates the SocialBackupMetadataState if import is successful. + * Restores an array of seed phrases to the vault. * - * This method is used to restore seed phrases from the Social Backup. - * - * @param {{data: Uint8Array, type: SecretType, timestamp: number, version: number}[]} secretDatas - The seed phrases to restore. + * @param {SecretMetadata[]} secretDatas - The secret metadata items to restore. * @returns {Promise} */ async restoreSeedPhrasesToVault(secretDatas) { @@ -5285,6 +5292,14 @@ export default class MetamaskController extends EventEmitter { // eslint-disable-next-line no-void void resyncAndAlignAccounts(); } + + // Run seedless onboarding migrations asynchronously after unlock for social login users + if (isSocialLoginFlow) { + // eslint-disable-next-line no-void + void this._runSeedlessOnboardingMigrations().catch((err) => { + log.error('Error during seedless onboarding migrations', err); + }); + } } async _loginUser(password) { @@ -6129,7 +6144,7 @@ export default class MetamaskController extends EventEmitter { await this.seedlessOnboardingController.addNewSecretData( bufferedPrivateKey, SecretType.PrivateKey, - { keyringId }, + { keyringId, dataType: EncAccountDataType.ImportedPrivateKey }, ); } catch (error) { log.error('Error adding new private key backup', error); @@ -7917,6 +7932,97 @@ export default class MetamaskController extends EventEmitter { this.emit('unlock'); } + /** + * Run seedless onboarding migrations based on the current migration version. + * + * @returns {Promise} + */ + async _runSeedlessOnboardingMigrations() { + const { seedlessOnboardingMigrationVersion } = + this.appStateController.state; + const { completedOnboarding } = this.onboardingController.state; + + if (!completedOnboarding) { + return; + } + + if ( + seedlessOnboardingMigrationVersion < + SeedlessOnboardingMigrationVersion.DataType + ) { + await this._migrateSeedlessDataTypes(); + } + } + + /** + * Assigns dataType (PrimarySrp/ImportedSrp/ImportedPrivateKey) to legacy secrets. + * Data is pre-sorted by server (PrimarySrp first, then by createdAt). + * + * @returns {Promise} + */ + async _migrateSeedlessDataTypes() { + try { + const secretDatas = await this.fetchAllSecretData(); + + if (!secretDatas || secretDatas.length === 0) { + this.appStateController.setSeedlessOnboardingMigrationVersion( + SeedlessOnboardingMigrationVersion.DataType, + ); + return; + } + + let hasPrimarySrp = secretDatas.some( + (secret) => secret.dataType === EncAccountDataType.PrimarySrp, + ); + + const updates = []; + + for (const secret of secretDatas) { + if (!secret.itemId || secret.itemId === 'PW_BACKUP') { + continue; + } + + if (secret.dataType !== undefined && secret.dataType !== null) { + continue; + } + + let dataType; + + if (secret.type === SecretType.Mnemonic) { + if (hasPrimarySrp) { + dataType = EncAccountDataType.ImportedSrp; + } else { + dataType = EncAccountDataType.PrimarySrp; + hasPrimarySrp = true; + } + } else if (secret.type === SecretType.PrivateKey) { + dataType = EncAccountDataType.ImportedPrivateKey; + } else { + continue; + } + + updates.push({ itemId: secret.itemId, dataType }); + } + + if (updates.length === 1) { + await this.seedlessOnboardingController.updateSecretDataItem( + updates[0], + ); + } else if (updates.length > 1) { + await this.seedlessOnboardingController.batchUpdateSecretDataItems({ + updates, + }); + } + + this.appStateController.setSeedlessOnboardingMigrationVersion( + SeedlessOnboardingMigrationVersion.DataType, + ); + } catch (error) { + log.error('Failed to migrate seedless data types', error); + throw error; + } + } + /** * Handle global application lock. */ diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 8aef695dc1ee..da8fba5dee1b 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -66,7 +66,10 @@ import { withResolvers } from '../../shared/lib/promise-with-resolvers'; import { flushPromises } from '../../test/lib/timer-helpers'; import { FirstTimeFlowType } from '../../shared/constants/onboarding'; import { MultichainNetworks } from '../../shared/constants/multichain/networks'; -import { HYPERLIQUID_APPROVAL_TYPE } from '../../shared/constants/app'; +import { + HYPERLIQUID_APPROVAL_TYPE, + SeedlessOnboardingMigrationVersion, +} from '../../shared/constants/app'; import { HYPERLIQUID_ORIGIN } from '../../shared/constants/referrals'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { ReferralStatus } from './controllers/preferences-controller'; @@ -838,6 +841,78 @@ describe('MetaMaskController', () => { keyringEncryptionKey, ); }); + + it('should set migration version after successful backup', async () => { + const password = 'a-fake-password'; + const mockSeedPhrase = + 'mock seed phrase one two three four five six seven eight nine ten'; + const mockEncodedSeedPhrase = Array.from( + Buffer.from(mockSeedPhrase, 'utf8').values(), + ); + + jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'createToprfKeyAndBackupSeedPhrase', + ) + .mockResolvedValueOnce(); + jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'storeKeyringEncryptionKey', + ) + .mockResolvedValueOnce(); + const setSeedlessOnboardingMigrationVersionSpy = jest.spyOn( + metamaskController.appStateController, + 'setSeedlessOnboardingMigrationVersion', + ); + + const primaryKeyring = + await metamaskController.createNewVaultAndKeychain(password); + + await metamaskController.createSeedPhraseBackup( + password, + mockEncodedSeedPhrase, + primaryKeyring.metadata.id, + ); + + expect(setSeedlessOnboardingMigrationVersionSpy).toHaveBeenCalledWith( + SeedlessOnboardingMigrationVersion.DataType, + ); + }); + + it('should not set migration version if backup fails', async () => { + const password = 'a-fake-password'; + const mockSeedPhrase = + 'mock seed phrase one two three four five six seven eight nine ten'; + const mockEncodedSeedPhrase = Array.from( + Buffer.from(mockSeedPhrase, 'utf8').values(), + ); + + jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'createToprfKeyAndBackupSeedPhrase', + ) + .mockRejectedValueOnce(new Error('Backup failed')); + const setSeedlessOnboardingMigrationVersionSpy = jest.spyOn( + metamaskController.appStateController, + 'setSeedlessOnboardingMigrationVersion', + ); + + const primaryKeyring = + await metamaskController.createNewVaultAndKeychain(password); + + await expect( + metamaskController.createSeedPhraseBackup( + password, + mockEncodedSeedPhrase, + primaryKeyring.metadata.id, + ), + ).rejects.toThrow('Backup failed'); + + expect(setSeedlessOnboardingMigrationVersionSpy).not.toHaveBeenCalled(); + }); }); describe('#createNewVaultAndRestore', () => { @@ -4186,6 +4261,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'primary-srp-id', + dataType: 1, // PrimarySrp }; const mockRemainingSecretData = [ { @@ -4193,6 +4270,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'imported-srp-id', + dataType: 2, // ImportedSrp }, ]; @@ -4230,6 +4309,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'primary-srp-id', + dataType: 1, // PrimarySrp }; metamaskController.seedlessOnboardingController.fetchAllSecretData.mockResolvedValue( @@ -4260,6 +4341,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'primary-srp-id', + dataType: 1, // PrimarySrp }; const mockRemainingSecretData = [ { @@ -4267,18 +4350,24 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'imported-srp-id-1', + dataType: 2, // ImportedSrp }, { data: new Uint8Array([15, 16, 17, 18]), type: 'privateKey', timestamp: Date.now(), version: 1, + itemId: 'imported-pk-id', + dataType: 3, // ImportedPrivateKey }, { data: new Uint8Array([19, 20, 21, 22]), type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'imported-srp-id-2', + dataType: 2, // ImportedSrp }, ]; @@ -4318,6 +4407,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'primary-srp-id', + dataType: 1, // PrimarySrp }; metamaskController.seedlessOnboardingController.fetchAllSecretData.mockResolvedValue( @@ -4344,6 +4435,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'primary-srp-id', + dataType: 1, // PrimarySrp }; const mockRemainingSecretData = [ { @@ -4351,6 +4444,8 @@ describe('MetaMaskController', () => { type: 'mnemonic', timestamp: Date.now(), version: 1, + itemId: 'imported-srp-id', + dataType: 2, // ImportedSrp }, ]; @@ -4373,6 +4468,122 @@ describe('MetaMaskController', () => { }); }); + describe('#addNewSeedPhraseBackup', () => { + it('should call addNewSecretData with ImportedSrp dataType', async () => { + await metamaskController.createNewVaultAndKeychain('test-password'); + const addNewSecretDataSpy = jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'addNewSecretData', + ) + .mockResolvedValue(); + + const mockMnemonic = + 'debris dizzy just program just float decrease vacant alarm reduce speak stadium'; + const mockKeyringId = 'test-keyring-id'; + + await metamaskController.addNewSeedPhraseBackup( + mockMnemonic, + mockKeyringId, + true, + ); + + expect(addNewSecretDataSpy).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'mnemonic', + { + keyringId: mockKeyringId, + dataType: 2, // EncAccountDataType.ImportedSrp + }, + ); + }); + }); + + describe('#addNewPrivateKeyBackup', () => { + it('should call addNewSecretData with ImportedPrivateKey dataType', async () => { + await metamaskController.createNewVaultAndKeychain('test-password'); + const addNewSecretDataSpy = jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'addNewSecretData', + ) + .mockResolvedValue(); + + const mockPrivateKey = + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const mockKeyringId = 'test-keyring-id'; + + await metamaskController.addNewPrivateKeyBackup( + mockPrivateKey, + mockKeyringId, + true, + ); + + expect(addNewSecretDataSpy).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'privateKey', + { + keyringId: mockKeyringId, + dataType: 3, // EncAccountDataType.ImportedPrivateKey + }, + ); + }); + }); + + describe('#_migrateSeedlessDataTypes', () => { + it('should assign PrimarySrp to first mnemonic and ImportedSrp to others', async () => { + await metamaskController.createNewVaultAndKeychain('test-password'); + jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'fetchAllSecretData', + ) + .mockResolvedValue([ + { + data: new Uint8Array([1, 2, 3]), + type: 'mnemonic', + itemId: 'srp-1', + dataType: undefined, + }, + { + data: new Uint8Array([4, 5, 6]), + type: 'mnemonic', + itemId: 'srp-2', + dataType: undefined, + }, + // Already has dataType - should be skipped + { + data: new Uint8Array([7, 8, 9]), + type: 'privateKey', + itemId: 'pk-1', + dataType: 3, + }, + ]); + const batchUpdateSpy = jest + .spyOn( + metamaskController.seedlessOnboardingController, + 'batchUpdateSecretDataItems', + ) + .mockResolvedValue(); + const setMigrationVersionSpy = jest.spyOn( + metamaskController.appStateController, + 'setSeedlessOnboardingMigrationVersion', + ); + + await metamaskController._migrateSeedlessDataTypes(); + + expect(batchUpdateSpy).toHaveBeenCalledWith({ + updates: [ + { itemId: 'srp-1', dataType: 1 }, // PrimarySrp (first mnemonic) + { itemId: 'srp-2', dataType: 2 }, // ImportedSrp (second mnemonic) + ], + }); + expect(setMigrationVersionSpy).toHaveBeenCalledWith( + SeedlessOnboardingMigrationVersion.DataType, + ); + }); + }); + describe('handleHyperliquidReferral', () => { const mockTabId = 140; const mockNewConnectionTriggerType = 'new_connection'; diff --git a/shared/constants/app-state.ts b/shared/constants/app-state.ts index 0ae1f0667255..05a7761a31f7 100644 --- a/shared/constants/app-state.ts +++ b/shared/constants/app-state.ts @@ -57,3 +57,11 @@ export type NetworkConnectionBanner = chainId: Hex; isInfuraEndpoint: boolean; }; + +/** + * Seedless onboarding migration versions. + * - DataType (1): Assigns PrimarySrp/ImportedSrp/ImportedPrivateKey to legacy secrets + */ +export enum SeedlessOnboardingMigrationVersion { + DataType = 1, +} From 2e1104519d02323a8838636f1d6974604f7624a6 Mon Sep 17 00:00:00 2001 From: huggingbot <83656073+huggingbot@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:08:09 +0800 Subject: [PATCH 2/2] fix: Run seedless onboarding migrations during the restore flow --- app/scripts/metamask-controller.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1f0d7e6f932c..c4cbb2842f22 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -4778,6 +4778,12 @@ export default class MetamaskController extends EventEmitter { await this.restoreSeedPhrasesToVault(remainingSecretData); } + // Run migration for existing users rehydrating their data. + // Skip onboarding check because completedOnboarding is not yet true during restore flow. + await this._runSeedlessOnboardingMigrations({ + skipOnboardingCheck: true, + }); + return mnemonic; } catch (error) { if (error instanceof RecoveryError) { @@ -7939,14 +7945,17 @@ export default class MetamaskController extends EventEmitter { /** * Run seedless onboarding migrations based on the current migration version. * + * @param {object} options - Options for migration. + * @param {boolean} options.skipOnboardingCheck - If true, skips the completedOnboarding check. + * Used during restoreSocialBackupAndGetSeedPhrase where onboarding is not yet complete. * @returns {Promise} */ - async _runSeedlessOnboardingMigrations() { + async _runSeedlessOnboardingMigrations({ skipOnboardingCheck = false } = {}) { const { seedlessOnboardingMigrationVersion } = this.appStateController.state; const { completedOnboarding } = this.onboardingController.state; - if (!completedOnboarding) { + if (!skipOnboardingCheck && !completedOnboarding) { return; }