Skip to content

feat(backup & sync): use entropySourceId to sync accounts for Multi-SRP #5753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1664229
feat(sync): spray entropySourceId on all methods that might use it. P…
mirceanis May 2, 2025
98c3f84
Merge branch 'main' into use-entropySourceId-for-account-sync
mathieuartu May 14, 2025
09b6304
feat: implement multi-auth
mathieuartu May 14, 2025
d1af642
feat: deprecate sessionData in favor of srpSessionData
mathieuartu May 14, 2025
8de4a4e
fix: fixtures
mathieuartu May 14, 2025
ce35c9c
fix: notifications test
mathieuartu May 14, 2025
df2ec14
feat: implement multi-SRP EVM account syncing
mathieuartu May 14, 2025
73f7edc
fix: UTs & mocks
mathieuartu May 14, 2025
72f8d2a
fix: eslint exception
mathieuartu May 14, 2025
7d013d6
fix: lint
mathieuartu May 14, 2025
5cc6dab
fix: cover multi-auth & multi-srp account syncing with UTs
mathieuartu May 15, 2025
6a340ce
fix: JSON.stringify oddity
mathieuartu May 15, 2025
48028bf
fix: move @metamask/keyring-api from dependencies to devDependencies
mathieuartu May 15, 2025
df5b08a
Merge branch 'main' into use-entropySourceId-for-account-sync
mathieuartu May 15, 2025
646215b
fix: add back keyring-api
mathieuartu May 15, 2025
bb8788c
fix: remove
mathieuartu May 16, 2025
0c874fe
feat: update auth mock responses to support multi-srp auth & storage …
mathieuartu May 16, 2025
eda7773
fix: remove comment
mathieuartu May 20, 2025
0a1282f
feat: conditionally call updateAccounts in getInternalAccountsList
mathieuartu May 21, 2025
a829791
Merge branch 'main' into use-entropySourceId-for-account-sync
mathieuartu May 22, 2025
d95af68
fix: remove accountAdded listeners because AccountsController emits a…
mathieuartu May 22, 2025
1b79c3a
fix: PR feedbacks
mathieuartu May 22, 2025
f6dbfb0
chore: use simpler cast instead of stringify and replace
mirceanis May 22, 2025
e68cd30
docs: add changelog
mirceanis May 22, 2025
5b99329
Merge branch 'main' into use-entropySourceId-for-account-sync
mathieuartu Jun 3, 2025
56df99c
fix: update CHANGELOG
mathieuartu Jun 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1231,7 +1231,7 @@ function mockNotificationMessenger() {

const mockAuthPerformSignIn =
typedMockAction<AuthenticationController.AuthenticationControllerPerformSignIn>().mockResolvedValue(
'New Access Token',
['New Access Token'],
);

const mockDisablePushNotifications =
Expand Down
6 changes: 6 additions & 0 deletions packages/profile-sync-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add account syncing support for multiple SRPs ([#5753](https://github.com/MetaMask/core/pull/5753))
- Add `entropySource` based authentication support for multiple SRPs
- Add `entropySource` optional parameter for `UserStorageController` CRUD methods

## [16.0.0]

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/profile-sync-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@
},
"dependencies": {
"@metamask/base-controller": "^8.0.1",
"@metamask/keyring-api": "^18.0.0",
"@metamask/snaps-sdk": "^7.1.0",
"@metamask/snaps-utils": "^9.4.0",
"@noble/ciphers": "^0.5.2",
Expand All @@ -115,6 +114,7 @@
"@lavamoat/preinstall-always-fail": "^2.1.0",
"@metamask/accounts-controller": "^30.0.0",
"@metamask/auto-changelog": "^3.4.4",
"@metamask/keyring-api": "^18.0.0",
"@metamask/keyring-controller": "^22.0.1",
"@metamask/keyring-internal-api": "^6.2.0",
"@metamask/network-controller": "^23.5.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,38 @@ import {
MOCK_LOGIN_RESPONSE,
MOCK_OATH_TOKEN_RESPONSE,
} from './mocks/mockResponses';
import type { LoginResponse } from '../../sdk';
import { Platform } from '../../sdk';
import { arrangeAuthAPIs } from '../../sdk/__fixtures__/auth';

const mockSignedInState = (): AuthenticationControllerState => ({
isSignedIn: true,
sessionData: {
token: {
accessToken: MOCK_OATH_TOKEN_RESPONSE.access_token,
expiresIn: Date.now() + 3600,
obtainedAt: 0,
},
profile: {
identifierId: MOCK_LOGIN_RESPONSE.profile.identifier_id,
profileId: MOCK_LOGIN_RESPONSE.profile.profile_id,
metaMetricsId: MOCK_LOGIN_RESPONSE.profile.metametrics_id,
},
},
});
const MOCK_ENTROPY_SOURCE_IDS = [
'MOCK_ENTROPY_SOURCE_ID',
'MOCK_ENTROPY_SOURCE_ID2',
];

const mockSignedInState = (): AuthenticationControllerState => {
const srpSessionData = {} as Record<string, LoginResponse>;

MOCK_ENTROPY_SOURCE_IDS.forEach((id) => {
srpSessionData[id] = {
token: {
accessToken: MOCK_OATH_TOKEN_RESPONSE.access_token,
expiresIn: Date.now() + 3600,
obtainedAt: 0,
},
profile: {
identifierId: MOCK_LOGIN_RESPONSE.profile.identifier_id,
profileId: MOCK_LOGIN_RESPONSE.profile.profile_id,
metaMetricsId: MOCK_LOGIN_RESPONSE.profile.metametrics_id,
},
};
});

return {
isSignedIn: true,
srpSessionData,
};
};

describe('authentication/authentication-controller - constructor() tests', () => {
it('should initialize with default state', () => {
Expand All @@ -38,7 +52,7 @@ describe('authentication/authentication-controller - constructor() tests', () =>
});

expect(controller.state.isSignedIn).toBe(false);
expect(controller.state.sessionData).toBeUndefined();
expect(controller.state.srpSessionData).toBeUndefined();
});

it('should initialize with override state', () => {
Expand All @@ -50,7 +64,7 @@ describe('authentication/authentication-controller - constructor() tests', () =>
});

expect(controller.state.isSignedIn).toBe(true);
expect(controller.state.sessionData).toBeDefined();
expect(controller.state.srpSessionData).toBeDefined();
});

it('should throw an error if metametrics is not provided', () => {
Expand All @@ -64,25 +78,35 @@ describe('authentication/authentication-controller - constructor() tests', () =>
});

describe('authentication/authentication-controller - performSignIn() tests', () => {
it('should create access token and update state', async () => {
it('should create access token(s) and update state', async () => {
const metametrics = createMockAuthMetaMetrics();
const mockEndpoints = arrangeAuthAPIs();
const { messenger, mockSnapGetPublicKey, mockSnapSignMessage } =
createMockAuthenticationMessenger();
const {
messenger,
mockSnapGetPublicKey,
mockSnapGetAllPublicKeys,
mockSnapSignMessage,
} = createMockAuthenticationMessenger();

const controller = new AuthenticationController({ messenger, metametrics });

const result = await controller.performSignIn();
expect(mockSnapGetPublicKey).toHaveBeenCalled();
expect(mockSnapSignMessage).toHaveBeenCalled();
expect(mockSnapGetAllPublicKeys).toHaveBeenCalledTimes(1);
expect(mockSnapGetPublicKey).toHaveBeenCalledTimes(2);
expect(mockSnapSignMessage).toHaveBeenCalledTimes(1);
mockEndpoints.mockNonceUrl.done();
mockEndpoints.mockSrpLoginUrl.done();
mockEndpoints.mockOAuth2TokenUrl.done();
expect(result).toBe(MOCK_OATH_TOKEN_RESPONSE.access_token);
expect(result).toStrictEqual([
MOCK_OATH_TOKEN_RESPONSE.access_token,
MOCK_OATH_TOKEN_RESPONSE.access_token,
]);

// Assert - state shows user is logged in
expect(controller.state.isSignedIn).toBe(true);
expect(controller.state.sessionData).toBeDefined();
for (const id of MOCK_ENTROPY_SOURCE_IDS) {
expect(controller.state.srpSessionData?.[id]).toBeDefined();
}
});

it('leverages the _snapSignMessageCache', async () => {
Expand All @@ -101,7 +125,9 @@ describe('authentication/authentication-controller - performSignIn() tests', ()
mockEndpoints.mockSrpLoginUrl.done();
mockEndpoints.mockOAuth2TokenUrl.done();
expect(controller.state.isSignedIn).toBe(true);
expect(controller.state.sessionData).toBeDefined();
for (const id of MOCK_ENTROPY_SOURCE_IDS) {
expect(controller.state.srpSessionData?.[id]).toBeDefined();
}
});

it('should error when nonce endpoint fails', async () => {
Expand Down Expand Up @@ -134,9 +160,10 @@ describe('authentication/authentication-controller - performSignIn() tests', ()
await expect(controller.performSignIn()).rejects.toThrow(expect.any(Error));

baseMessenger.publish('KeyringController:unlock');
expect(await controller.performSignIn()).toBe(
expect(await controller.performSignIn()).toStrictEqual([
MOCK_OATH_TOKEN_RESPONSE.access_token,
);
MOCK_OATH_TOKEN_RESPONSE.access_token,
]);
});

/**
Expand Down Expand Up @@ -188,7 +215,7 @@ describe('authentication/authentication-controller - performSignOut() tests', ()

controller.performSignOut();
expect(controller.state.isSignedIn).toBe(false);
expect(controller.state.sessionData).toBeUndefined();
expect(controller.state.srpSessionData).toBeUndefined();
});
});

Expand All @@ -207,7 +234,7 @@ describe('authentication/authentication-controller - getBearerToken() tests', ()
);
});

it('should return original access token in state', async () => {
it('should return original access token(s) in state', async () => {
const metametrics = createMockAuthMetaMetrics();
const { messenger } = createMockAuthenticationMessenger();
const originalState = mockSignedInState();
Expand All @@ -217,9 +244,20 @@ describe('authentication/authentication-controller - getBearerToken() tests', ()
metametrics,
});

const result = await controller.getBearerToken();
expect(result).toBeDefined();
expect(result).toBe(originalState.sessionData?.token.accessToken);
const resultWithoutEntropySourceId = await controller.getBearerToken();
expect(resultWithoutEntropySourceId).toBeDefined();
expect(resultWithoutEntropySourceId).toBe(
originalState.srpSessionData?.[MOCK_ENTROPY_SOURCE_IDS[0]]?.token
.accessToken,
);

for (const id of MOCK_ENTROPY_SOURCE_IDS) {
const resultWithEntropySourceId = await controller.getBearerToken(id);
expect(resultWithEntropySourceId).toBeDefined();
expect(resultWithEntropySourceId).toBe(
originalState.srpSessionData?.[id]?.token.accessToken,
);
}
});

it('should return new access token if state is invalid', async () => {
Expand All @@ -228,13 +266,15 @@ describe('authentication/authentication-controller - getBearerToken() tests', ()
mockAuthenticationFlowEndpoints();
const originalState = mockSignedInState();
// eslint-disable-next-line jest/no-conditional-in-test
if (originalState.sessionData) {
originalState.sessionData.token.accessToken =
MOCK_OATH_TOKEN_RESPONSE.access_token;
if (originalState.srpSessionData) {
originalState.srpSessionData[
MOCK_ENTROPY_SOURCE_IDS[0]
].token.accessToken = MOCK_OATH_TOKEN_RESPONSE.access_token;

const d = new Date();
d.setMinutes(d.getMinutes() - 31); // expires at 30 mins
originalState.sessionData.token.expiresIn = d.getTime();
originalState.srpSessionData[MOCK_ENTROPY_SOURCE_IDS[0]].token.expiresIn =
d.getTime();
}

const controller = new AuthenticationController({
Expand All @@ -259,12 +299,15 @@ describe('authentication/authentication-controller - getBearerToken() tests', ()
// Invalid/old state
const originalState = mockSignedInState();
// eslint-disable-next-line jest/no-conditional-in-test
if (originalState.sessionData) {
originalState.sessionData.token.accessToken = 'ACCESS_TOKEN_1';
if (originalState.srpSessionData) {
originalState.srpSessionData[
MOCK_ENTROPY_SOURCE_IDS[0]
].token.accessToken = 'ACCESS_TOKEN_1';

const d = new Date();
d.setMinutes(d.getMinutes() - 31); // expires at 30 mins
originalState.sessionData.token.expiresIn = d.getTime();
originalState.srpSessionData[MOCK_ENTROPY_SOURCE_IDS[0]].token.expiresIn =
d.getTime();
}

// Mock wallet is locked
Expand Down Expand Up @@ -297,7 +340,7 @@ describe('authentication/authentication-controller - getSessionProfile() tests',
);
});

it('should return original access token in state', async () => {
it('should return original user profile(s) in state', async () => {
const metametrics = createMockAuthMetaMetrics();
const { messenger } = createMockAuthenticationMessenger();
const originalState = mockSignedInState();
Expand All @@ -307,24 +350,36 @@ describe('authentication/authentication-controller - getSessionProfile() tests',
metametrics,
});

const result = await controller.getSessionProfile();
expect(result).toBeDefined();
expect(result).toStrictEqual(originalState.sessionData?.profile);
const resultWithoutEntropySourceId = await controller.getSessionProfile();
expect(resultWithoutEntropySourceId).toBeDefined();
expect(resultWithoutEntropySourceId).toStrictEqual(
originalState.srpSessionData?.[MOCK_ENTROPY_SOURCE_IDS[0]]?.profile,
);

for (const id of MOCK_ENTROPY_SOURCE_IDS) {
const resultWithEntropySourceId = await controller.getSessionProfile(id);
expect(resultWithEntropySourceId).toBeDefined();
expect(resultWithEntropySourceId).toStrictEqual(
originalState.srpSessionData?.[id]?.profile,
);
}
});

it('should return new access token if state is invalid', async () => {
it('should return new user profile if state is invalid', async () => {
const metametrics = createMockAuthMetaMetrics();
const { messenger } = createMockAuthenticationMessenger();
mockAuthenticationFlowEndpoints();
const originalState = mockSignedInState();
// eslint-disable-next-line jest/no-conditional-in-test
if (originalState.sessionData) {
originalState.sessionData.profile.identifierId =
MOCK_LOGIN_RESPONSE.profile.identifier_id;
if (originalState.srpSessionData) {
originalState.srpSessionData[
MOCK_ENTROPY_SOURCE_IDS[0]
].profile.identifierId = MOCK_LOGIN_RESPONSE.profile.identifier_id;

const d = new Date();
d.setMinutes(d.getMinutes() - 31); // expires at 30 mins
originalState.sessionData.token.expiresIn = d.getTime();
originalState.srpSessionData[MOCK_ENTROPY_SOURCE_IDS[0]].token.expiresIn =
d.getTime();
}

const controller = new AuthenticationController({
Expand All @@ -350,13 +405,15 @@ describe('authentication/authentication-controller - getSessionProfile() tests',
// Invalid/old state
const originalState = mockSignedInState();
// eslint-disable-next-line jest/no-conditional-in-test
if (originalState.sessionData) {
originalState.sessionData.profile.identifierId =
MOCK_LOGIN_RESPONSE.profile.identifier_id;
if (originalState.srpSessionData) {
originalState.srpSessionData[
MOCK_ENTROPY_SOURCE_IDS[0]
].profile.identifierId = MOCK_LOGIN_RESPONSE.profile.identifier_id;

const d = new Date();
d.setMinutes(d.getMinutes() - 31); // expires at 30 mins
originalState.sessionData.token.expiresIn = d.getTime();
originalState.srpSessionData[MOCK_ENTROPY_SOURCE_IDS[0]].token.expiresIn =
d.getTime();
}

// Mock wallet is locked
Expand Down Expand Up @@ -426,8 +483,14 @@ function createAuthenticationMessenger() {
*/
function createMockAuthenticationMessenger() {
const { baseMessenger, messenger } = createAuthenticationMessenger();

const mockCall = jest.spyOn(messenger, 'call');
const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY');
const mockSnapGetAllPublicKeys = jest
.fn()
.mockResolvedValue(
MOCK_ENTROPY_SOURCE_IDS.map((id) => [id, 'MOCK_PUBLIC_KEY']),
);
const mockSnapSignMessage = jest
.fn()
.mockResolvedValue('MOCK_SIGNED_MESSAGE');
Expand All @@ -443,6 +506,10 @@ function createMockAuthenticationMessenger() {
return mockSnapGetPublicKey();
}

if (params?.request.method === 'getAllPublicKeys') {
return mockSnapGetAllPublicKeys();
}

if (params?.request.method === 'signMessage') {
return mockSnapSignMessage();
}
Expand All @@ -467,6 +534,7 @@ function createMockAuthenticationMessenger() {
messenger,
baseMessenger,
mockSnapGetPublicKey,
mockSnapGetAllPublicKeys,
mockSnapSignMessage,
mockKeyringControllerGetState,
};
Expand Down
Loading
Loading