From 50d647d59e72438f5d629bd7c11c5bc4f4bf021d Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 20 Nov 2025 11:26:14 +0100 Subject: [PATCH 01/10] use url based on selected environment --- .../src/UserProfileService.test.ts | 74 +++++++++---------- .../src/UserProfileService.ts | 12 ++- .../user-profile-controller/src/constants.ts | 25 +++++++ packages/user-profile-controller/src/index.ts | 2 + 4 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 packages/user-profile-controller/src/constants.ts diff --git a/packages/user-profile-controller/src/UserProfileService.test.ts b/packages/user-profile-controller/src/UserProfileService.test.ts index 152ce85a3c8..874a98df96b 100644 --- a/packages/user-profile-controller/src/UserProfileService.test.ts +++ b/packages/user-profile-controller/src/UserProfileService.test.ts @@ -8,10 +8,17 @@ import { import nock from 'nock'; import { type SinonFakeTimers, useFakeTimers } from 'sinon'; -import { UserProfileService, type UserProfileServiceMessenger } from '.'; -import type { UserProfileUpdateRequest } from './UserProfileService'; +import { + UserProfileService, + type UserProfileUpdateRequest, + type UserProfileServiceMessenger, + Env, + getEnvUrl, +} from '.'; import { HttpError } from '../../controller-utils/src/util'; +const defaultBaseEndpoint = getEnvUrl(Env.DEV); + /** * Creates a mock request object for testing purposes. * @@ -46,8 +53,8 @@ describe('UserProfileService', () => { describe('UserProfileService:updateProfile', () => { it('resolves when there is a successful response from the API', async () => { - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .reply(200, { data: { success: true, @@ -64,8 +71,8 @@ describe('UserProfileService', () => { }); it('throws if there is an unsuccessful response from the API', async () => { - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .reply(200, { data: { success: false, @@ -96,8 +103,8 @@ describe('UserProfileService', () => { ])( 'throws if the API returns a malformed response %o', async (response) => { - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .reply(200, JSON.stringify(response)); const { rootMessenger } = getService(); @@ -111,8 +118,8 @@ describe('UserProfileService', () => { ); it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .reply(200, () => { clock.tick(6000); return { @@ -134,8 +141,8 @@ describe('UserProfileService', () => { }); it('allows the degradedThreshold to be changed', async () => { - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .reply(200, () => { clock.tick(1000); return { @@ -161,10 +168,7 @@ describe('UserProfileService', () => { }); it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { - nock('https://api.example.com') - .put('/update-profile') - .times(4) - .reply(500); + nock(defaultBaseEndpoint).put('/profile/accounts').times(4).reply(500); const { service, rootMessenger } = getService(); service.onRetry(clock.next); @@ -174,15 +178,12 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); }); it('calls onDegraded listeners when the maximum number of retries is exceeded', async () => { - nock('https://api.example.com') - .put('/update-profile') - .times(4) - .reply(500); + nock(defaultBaseEndpoint).put('/profile/accounts').times(4).reply(500); const { service, rootMessenger } = getService(); service.onRetry(clock.next); const onDegradedListener = jest.fn(); @@ -194,16 +195,13 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); expect(onDegradedListener).toHaveBeenCalled(); }); it('intercepts requests and throws a circuit break error after the 4th failed attempt, running onBreak listeners', async () => { - nock('https://api.example.com') - .put('/update-profile') - .times(12) - .reply(500); + nock(defaultBaseEndpoint).put('/profile/accounts').times(12).reply(500); const { service, rootMessenger } = getService(); service.onRetry(clock.next); const onBreakListener = jest.fn(); @@ -216,7 +214,7 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); // Should make 4 requests await expect( @@ -225,7 +223,7 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); // Should make 4 requests await expect( @@ -234,7 +232,7 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); // Should not make an additional request (we only mocked 12 requests // above) @@ -249,18 +247,18 @@ describe('UserProfileService', () => { expect(onBreakListener).toHaveBeenCalledWith({ error: new HttpError( 500, - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ), }); }); it('resumes requests after the circuit break duration passes, returning the API response if the request ultimately succeeds', async () => { const circuitBreakDuration = 5_000; - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .times(12) .reply(500) - .put('/update-profile') + .put('/profile/accounts') .reply(200, { data: { success: true, @@ -279,7 +277,7 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); await expect( rootMessenger.call( @@ -287,7 +285,7 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); await expect( rootMessenger.call( @@ -295,7 +293,7 @@ describe('UserProfileService', () => { createMockRequest(), ), ).rejects.toThrow( - "Fetching 'https://api.example.com/update-profile' failed with status '500'", + `Fetching '${defaultBaseEndpoint}/profile/accounts' failed with status '500'`, ); await expect( rootMessenger.call( @@ -314,8 +312,8 @@ describe('UserProfileService', () => { describe('fetchGasPrices', () => { it('does the same thing as the messenger action', async () => { - nock('https://api.example.com') - .put('/update-profile') + nock(defaultBaseEndpoint) + .put('/profile/accounts') .reply(200, { data: { success: true, diff --git a/packages/user-profile-controller/src/UserProfileService.ts b/packages/user-profile-controller/src/UserProfileService.ts index 2a18fb8bd0e..13cc1b07d77 100644 --- a/packages/user-profile-controller/src/UserProfileService.ts +++ b/packages/user-profile-controller/src/UserProfileService.ts @@ -7,6 +7,7 @@ import type { Messenger } from '@metamask/messenger'; import { hasProperty, isPlainObject } from '@metamask/utils'; import type { UserProfileServiceMethodActions } from '.'; +import { Env, getEnvUrl } from './constants'; // === GENERAL === @@ -89,6 +90,11 @@ export class UserProfileService { */ readonly #policy: ServicePolicy; + /** + * The environment to determine the correct API endpoints. + */ + readonly #env: Env; + /** * Constructs a new UserProfileService object. * @@ -100,20 +106,24 @@ export class UserProfileService { * `node-fetch`). * @param args.policyOptions - Options to pass to `createServicePolicy`, which * is used to wrap each request. See {@link CreateServicePolicyOptions}. + * @param args.env - The environment to determine the correct API endpoints. */ constructor({ messenger, fetch: fetchFunction, policyOptions = {}, + env = Env.DEV, }: { messenger: UserProfileServiceMessenger; fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; + env?: Env; }) { this.name = serviceName; this.#messenger = messenger; this.#fetch = fetchFunction; this.#policy = createServicePolicy(policyOptions); + this.#env = env; this.#messenger.registerMethodActionHandlers( this, @@ -179,7 +189,7 @@ export class UserProfileService { */ async updateProfile(data: UserProfileUpdateRequest): Promise { const response = await this.#policy.execute(async () => { - const url = new URL('https://api.example.com/update-profile'); + const url = new URL(`${getEnvUrl(this.#env)}/profile/accounts`); const localResponse = await this.#fetch(url, { method: 'PUT', headers: { diff --git a/packages/user-profile-controller/src/constants.ts b/packages/user-profile-controller/src/constants.ts new file mode 100644 index 00000000000..ef3bcb8d538 --- /dev/null +++ b/packages/user-profile-controller/src/constants.ts @@ -0,0 +1,25 @@ +export enum Env { + DEV = 'dev', + UAT = 'uat', + PRD = 'prd', +} + +const ENV_URLS: Record = { + dev: 'https://authentication.dev-api.cx.metamask.io/api/v2', + uat: 'https://authentication.uat-api.cx.metamask.io/api/v2', + prd: 'https://authentication.api.cx.metamask.io/api/v2', +}; + +/** + * Validates and returns correct environment endpoints + * + * @param env - environment field + * @returns the correct environment url + * @throws on invalid environment passed + */ +export function getEnvUrl(env: Env): string { + if (!ENV_URLS[env]) { + throw new Error('invalid environment configuration'); + } + return ENV_URLS[env]; +} diff --git a/packages/user-profile-controller/src/index.ts b/packages/user-profile-controller/src/index.ts index 8b4019cce34..e60b6eb5407 100644 --- a/packages/user-profile-controller/src/index.ts +++ b/packages/user-profile-controller/src/index.ts @@ -14,6 +14,8 @@ export type { UserProfileServiceActions, UserProfileServiceEvents, UserProfileServiceMessenger, + UserProfileUpdateRequest, } from './UserProfileService'; export { UserProfileService, serviceName } from './UserProfileService'; export type { UserProfileServiceMethodActions } from './UserProfileService-method-action-types'; +export { getEnvUrl, Env } from './constants'; From 86e05de185f01c5420fdfea3574eb3733f8bc4ce Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 20 Nov 2025 13:13:16 +0100 Subject: [PATCH 02/10] add bearer token to request --- packages/user-profile-controller/package.json | 4 +- .../src/UserProfileService.test.ts | 66 +++++++++++++++---- .../src/UserProfileService.ts | 22 +++++-- .../tsconfig.build.json | 3 +- .../user-profile-controller/tsconfig.json | 3 +- yarn.lock | 2 + 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/user-profile-controller/package.json b/packages/user-profile-controller/package.json index 4e4cdb9f113..1a0df143236 100644 --- a/packages/user-profile-controller/package.json +++ b/packages/user-profile-controller/package.json @@ -60,6 +60,7 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^24.0.0", "@metamask/keyring-internal-api": "^9.0.0", + "@metamask/profile-sync-controller": "^26.0.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", @@ -73,7 +74,8 @@ }, "peerDependencies": { "@metamask/accounts-controller": "^34.0.0", - "@metamask/keyring-controller": "^24.0.0" + "@metamask/keyring-controller": "^24.0.0", + "@metamask/profile-sync-controller": "^26.0.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/user-profile-controller/src/UserProfileService.test.ts b/packages/user-profile-controller/src/UserProfileService.test.ts index fef7dbd3b52..99fe2d33926 100644 --- a/packages/user-profile-controller/src/UserProfileService.test.ts +++ b/packages/user-profile-controller/src/UserProfileService.test.ts @@ -22,13 +22,17 @@ const defaultBaseEndpoint = getEnvUrl(Env.DEV); /** * Creates a mock request object for testing purposes. * + * @param override - Optional properties to override in the mock request. * @returns A mock request object. */ -function createMockRequest(): UserProfileUpdateRequest { +function createMockRequest( + override?: Partial, +): UserProfileUpdateRequest { return { metametricsId: 'mock-meta-metrics-id', entropySourceId: 'mock-entropy-source-id', accounts: ['0xMockAccountAddress1'], + ...override, }; } @@ -43,8 +47,22 @@ describe('UserProfileService', () => { clock.restore(); }); + describe('constructor', () => { + it('throws when an invalid env is selected', () => { + expect( + () => + new UserProfileService({ + fetch, + messenger: getMessenger(getRootMessenger()), + // @ts-expect-error Testing invalid env + env: 'invalid-env', + }), + ).toThrow('invalid environment configuration'); + }); + }); + describe('UserProfileService:updateProfile', () => { - it('resolves when there is a successful response from the API', async () => { + it('resolves when there is a successful response from the API and the accounts have an entropy source id', async () => { nock(defaultBaseEndpoint) .put('/profile/accounts') .reply(200, { @@ -62,6 +80,26 @@ describe('UserProfileService', () => { expect(updateProfileResponse).toBeUndefined(); }); + it('resolves when there is a successful response from the API and the accounts do not have an entropy source id', async () => { + nock(defaultBaseEndpoint) + .put('/profile/accounts') + .reply(200, { + data: { + success: true, + }, + }); + const { rootMessenger } = getService(); + + const request = createMockRequest({ entropySourceId: null }); + + const updateProfileResponse = await rootMessenger.call( + 'UserProfileService:updateProfile', + request, + ); + + expect(updateProfileResponse).toBeUndefined(); + }); + it('throws if there is an unsuccessful response from the API', async () => { nock(defaultBaseEndpoint) .put('/profile/accounts') @@ -82,17 +120,7 @@ describe('UserProfileService', () => { ); }); - it.each([ - 'not an object', - { missing: 'data' }, - { data: 'not an object' }, - { data: { missing: 'low', average: 2, high: 3 } }, - { data: { low: 1, missing: 'average', high: 3 } }, - { data: { low: 1, average: 2, missing: 'high' } }, - { data: { low: 'not a number', average: 2, high: 3 } }, - { data: { low: 1, average: 'not a number', high: 3 } }, - { data: { low: 1, average: 2, high: 'not a number' } }, - ])( + it.each(['not an object', { missing: 'data' }, { data: 'not an object' }])( 'throws if the API returns a malformed response %o', async (response) => { nock(defaultBaseEndpoint) @@ -351,10 +379,15 @@ function getRootMessenger(): RootMessenger { function getMessenger( rootMessenger: RootMessenger, ): UserProfileServiceMessenger { - return new Messenger({ + const serviceMessenger: UserProfileServiceMessenger = new Messenger({ namespace: 'UserProfileService', parent: rootMessenger, }); + rootMessenger.delegate({ + messenger: serviceMessenger, + actions: ['AuthenticationController:getBearerToken'], + }); + return serviceMessenger; } /** @@ -376,6 +409,11 @@ function getService({ messenger: UserProfileServiceMessenger; } { const rootMessenger = getRootMessenger(); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'mock-bearer-token', + ); + const messenger = getMessenger(rootMessenger); const service = new UserProfileService({ fetch, diff --git a/packages/user-profile-controller/src/UserProfileService.ts b/packages/user-profile-controller/src/UserProfileService.ts index 6cfa442bac4..3ef95bf7a60 100644 --- a/packages/user-profile-controller/src/UserProfileService.ts +++ b/packages/user-profile-controller/src/UserProfileService.ts @@ -4,6 +4,7 @@ import type { } from '@metamask/controller-utils'; import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { hasProperty, isPlainObject } from '@metamask/utils'; import { type UserProfileServiceMethodActions, Env, getEnvUrl } from '.'; @@ -37,7 +38,8 @@ export type UserProfileServiceActions = UserProfileServiceMethodActions; /** * Actions from other messengers that {@link UserProfileService} calls. */ -type AllowedActions = never; +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerToken; /** * Events that {@link UserProfileService} exposes to other consumers. @@ -88,9 +90,9 @@ export class UserProfileService { readonly #policy: ServicePolicy; /** - * The environment to determine the correct API endpoints. + * The API base URL environment. */ - readonly #env: Env; + readonly #baseURL: string; /** * Constructs a new UserProfileService object. @@ -120,7 +122,7 @@ export class UserProfileService { this.#messenger = messenger; this.#fetch = fetchFunction; this.#policy = createServicePolicy(policyOptions); - this.#env = env; + this.#baseURL = getEnvUrl(env); this.#messenger.registerMethodActionHandlers( this, @@ -185,14 +187,22 @@ export class UserProfileService { * @returns The response from the API. */ async updateProfile(data: UserProfileUpdateRequest): Promise { + const authToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + data.entropySourceId || undefined, + ); const response = await this.#policy.execute(async () => { - const url = new URL(`${getEnvUrl(this.#env)}/profile/accounts`); + const url = new URL(`${this.#baseURL}/profile/accounts`); const localResponse = await this.#fetch(url, { method: 'PUT', headers: { + Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(data), + body: JSON.stringify({ + metametrics_id: data.metametricsId, + accounts: data.accounts, + }), }); if (!localResponse.ok) { throw new HttpError( diff --git a/packages/user-profile-controller/tsconfig.build.json b/packages/user-profile-controller/tsconfig.build.json index 7663dc29c99..55d64166e07 100644 --- a/packages/user-profile-controller/tsconfig.build.json +++ b/packages/user-profile-controller/tsconfig.build.json @@ -10,7 +10,8 @@ { "path": "../../packages/base-controller/tsconfig.build.json" }, { "path": "../../packages/keyring-controller/tsconfig.build.json" }, { "path": "../../packages/messenger/tsconfig.build.json" }, - { "path": "../../packages/polling-controller/tsconfig.build.json" } + { "path": "../../packages/polling-controller/tsconfig.build.json" }, + { "path": "../../packages/profile-sync-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/user-profile-controller/tsconfig.json b/packages/user-profile-controller/tsconfig.json index 6d57e931a1b..7dc73fd05ac 100644 --- a/packages/user-profile-controller/tsconfig.json +++ b/packages/user-profile-controller/tsconfig.json @@ -8,7 +8,8 @@ { "path": "../../packages/base-controller" }, { "path": "../../packages/keyring-controller" }, { "path": "../../packages/messenger" }, - { "path": "../../packages/polling-controller" } + { "path": "../../packages/polling-controller" }, + { "path": "../../packages/profile-sync-controller" } ], "include": ["../../types", "./src"], /** diff --git a/yarn.lock b/yarn.lock index 8dba84fd166..6bd2f36e307 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5206,6 +5206,7 @@ __metadata: "@metamask/keyring-internal-api": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^15.0.0" + "@metamask/profile-sync-controller": "npm:^26.0.0" "@metamask/utils": "npm:^11.8.1" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" @@ -5221,6 +5222,7 @@ __metadata: peerDependencies: "@metamask/accounts-controller": ^34.0.0 "@metamask/keyring-controller": ^24.0.0 + "@metamask/profile-sync-controller": ^26.0.0 languageName: unknown linkType: soft From 94699333688f2a17530bdf088cb497a8eec45cbf Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 20 Nov 2025 13:38:41 +0100 Subject: [PATCH 03/10] update changelog --- packages/user-profile-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/user-profile-controller/CHANGELOG.md b/packages/user-profile-controller/CHANGELOG.md index b59de0a3ab6..c5f70a70960 100644 --- a/packages/user-profile-controller/CHANGELOG.md +++ b/packages/user-profile-controller/CHANGELOG.md @@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release ([#7194](https://github.com/MetaMask/core/pull/7194), [#7196](https://github.com/MetaMask/core/pull/7196)) +- Initial release ([#7194](https://github.com/MetaMask/core/pull/7194), [#7196](https://github.com/MetaMask/core/pull/7196), [#7204](https://github.com/MetaMask/core/pull/7204)) [Unreleased]: https://github.com/MetaMask/core/ From cf144dbc52bc49b33c6a822313d41a678dde8619 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 20 Nov 2025 13:57:25 +0100 Subject: [PATCH 04/10] remove response parsing --- .../src/UserProfileService.test.ts | 37 ------------------- .../src/UserProfileService.ts | 20 +--------- 2 files changed, 1 insertion(+), 56 deletions(-) diff --git a/packages/user-profile-controller/src/UserProfileService.test.ts b/packages/user-profile-controller/src/UserProfileService.test.ts index 99fe2d33926..2adf4f36d0f 100644 --- a/packages/user-profile-controller/src/UserProfileService.test.ts +++ b/packages/user-profile-controller/src/UserProfileService.test.ts @@ -100,43 +100,6 @@ describe('UserProfileService', () => { expect(updateProfileResponse).toBeUndefined(); }); - it('throws if there is an unsuccessful response from the API', async () => { - nock(defaultBaseEndpoint) - .put('/profile/accounts') - .reply(200, { - data: { - success: false, - }, - }); - const { rootMessenger } = getService(); - - await expect( - rootMessenger.call( - 'UserProfileService:updateProfile', - createMockRequest(), - ), - ).rejects.toThrow( - 'API indicated that the profile update was unsuccessfu', - ); - }); - - it.each(['not an object', { missing: 'data' }, { data: 'not an object' }])( - 'throws if the API returns a malformed response %o', - async (response) => { - nock(defaultBaseEndpoint) - .put('/profile/accounts') - .reply(200, JSON.stringify(response)); - const { rootMessenger } = getService(); - - await expect( - rootMessenger.call( - 'UserProfileService:updateProfile', - createMockRequest(), - ), - ).rejects.toThrow('Malformed response received from gas prices API'); - }, - ); - it('calls onDegraded listeners if the request takes longer than 5 seconds to resolve', async () => { nock(defaultBaseEndpoint) .put('/profile/accounts') diff --git a/packages/user-profile-controller/src/UserProfileService.ts b/packages/user-profile-controller/src/UserProfileService.ts index 3ef95bf7a60..8b835c990fc 100644 --- a/packages/user-profile-controller/src/UserProfileService.ts +++ b/packages/user-profile-controller/src/UserProfileService.ts @@ -191,7 +191,7 @@ export class UserProfileService { 'AuthenticationController:getBearerToken', data.entropySourceId || undefined, ); - const response = await this.#policy.execute(async () => { + await this.#policy.execute(async () => { const url = new URL(`${this.#baseURL}/profile/accounts`); const localResponse = await this.#fetch(url, { method: 'PUT', @@ -212,23 +212,5 @@ export class UserProfileService { } return localResponse; }); - const jsonResponse = await response.json(); - - if ( - isPlainObject(jsonResponse) && - hasProperty(jsonResponse, 'data') && - isPlainObject(jsonResponse.data) && - hasProperty(jsonResponse.data, 'success') && - typeof jsonResponse.data.success === 'boolean' - ) { - if (!jsonResponse.data.success) { - throw new Error( - 'API indicated that the profile update was unsuccessful', - ); - } - return; - } - - throw new Error('Malformed response received from gas prices API'); } } From 4cd9da8d2773a0ac91c27580e16fc668d9fcb56e Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Thu, 20 Nov 2025 14:49:02 +0100 Subject: [PATCH 05/10] remove unused imports --- packages/user-profile-controller/src/UserProfileService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/user-profile-controller/src/UserProfileService.ts b/packages/user-profile-controller/src/UserProfileService.ts index 8b835c990fc..55e79b82211 100644 --- a/packages/user-profile-controller/src/UserProfileService.ts +++ b/packages/user-profile-controller/src/UserProfileService.ts @@ -5,7 +5,6 @@ import type { import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; -import { hasProperty, isPlainObject } from '@metamask/utils'; import { type UserProfileServiceMethodActions, Env, getEnvUrl } from '.'; From 120dfc46aac5e32ba1226c911728ed6b847e2635 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 24 Nov 2025 11:46:38 +0100 Subject: [PATCH 06/10] fix request body format --- packages/user-profile-controller/src/UserProfileService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/user-profile-controller/src/UserProfileService.ts b/packages/user-profile-controller/src/UserProfileService.ts index 55e79b82211..3416913a3d7 100644 --- a/packages/user-profile-controller/src/UserProfileService.ts +++ b/packages/user-profile-controller/src/UserProfileService.ts @@ -200,7 +200,7 @@ export class UserProfileService { }, body: JSON.stringify({ metametrics_id: data.metametricsId, - accounts: data.accounts, + accounts: data.accounts.map((account) => ({ address: account })), }), }); if (!localResponse.ok) { From 9d785579f08086351f725433e626a90c414b0f87 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 24 Nov 2025 15:34:48 +0100 Subject: [PATCH 07/10] use caip-10 addresses --- .../src/UserProfileController.test.ts | 37 ++++++++------ .../src/UserProfileController.ts | 51 ++++++++++++------- .../src/UserProfileService.test.ts | 2 +- .../src/UserProfileService.ts | 7 ++- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/user-profile-controller/src/UserProfileController.test.ts b/packages/user-profile-controller/src/UserProfileController.test.ts index 1fa8181128a..61a5ed36d57 100644 --- a/packages/user-profile-controller/src/UserProfileController.test.ts +++ b/packages/user-profile-controller/src/UserProfileController.test.ts @@ -7,6 +7,7 @@ import { type MessengerActions, type MessengerEvents, } from '@metamask/messenger'; +import type { CaipAccountId } from '@metamask/utils'; import { UserProfileController, @@ -39,7 +40,7 @@ function createMockAccount( } : {}, methods: [], - scopes: [], + scopes: ['eip155:1'], type: 'any:account', metadata: { keyring: { @@ -90,8 +91,8 @@ describe('UserProfileController', () => { expect(controller.state.firstSyncCompleted).toBe(true); expect(controller.state.syncQueue).toStrictEqual({ - 'entropy-0xAccount1': ['0xAccount1'], - null: ['0xAccount2'], + 'entropy-0xAccount1': [{ address: 'eip155:_:0xAccount1' }], + null: [{ address: 'eip155:_:0xAccount2' }], }); }, ); @@ -184,7 +185,7 @@ describe('UserProfileController', () => { await Promise.resolve(); expect(controller.state.syncQueue).toStrictEqual({ - 'entropy-0xNewAccount': ['0xNewAccount'], + 'entropy-0xNewAccount': [{ address: 'eip155:_:0xNewAccount' }], }); }, ); @@ -204,7 +205,7 @@ describe('UserProfileController', () => { await Promise.resolve(); expect(controller.state.syncQueue).toStrictEqual({ - null: ['0xNewAccount'], + null: [{ address: 'eip155:_:0xNewAccount' }], }); }, ); @@ -230,8 +231,8 @@ describe('UserProfileController', () => { describe('_executePoll', () => { it('processes the sync queue on each poll', async () => { - const accounts = { - id1: ['0xAccount1'], + const accounts: Record = { + id1: [{ address: 'eip155:_:0xAccount1' }], }; await withController( { @@ -244,7 +245,7 @@ describe('UserProfileController', () => { expect(mockUpdateProfile).toHaveBeenCalledWith({ metametricsId: getMetaMetricsId(), entropySourceId: 'id1', - accounts: ['0xAccount1'], + accounts: [{ address: 'eip155:_:0xAccount1' }], }); expect(controller.state.syncQueue).toStrictEqual({}); }, @@ -252,10 +253,13 @@ describe('UserProfileController', () => { }); it('processes the sync queue in batches grouped by entropySourceId', async () => { - const accounts = { - id1: ['0xAccount1', '0xAccount2'], - id2: ['0xAccount3'], - null: ['0xAccount4'], + const accounts: Record = { + id1: [ + { address: 'eip155:_:0xAccount1' }, + { address: 'eip155:_:0xAccount2' }, + ], + id2: [{ address: 'eip155:_:0xAccount3' }], + null: [{ address: 'eip155:_:0xAccount4' }], }; await withController( { @@ -268,17 +272,20 @@ describe('UserProfileController', () => { expect(mockUpdateProfile).toHaveBeenNthCalledWith(1, { metametricsId: getMetaMetricsId(), entropySourceId: 'id1', - accounts: ['0xAccount1', '0xAccount2'], + accounts: [ + { address: 'eip155:_:0xAccount1' }, + { address: 'eip155:_:0xAccount2' }, + ], }); expect(mockUpdateProfile).toHaveBeenNthCalledWith(2, { metametricsId: getMetaMetricsId(), entropySourceId: 'id2', - accounts: ['0xAccount3'], + accounts: [{ address: 'eip155:_:0xAccount3' }], }); expect(mockUpdateProfile).toHaveBeenNthCalledWith(3, { metametricsId: getMetaMetricsId(), entropySourceId: null, - accounts: ['0xAccount4'], + accounts: [{ address: 'eip155:_:0xAccount4' }], }); expect(controller.state.syncQueue).toStrictEqual({}); }, diff --git a/packages/user-profile-controller/src/UserProfileController.ts b/packages/user-profile-controller/src/UserProfileController.ts index 54073b20ee6..83dc24c1dca 100644 --- a/packages/user-profile-controller/src/UserProfileController.ts +++ b/packages/user-profile-controller/src/UserProfileController.ts @@ -14,6 +14,7 @@ import type { import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { isCaipNamespace, type CaipAccountId } from '@metamask/utils'; import { Mutex } from 'async-mutex'; import type { UserProfileServiceMethodActions } from '.'; @@ -39,7 +40,7 @@ export type UserProfileControllerState = { * addresses associated with that entropy source. Accounts with no entropy * source ID are grouped under the key "null". */ - syncQueue: Record; + syncQueue: Record; }; /** @@ -253,12 +254,7 @@ export class UserProfileController extends StaticIntervalPollingController()< return; } const newGroupedAccounts = groupAccountsByEntropySourceId( - this.messenger - .call('AccountsController:listAccounts') - .map((account) => ({ - entropySourceId: getAccountEntropySourceId(account), - address: account.address, - })), + this.messenger.call('AccountsController:listAccounts'), ); const queuedAddresses = { ...this.state.syncQueue }; for (const key of Object.keys(newGroupedAccounts)) { @@ -289,7 +285,9 @@ export class UserProfileController extends StaticIntervalPollingController()< if (!state.syncQueue[entropySourceId]) { state.syncQueue[entropySourceId] = []; } - state.syncQueue[entropySourceId].push(account.address); + state.syncQueue[entropySourceId].push({ + address: accountToCaipAccountId(account), + }); }); }); } @@ -316,14 +314,31 @@ function getAccountEntropySourceId(account: InternalAccount): string | null { * an array of account addresses associated with that entropy source ID. */ function groupAccountsByEntropySourceId( - accounts: { address: string; entropySourceId?: string | null }[], -): Record { - return accounts.reduce((result: Record, account) => { - const key = account.entropySourceId ?? 'null'; - if (!result[key]) { - result[key] = []; - } - result[key].push(account.address); - return result; - }, {}); + accounts: InternalAccount[], +): Record { + return accounts.reduce( + (result: Record, account) => { + const entropySourceId = getAccountEntropySourceId(account); + const key = entropySourceId || 'null'; + if (!result[key]) { + result[key] = []; + } + result[key].push({ address: accountToCaipAccountId(account) }); + return result; + }, + {}, + ); +} + +/** + * Converts an InternalAccount to a CaipAccountId. + * + * @param account - The InternalAccount to convert. + * @returns The corresponding CaipAccountId. + */ +function accountToCaipAccountId(account: InternalAccount): CaipAccountId { + const [scope] = account.scopes; + const [namespace] = scope.split(':'); + isCaipNamespace(namespace); + return `${namespace}:_:${account.address}`; } diff --git a/packages/user-profile-controller/src/UserProfileService.test.ts b/packages/user-profile-controller/src/UserProfileService.test.ts index 2adf4f36d0f..6ffa0937b11 100644 --- a/packages/user-profile-controller/src/UserProfileService.test.ts +++ b/packages/user-profile-controller/src/UserProfileService.test.ts @@ -31,7 +31,7 @@ function createMockRequest( return { metametricsId: 'mock-meta-metrics-id', entropySourceId: 'mock-entropy-source-id', - accounts: ['0xMockAccountAddress1'], + accounts: [{ address: 'eip155:_:0xMockAccountAddress1' }], ...override, }; } diff --git a/packages/user-profile-controller/src/UserProfileService.ts b/packages/user-profile-controller/src/UserProfileService.ts index 3416913a3d7..fab7e7279fc 100644 --- a/packages/user-profile-controller/src/UserProfileService.ts +++ b/packages/user-profile-controller/src/UserProfileService.ts @@ -5,6 +5,7 @@ import type { import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { CaipAccountId } from '@metamask/utils'; import { type UserProfileServiceMethodActions, Env, getEnvUrl } from '.'; @@ -22,7 +23,9 @@ export const serviceName = 'UserProfileService'; export type UserProfileUpdateRequest = { metametricsId: string; entropySourceId?: string | null; - accounts: string[]; + accounts: { + address: CaipAccountId; + }[]; }; // === MESSENGER === @@ -200,7 +203,7 @@ export class UserProfileService { }, body: JSON.stringify({ metametrics_id: data.metametricsId, - accounts: data.accounts.map((account) => ({ address: account })), + accounts: data.accounts, }), }); if (!localResponse.ok) { From 85618703a28cb8a4fb8c67c2b0ed910d859b18eb Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 24 Nov 2025 18:01:09 +0100 Subject: [PATCH 08/10] update tests --- .../src/UserProfileController.test.ts | 2 +- yarn.lock | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/user-profile-controller/src/UserProfileController.test.ts b/packages/user-profile-controller/src/UserProfileController.test.ts index 22b7841331e..e9a6233e62d 100644 --- a/packages/user-profile-controller/src/UserProfileController.test.ts +++ b/packages/user-profile-controller/src/UserProfileController.test.ts @@ -178,7 +178,7 @@ describe('UserProfileController', () => { options: { state: { firstSyncCompleted: true, - syncQueue: { someId: ['0xSomeAccount'] }, + syncQueue: { someId: [{ address: 'eip155:_:0xSomeAccount' }] }, }, }, }, diff --git a/yarn.lock b/yarn.lock index e59a0a91af8..efe4e139459 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4567,6 +4567,30 @@ __metadata: languageName: unknown linkType: soft +"@metamask/profile-sync-controller@npm:^26.0.0": + version: 26.0.0 + resolution: "@metamask/profile-sync-controller@npm:26.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/snaps-sdk": "npm:^9.0.0" + "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/utils": "npm:^11.8.1" + "@noble/ciphers": "npm:^1.3.0" + "@noble/hashes": "npm:^1.8.0" + immer: "npm:^9.0.6" + loglevel: "npm:^1.8.1" + siwe: "npm:^2.3.2" + peerDependencies: + "@metamask/address-book-controller": ^7.0.0 + "@metamask/keyring-controller": ^24.0.0 + "@metamask/providers": ^22.0.0 + "@metamask/snaps-controllers": ^14.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/5cff666b961041ef5a38ce42ed6f77173c4d41d9c57574243c0d53169f8cd339920a010e10eafc88c162380ef5aa24269896e0ea01e888ad3b4e3df82bc481ce + languageName: node + linkType: hard + "@metamask/profile-sync-controller@npm:^27.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" From 4a05a524f5316fcc0c7306c5ac201c5919c9b626 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 24 Nov 2025 18:02:43 +0100 Subject: [PATCH 09/10] temporary: use reference from InternalAccount for CAIP-10 --- .../src/UserProfileController.test.ts | 30 +++++++++---------- .../src/UserProfileController.ts | 4 +-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/user-profile-controller/src/UserProfileController.test.ts b/packages/user-profile-controller/src/UserProfileController.test.ts index e9a6233e62d..a4f3d1bd108 100644 --- a/packages/user-profile-controller/src/UserProfileController.test.ts +++ b/packages/user-profile-controller/src/UserProfileController.test.ts @@ -92,8 +92,8 @@ describe('UserProfileController', () => { expect(controller.state.firstSyncCompleted).toBe(true); expect(controller.state.syncQueue).toStrictEqual({ - 'entropy-0xAccount1': [{ address: 'eip155:_:0xAccount1' }], - null: [{ address: 'eip155:_:0xAccount2' }], + 'entropy-0xAccount1': [{ address: 'eip155:1:0xAccount1' }], + null: [{ address: 'eip155:1:0xAccount2' }], }); }, ); @@ -178,7 +178,7 @@ describe('UserProfileController', () => { options: { state: { firstSyncCompleted: true, - syncQueue: { someId: [{ address: 'eip155:_:0xSomeAccount' }] }, + syncQueue: { someId: [{ address: 'eip155:1:0xSomeAccount' }] }, }, }, }, @@ -208,7 +208,7 @@ describe('UserProfileController', () => { await Promise.resolve(); expect(controller.state.syncQueue).toStrictEqual({ - 'entropy-0xNewAccount': [{ address: 'eip155:_:0xNewAccount' }], + 'entropy-0xNewAccount': [{ address: 'eip155:1:0xNewAccount' }], }); }, ); @@ -228,7 +228,7 @@ describe('UserProfileController', () => { await Promise.resolve(); expect(controller.state.syncQueue).toStrictEqual({ - null: [{ address: 'eip155:_:0xNewAccount' }], + null: [{ address: 'eip155:1:0xNewAccount' }], }); }, ); @@ -255,7 +255,7 @@ describe('UserProfileController', () => { describe('_executePoll', () => { it('processes the sync queue on each poll', async () => { const accounts: Record = { - id1: [{ address: 'eip155:_:0xAccount1' }], + id1: [{ address: 'eip155:1:0xAccount1' }], }; await withController( { @@ -268,7 +268,7 @@ describe('UserProfileController', () => { expect(mockUpdateProfile).toHaveBeenCalledWith({ metametricsId: getMetaMetricsId(), entropySourceId: 'id1', - accounts: [{ address: 'eip155:_:0xAccount1' }], + accounts: [{ address: 'eip155:1:0xAccount1' }], }); expect(controller.state.syncQueue).toStrictEqual({}); }, @@ -278,11 +278,11 @@ describe('UserProfileController', () => { it('processes the sync queue in batches grouped by entropySourceId', async () => { const accounts: Record = { id1: [ - { address: 'eip155:_:0xAccount1' }, - { address: 'eip155:_:0xAccount2' }, + { address: 'eip155:1:0xAccount1' }, + { address: 'eip155:1:0xAccount2' }, ], - id2: [{ address: 'eip155:_:0xAccount3' }], - null: [{ address: 'eip155:_:0xAccount4' }], + id2: [{ address: 'eip155:1:0xAccount3' }], + null: [{ address: 'eip155:1:0xAccount4' }], }; await withController( { @@ -296,19 +296,19 @@ describe('UserProfileController', () => { metametricsId: getMetaMetricsId(), entropySourceId: 'id1', accounts: [ - { address: 'eip155:_:0xAccount1' }, - { address: 'eip155:_:0xAccount2' }, + { address: 'eip155:1:0xAccount1' }, + { address: 'eip155:1:0xAccount2' }, ], }); expect(mockUpdateProfile).toHaveBeenNthCalledWith(2, { metametricsId: getMetaMetricsId(), entropySourceId: 'id2', - accounts: [{ address: 'eip155:_:0xAccount3' }], + accounts: [{ address: 'eip155:1:0xAccount3' }], }); expect(mockUpdateProfile).toHaveBeenNthCalledWith(3, { metametricsId: getMetaMetricsId(), entropySourceId: null, - accounts: [{ address: 'eip155:_:0xAccount4' }], + accounts: [{ address: 'eip155:1:0xAccount4' }], }); expect(controller.state.syncQueue).toStrictEqual({}); }, diff --git a/packages/user-profile-controller/src/UserProfileController.ts b/packages/user-profile-controller/src/UserProfileController.ts index 18eef85225e..8f96b8e2e8a 100644 --- a/packages/user-profile-controller/src/UserProfileController.ts +++ b/packages/user-profile-controller/src/UserProfileController.ts @@ -356,7 +356,7 @@ function groupAccountsByEntropySourceId( */ function accountToCaipAccountId(account: InternalAccount): CaipAccountId { const [scope] = account.scopes; - const [namespace] = scope.split(':'); + const [namespace, reference] = scope.split(':'); isCaipNamespace(namespace); - return `${namespace}:_:${account.address}`; + return `${namespace}:${reference}:${account.address}`; } From e4c0a5093b55a70603c472736ffbc99d5c47e676 Mon Sep 17 00:00:00 2001 From: Michele Esposito Date: Mon, 24 Nov 2025 21:50:16 +0100 Subject: [PATCH 10/10] update tests --- .../src/UserProfileController.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/user-profile-controller/src/UserProfileController.test.ts b/packages/user-profile-controller/src/UserProfileController.test.ts index 99937f7b5f3..3311cbe99a7 100644 --- a/packages/user-profile-controller/src/UserProfileController.test.ts +++ b/packages/user-profile-controller/src/UserProfileController.test.ts @@ -263,8 +263,8 @@ describe('UserProfileController', () => { it('removes the key from the sync queue if it becomes empty after account removal', async () => { const accounts = { - id1: ['0xAccount1'], - id2: ['0xAccount2'], + id1: [{ address: '0xAccount1', scopes: ['eip155:1'] }], + id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], }; await withController( { @@ -279,7 +279,7 @@ describe('UserProfileController', () => { await Promise.resolve(); expect(controller.state.syncQueue).toStrictEqual({ - id2: ['0xAccount2'], + id2: [{ address: '0xAccount2', scopes: ['eip155:1'] }], }); }, );