diff --git a/.metamaskrc.dist b/.metamaskrc.dist index e0054b2e770a..54d4fec455df 100644 --- a/.metamaskrc.dist +++ b/.metamaskrc.dist @@ -10,6 +10,10 @@ SEEDLESS_ONBOARDING_ENABLED='false' ; Set this to `true` to enable Metamask Shield METAMASK_SHIELD_ENABLED='false' +; Backend WebSocket URL +; Uses production API endpoint by default (same as builds.yml) +;MM_BACKEND_WEBSOCKET_URL='wss://gateway.api.cx.metamask.io/v1' + ; These variables are required for OAuth Service GOOGLE_CLIENT_ID= APPLE_CLIENT_ID= diff --git a/.yarn/patches/@metamask-assets-controllers-npm-79.0.0-8b55992ea9.patch b/.yarn/patches/@metamask-assets-controllers-npm-81.0.0-706c32028b.patch similarity index 100% rename from .yarn/patches/@metamask-assets-controllers-npm-79.0.0-8b55992ea9.patch rename to .yarn/patches/@metamask-assets-controllers-npm-81.0.0-706c32028b.patch diff --git a/app/scripts/controller-init/controller-list.ts b/app/scripts/controller-init/controller-list.ts index 4142686b6ed0..3a4509b860eb 100644 --- a/app/scripts/controller-init/controller-list.ts +++ b/app/scripts/controller-init/controller-list.ts @@ -76,6 +76,10 @@ import { } from '@metamask/message-manager'; import { SignatureController } from '@metamask/signature-controller'; import { UserOperationController } from '@metamask/user-operation-controller'; +import { + AccountActivityService, + BackendWebSocketService, +} from '@metamask/core-backend'; import OnboardingController from '../controllers/onboarding'; import { PreferencesController } from '../controllers/preferences-controller'; import SwapsController from '../controllers/swaps'; @@ -181,6 +185,8 @@ export type Controller = | AssetsContractController | AccountTreeController | WebSocketService + | BackendWebSocketService + | AccountActivityService | MultichainAccountService | NetworkEnablementController; diff --git a/app/scripts/controller-init/core-backend/account-activity-service-init.test.ts b/app/scripts/controller-init/core-backend/account-activity-service-init.test.ts new file mode 100644 index 000000000000..3d0abcd1ab47 --- /dev/null +++ b/app/scripts/controller-init/core-backend/account-activity-service-init.test.ts @@ -0,0 +1,61 @@ +import { + AccountActivityService, + AccountActivityServiceMessenger, +} from '@metamask/core-backend'; +import { Messenger } from '@metamask/base-controller'; +import { ControllerInitRequest } from '../types'; +import { buildControllerInitRequestMock } from '../test/utils'; +import { getAccountActivityServiceMessenger } from '../messengers/core-backend'; +import { AccountActivityServiceInit } from './account-activity-service-init'; + +jest.mock('@metamask/core-backend'); +jest.mock('../../../../shared/lib/trace'); + +function getInitRequestMock(): jest.Mocked< + ControllerInitRequest +> { + const baseMessenger = new Messenger(); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getAccountActivityServiceMessenger(baseMessenger), + initMessenger: undefined, + }; + + return requestMock; +} + +describe('AccountActivityServiceInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes the controller', () => { + const { controller } = AccountActivityServiceInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(AccountActivityService); + }); + + it('passes the messenger and traceFn to the controller', () => { + AccountActivityServiceInit(getInitRequestMock()); + + const controllerMock = jest.mocked(AccountActivityService); + expect(controllerMock).toHaveBeenCalledWith({ + messenger: expect.any(Object), + traceFn: expect.any(Function), + }); + }); + + it('returns null for both state keys', () => { + const result = AccountActivityServiceInit(getInitRequestMock()); + + expect(result.memStateKey).toBeNull(); + expect(result.persistedStateKey).toBeNull(); + }); + + it('returns the controller instance', () => { + const { controller } = AccountActivityServiceInit(getInitRequestMock()); + + expect(controller).toBeDefined(); + expect(controller).toBeInstanceOf(AccountActivityService); + }); +}); diff --git a/app/scripts/controller-init/core-backend/account-activity-service-init.ts b/app/scripts/controller-init/core-backend/account-activity-service-init.ts new file mode 100644 index 000000000000..789620637c30 --- /dev/null +++ b/app/scripts/controller-init/core-backend/account-activity-service-init.ts @@ -0,0 +1,30 @@ +import { + AccountActivityService, + AccountActivityServiceMessenger, +} from '@metamask/core-backend'; +import { trace } from '../../../../shared/lib/trace'; +import { ControllerInitFunction } from '../types'; + +/** + * Initialize the Account Activity service. + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @returns The initialized service. + */ +export const AccountActivityServiceInit: ControllerInitFunction< + AccountActivityService, + AccountActivityServiceMessenger +> = ({ controllerMessenger }) => { + const controller = new AccountActivityService({ + messenger: controllerMessenger, + // @ts-expect-error: Types of `TraceRequest` are not the same. + traceFn: trace, + }); + + return { + memStateKey: null, + persistedStateKey: null, + controller, + }; +}; diff --git a/app/scripts/controller-init/core-backend/backend-websocket-service-init.test.ts b/app/scripts/controller-init/core-backend/backend-websocket-service-init.test.ts new file mode 100644 index 000000000000..cb8c10f9a5e2 --- /dev/null +++ b/app/scripts/controller-init/core-backend/backend-websocket-service-init.test.ts @@ -0,0 +1,278 @@ +import { BackendWebSocketService } from '@metamask/core-backend'; +import { Messenger, ActionConstraint } from '@metamask/base-controller'; +import { ControllerInitRequest } from '../types'; +import { buildControllerInitRequestMock } from '../test/utils'; +import { + BackendWebSocketServiceMessenger, + BackendWebSocketServiceInitMessenger, + getBackendWebSocketServiceMessenger, + getBackendWebSocketServiceInitMessenger, +} from '../messengers/core-backend'; +import { BackendWebSocketServiceInit } from './backend-websocket-service-init'; + +jest.mock('@metamask/core-backend'); + +function getInitRequestMock(): jest.Mocked< + ControllerInitRequest< + BackendWebSocketServiceMessenger, + BackendWebSocketServiceInitMessenger + > +> { + const baseMessenger = new Messenger(); + + // Mock RemoteFeatureFlagController:getState + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => + ({ + remoteFeatureFlags: { + backendWebSocketConnection: { + value: false, + }, + }, + }) as never, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + return requestMock; +} + +describe('BackendWebSocketServiceInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes the controller', () => { + const { controller } = BackendWebSocketServiceInit(getInitRequestMock()); + expect(controller).toBeInstanceOf(BackendWebSocketService); + }); + + it('passes the proper arguments to the controller', () => { + BackendWebSocketServiceInit(getInitRequestMock()); + + const controllerMock = jest.mocked(BackendWebSocketService); + expect(controllerMock).toHaveBeenCalledWith({ + messenger: expect.any(Object), + url: 'wss://gateway.api.cx.metamask.io/v1', + timeout: 15000, + reconnectDelay: 1000, + maxReconnectDelay: 30000, + requestTimeout: 20000, + traceFn: expect.any(Function), + isEnabled: expect.any(Function), + }); + }); + + it('returns null for both state keys', () => { + const result = BackendWebSocketServiceInit(getInitRequestMock()); + + expect(result.memStateKey).toBeNull(); + expect(result.persistedStateKey).toBeNull(); + }); + + it('uses environment variable for WebSocket URL when provided', () => { + const originalEnv = process.env.MM_BACKEND_WEBSOCKET_URL; + process.env.MM_BACKEND_WEBSOCKET_URL = 'wss://custom-backend.example.com'; + + BackendWebSocketServiceInit(getInitRequestMock()); + + const controllerMock = jest.mocked(BackendWebSocketService); + expect(controllerMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'wss://custom-backend.example.com', + }), + ); + + process.env.MM_BACKEND_WEBSOCKET_URL = originalEnv; + }); + + describe('isEnabled callback', () => { + it('returns false when feature flag is disabled', () => { + BackendWebSocketServiceInit(getInitRequestMock()); + + const { isEnabled } = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + + expect(isEnabled).toBeDefined(); + expect(isEnabled?.()).toBe(false); + }); + + it('returns false when feature flag check fails', () => { + const baseMessenger = new Messenger(); + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => { + throw new Error('Feature flag error'); + }, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + BackendWebSocketServiceInit(requestMock); + + const { isEnabled } = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + + expect(isEnabled).toBeDefined(); + expect(isEnabled?.()).toBe(false); + }); + + it('returns true when feature flag is enabled', () => { + const baseMessenger = new Messenger(); + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => + ({ + remoteFeatureFlags: { + backendWebSocketConnection: { + value: true, + }, + }, + }) as never, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + BackendWebSocketServiceInit(requestMock); + + const constructorArgs = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + const { isEnabled } = constructorArgs; + + expect(isEnabled).toBeDefined(); + expect(isEnabled?.()).toBe(true); + }); + + it('returns false when feature flag object does not have value property', () => { + const baseMessenger = new Messenger(); + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => + ({ + remoteFeatureFlags: { + backendWebSocketConnection: { + // Missing 'value' property + enabled: true, + }, + }, + }) as never, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + BackendWebSocketServiceInit(requestMock); + + const constructorArgs = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + const { isEnabled } = constructorArgs; + + expect(isEnabled).toBeDefined(); + expect(isEnabled?.()).toBe(false); + }); + + it('returns false when feature flag is not an object', () => { + const baseMessenger = new Messenger(); + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => + ({ + remoteFeatureFlags: { + backendWebSocketConnection: 'enabled', // Not an object + }, + }) as never, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + BackendWebSocketServiceInit(requestMock); + + const constructorArgs = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + const { isEnabled } = constructorArgs; + + expect(isEnabled).toBeDefined(); + expect(isEnabled?.()).toBe(false); + }); + + it('returns false when remoteFeatureFlags is missing', () => { + const baseMessenger = new Messenger(); + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => + ({ + // Missing remoteFeatureFlags + }) as never, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + BackendWebSocketServiceInit(requestMock); + + const constructorArgs = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + const { isEnabled } = constructorArgs; + + expect(isEnabled).toBeDefined(); + expect(isEnabled?.()).toBe(false); + }); + + it('logs warning when feature flag check fails', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const baseMessenger = new Messenger(); + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => { + throw new Error('Feature flag error'); + }, + ); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getBackendWebSocketServiceMessenger(baseMessenger), + initMessenger: getBackendWebSocketServiceInitMessenger(baseMessenger), + }; + + BackendWebSocketServiceInit(requestMock); + + const constructorArgs = jest.mocked(BackendWebSocketService).mock + .calls[0][0]; + const { isEnabled } = constructorArgs; + + expect(isEnabled).toBeDefined(); + isEnabled?.(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[BackendWebSocketService] Could not check feature flag, defaulting to NOT connect:', + expect.any(Error), + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/app/scripts/controller-init/core-backend/backend-websocket-service-init.ts b/app/scripts/controller-init/core-backend/backend-websocket-service-init.ts new file mode 100644 index 000000000000..5b775cb32c62 --- /dev/null +++ b/app/scripts/controller-init/core-backend/backend-websocket-service-init.ts @@ -0,0 +1,79 @@ +import { BackendWebSocketService } from '@metamask/core-backend'; +import { ControllerInitFunction } from '../types'; +import { + BackendWebSocketServiceMessenger, + BackendWebSocketServiceInitMessenger, +} from '../messengers/core-backend'; +import { trace } from '../../../../shared/lib/trace'; + +/** + * Initialize the Backend Platform WebSocket service with authentication support. + * This provides WebSocket connectivity for backend platform services + * like AccountActivityService and other platform-level integrations. + * + * Authentication Flow (simplified with AuthenticationController): + * 1. Core WebSocketService: Controls WHETHER connections are allowed (AuthenticationController.isSignedIn = yes) + * 2. Browser/Extension lifecycle: Controls WHEN to connect/disconnect (close = disconnect, open = connect) + * 3. AuthenticationController.isSignedIn includes BOTH wallet unlock + identity provider authentication + * 4. Fresh bearer tokens retrieved on each connection attempt (getBearerToken checks wallet unlock internally) + * + * @param request - The request object. + * @param request.controllerMessenger - The messenger to use for the service. + * @param request.initMessenger - The messenger for accessing other controllers. + * @returns The initialized service. + */ +export const BackendWebSocketServiceInit: ControllerInitFunction< + BackendWebSocketService, + BackendWebSocketServiceMessenger, + BackendWebSocketServiceInitMessenger +> = ({ controllerMessenger, initMessenger }) => { + const controller = new BackendWebSocketService({ + messenger: controllerMessenger, + url: + process.env.MM_BACKEND_WEBSOCKET_URL || + 'wss://gateway.api.cx.metamask.io/v1', + // Backend Platform optimized configuration + timeout: 15000, // Longer timeout for backend operations + reconnectDelay: 1000, // Conservative reconnect strategy + maxReconnectDelay: 30000, // Allow longer delays for backend stability + requestTimeout: 20000, // Reasonable timeout for backend requests + // Inject the Sentry-backed trace function from extension platform + // @ts-expect-error: Types of `TraceRequest` are not the same. + traceFn: trace, + // Feature flag AND app lifecycle integration + // Service will check this callback before connecting/reconnecting + isEnabled: () => { + try { + const remoteFeatureFlagState = initMessenger?.call( + 'RemoteFeatureFlagController:getState', + ); + const { backendWebSocketConnection } = + remoteFeatureFlagState?.remoteFeatureFlags || {}; + + const result = + backendWebSocketConnection && + typeof backendWebSocketConnection === 'object' && + 'value' in backendWebSocketConnection && + Boolean(backendWebSocketConnection.value); + + return Boolean(result); + } catch (error) { + // If feature flag check fails, default to NOT connecting for safer startup + console.warn( + '[BackendWebSocketService] Could not check feature flag, defaulting to NOT connect:', + error, + ); + return false; + } + }, + }); + + // Authentication and lock/unlock handling is now managed by the core WebSocket service + // Core service will automatically connect when wallet is unlocked (no manual connect() needed) + + return { + memStateKey: null, + persistedStateKey: null, + controller, + }; +}; diff --git a/app/scripts/controller-init/core-backend/index.ts b/app/scripts/controller-init/core-backend/index.ts new file mode 100644 index 000000000000..56fcf733d9d6 --- /dev/null +++ b/app/scripts/controller-init/core-backend/index.ts @@ -0,0 +1,2 @@ +export { BackendWebSocketServiceInit } from './backend-websocket-service-init'; +export { AccountActivityServiceInit } from './account-activity-service-init'; diff --git a/app/scripts/controller-init/messengers/core-backend/account-activity-service-messenger.test.ts b/app/scripts/controller-init/messengers/core-backend/account-activity-service-messenger.test.ts new file mode 100644 index 000000000000..955ae6f4be9b --- /dev/null +++ b/app/scripts/controller-init/messengers/core-backend/account-activity-service-messenger.test.ts @@ -0,0 +1,12 @@ +import { Messenger, RestrictedMessenger } from '@metamask/base-controller'; +import { getAccountActivityServiceMessenger } from './account-activity-service-messenger'; + +describe('getAccountActivityServiceMessenger', () => { + it('returns a restricted controller messenger', () => { + const controllerMessenger = new Messenger(); + const messenger = getAccountActivityServiceMessenger(controllerMessenger); + + expect(messenger).toBeInstanceOf(RestrictedMessenger); + expect(messenger).toBeDefined(); + }); +}); diff --git a/app/scripts/controller-init/messengers/core-backend/account-activity-service-messenger.ts b/app/scripts/controller-init/messengers/core-backend/account-activity-service-messenger.ts new file mode 100644 index 000000000000..badcb7380d5f --- /dev/null +++ b/app/scripts/controller-init/messengers/core-backend/account-activity-service-messenger.ts @@ -0,0 +1,33 @@ +import { AccountActivityServiceMessenger } from '@metamask/core-backend'; +import { BaseControllerMessenger } from '../../types'; + +/** + * Get a restricted messenger for the Account Activity service. This is scoped to the + * actions and events that the Account Activity service is allowed to handle. + * + * @param messenger - The main controller messenger. + * @returns The restricted messenger. + */ +export function getAccountActivityServiceMessenger( + messenger: BaseControllerMessenger, +): AccountActivityServiceMessenger { + return messenger.getRestricted({ + name: 'AccountActivityService', + allowedActions: [ + 'AccountsController:getSelectedAccount', + 'BackendWebSocketService:connect', + 'BackendWebSocketService:disconnect', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + ], + allowedEvents: [ + 'AccountsController:selectedAccountChange', + 'BackendWebSocketService:connectionStateChanged', + ], + }); +} diff --git a/app/scripts/controller-init/messengers/core-backend/backend-websocket-service-messenger.test.ts b/app/scripts/controller-init/messengers/core-backend/backend-websocket-service-messenger.test.ts new file mode 100644 index 000000000000..acd9b6333f80 --- /dev/null +++ b/app/scripts/controller-init/messengers/core-backend/backend-websocket-service-messenger.test.ts @@ -0,0 +1,26 @@ +import { Messenger, RestrictedMessenger } from '@metamask/base-controller'; +import { + getBackendWebSocketServiceMessenger, + getBackendWebSocketServiceInitMessenger, +} from './backend-websocket-service-messenger'; + +describe('getBackendWebSocketServiceMessenger', () => { + it('returns a restricted controller messenger', () => { + const controllerMessenger = new Messenger(); + const messenger = getBackendWebSocketServiceMessenger(controllerMessenger); + + expect(messenger).toBeInstanceOf(RestrictedMessenger); + expect(messenger).toBeDefined(); + }); +}); + +describe('getBackendWebSocketServiceInitMessenger', () => { + it('returns a restricted controller messenger', () => { + const controllerMessenger = new Messenger(); + const messenger = + getBackendWebSocketServiceInitMessenger(controllerMessenger); + + expect(messenger).toBeInstanceOf(RestrictedMessenger); + expect(messenger).toBeDefined(); + }); +}); diff --git a/app/scripts/controller-init/messengers/core-backend/backend-websocket-service-messenger.ts b/app/scripts/controller-init/messengers/core-backend/backend-websocket-service-messenger.ts new file mode 100644 index 000000000000..875c6781316a --- /dev/null +++ b/app/scripts/controller-init/messengers/core-backend/backend-websocket-service-messenger.ts @@ -0,0 +1,46 @@ +import { BackendWebSocketServiceMessenger as BackendPlatformWebSocketServiceMessenger } from '@metamask/core-backend'; + +export type BackendWebSocketServiceMessenger = + BackendPlatformWebSocketServiceMessenger; + +/** + * Get a restricted messenger for the Backend Platform WebSocket service. + * This is scoped to backend platform operations and services. + * + * @param messenger - The main controller messenger. + * @returns The restricted messenger. + */ +export function getBackendWebSocketServiceMessenger( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messenger: any, // Using any to avoid type conflicts with the main messenger +): BackendPlatformWebSocketServiceMessenger { + return messenger.getRestricted({ + name: 'BackendWebSocketService', + allowedActions: [ + 'AuthenticationController:getBearerToken', // Get auth token (includes wallet unlock check) + ], + allowedEvents: [ + 'AuthenticationController:stateChange', // Listen for authentication state (sign in/out) + 'KeyringController:lock', // Listen for wallet lock + 'KeyringController:unlock', // Listen for wallet unlock + ], + }); +} + +export type BackendWebSocketServiceInitMessenger = ReturnType< + typeof getBackendWebSocketServiceInitMessenger +>; + +export function getBackendWebSocketServiceInitMessenger( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messenger: any, +) { + return messenger.getRestricted({ + name: 'BackendWebSocketServiceInit', + allowedEvents: [], + allowedActions: [ + 'RemoteFeatureFlagController:getState', + 'AuthenticationController:getBearerToken', + ], + }); +} diff --git a/app/scripts/controller-init/messengers/core-backend/index.ts b/app/scripts/controller-init/messengers/core-backend/index.ts new file mode 100644 index 000000000000..125ea53c8131 --- /dev/null +++ b/app/scripts/controller-init/messengers/core-backend/index.ts @@ -0,0 +1,9 @@ +export { + getBackendWebSocketServiceMessenger, + getBackendWebSocketServiceInitMessenger, +} from './backend-websocket-service-messenger'; +export type { + BackendWebSocketServiceMessenger, + BackendWebSocketServiceInitMessenger, +} from './backend-websocket-service-messenger'; +export { getAccountActivityServiceMessenger } from './account-activity-service-messenger'; diff --git a/app/scripts/controller-init/messengers/index.ts b/app/scripts/controller-init/messengers/index.ts index 50a6fe7218ee..e8250344b0c8 100644 --- a/app/scripts/controller-init/messengers/index.ts +++ b/app/scripts/controller-init/messengers/index.ts @@ -23,6 +23,11 @@ import { getTransactionControllerMessenger, getTransactionControllerInitMessenger, } from './transaction-controller-messenger'; +import { + getBackendWebSocketServiceMessenger, + getBackendWebSocketServiceInitMessenger, + getAccountActivityServiceMessenger, +} from './core-backend'; import { getMultichainBalancesControllerMessenger, getMultichainTransactionsControllerMessenger, @@ -682,6 +687,14 @@ export const CONTROLLER_MESSENGERS = { getMessenger: getWebSocketServiceMessenger, getInitMessenger: noop, }, + BackendWebSocketService: { + getMessenger: getBackendWebSocketServiceMessenger, + getInitMessenger: getBackendWebSocketServiceInitMessenger, + }, + AccountActivityService: { + getMessenger: getAccountActivityServiceMessenger, + getInitMessenger: noop, + }, SmartTransactionsController: { getMessenger: getSmartTransactionsControllerMessenger, getInitMessenger: getSmartTransactionsControllerInitMessenger, diff --git a/app/scripts/controller-init/messengers/token-balances-controller-messenger.ts b/app/scripts/controller-init/messengers/token-balances-controller-messenger.ts index 8bb7a98ce547..827528b30b55 100644 --- a/app/scripts/controller-init/messengers/token-balances-controller-messenger.ts +++ b/app/scripts/controller-init/messengers/token-balances-controller-messenger.ts @@ -19,6 +19,11 @@ import { } from '@metamask/assets-controllers'; import { KeyringControllerAccountRemovedEvent } from '@metamask/keyring-controller'; import { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { + AccountActivityServiceStatusChangedEvent, + AccountActivityServiceBalanceUpdatedEvent, +} from '@metamask/core-backend'; +import type { TokenDetectionControllerAddDetectedTokensViaWsAction } from '@metamask/assets-controllers'; import { AccountTrackerControllerGetStateAction } from '../../controllers/account-tracker-controller'; import { PreferencesControllerGetStateAction, @@ -45,13 +50,16 @@ type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetStateAction | PreferencesControllerGetStateAction - | TokensControllerGetStateAction; + | TokensControllerGetStateAction + | TokenDetectionControllerAddDetectedTokensViaWsAction; type AllowedEvents = | KeyringControllerAccountRemovedEvent | NetworkControllerStateChangeEvent | PreferencesControllerStateChangeEvent - | TokensControllerStateChangeEvent; + | TokensControllerStateChangeEvent + | AccountActivityServiceStatusChangedEvent + | AccountActivityServiceBalanceUpdatedEvent; export type TokenBalancesControllerMessenger = ReturnType< typeof getTokenBalancesControllerMessenger @@ -79,12 +87,15 @@ export function getTokenBalancesControllerMessenger( 'AccountTrackerController:getState', 'AccountTrackerController:updateNativeBalances', 'AccountTrackerController:updateStakedBalances', + 'TokenDetectionController:addDetectedTokensViaWs', ], allowedEvents: [ 'PreferencesController:stateChange', 'TokensController:stateChange', 'NetworkController:stateChange', 'KeyringController:accountRemoved', + 'AccountActivityService:statusChanged', + 'AccountActivityService:balanceUpdated', ], }); } diff --git a/app/scripts/controllers/account-tracker-controller.ts b/app/scripts/controllers/account-tracker-controller.ts index 288121dcbce5..07848381d1b0 100644 --- a/app/scripts/controllers/account-tracker-controller.ts +++ b/app/scripts/controllers/account-tracker-controller.ts @@ -1015,20 +1015,26 @@ export default class AccountTrackerController extends BaseController< ) { this.update((state) => { balances.forEach(({ address, chainId, balance }) => { + // Temporary until moving AccountTrackerController in core with an normalized format + // accountsByChainId works with lowercase addresses, this is just a safe guard in case address is in checksum format + // Convert address to lowercase to match extension's accountsByChainId format + const lowercaseAddress = address.toLowerCase(); + // Ensure the chainId exists in the state if (!state.accountsByChainId[chainId]) { state.accountsByChainId[chainId] = {}; } // Ensure the address exists for this chain - if (!state.accountsByChainId[chainId][address]) { - state.accountsByChainId[chainId][address] = { - address, + if (!state.accountsByChainId[chainId][lowercaseAddress]) { + state.accountsByChainId[chainId][lowercaseAddress] = { + address: lowercaseAddress, balance: '0x0', }; } // Update the balance if (balance) { - state.accountsByChainId[chainId][address].balance = toHex(balance); + state.accountsByChainId[chainId][lowercaseAddress].balance = + toHex(balance); } }); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 56676183a7c1..f4fc65cba4e5 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -326,6 +326,10 @@ import { MultichainRouterInit, ///: END:ONLY_INCLUDE_IF } from './controller-init/snaps'; +import { + BackendWebSocketServiceInit, + AccountActivityServiceInit, +} from './controller-init/core-backend'; import { AuthenticationControllerInit } from './controller-init/identity/authentication-controller-init'; import { UserStorageControllerInit } from './controller-init/identity/user-storage-controller-init'; import { DeFiPositionsControllerInit } from './controller-init/defi-positions/defi-positions-controller-init'; @@ -551,6 +555,8 @@ export default class MetamaskController extends EventEmitter { SnapInsightsController: SnapInsightsControllerInit, SnapInterfaceController: SnapInterfaceControllerInit, WebSocketService: WebSocketServiceInit, + BackendWebSocketService: BackendWebSocketServiceInit, + AccountActivityService: AccountActivityServiceInit, PPOMController: PPOMControllerInit, PhishingController: PhishingControllerInit, OnboardingController: OnboardingControllerInit, @@ -665,6 +671,8 @@ export default class MetamaskController extends EventEmitter { this.swapsController = controllersByName.SwapsController; this.bridgeController = controllersByName.BridgeController; this.bridgeStatusController = controllersByName.BridgeStatusController; + this.backendWebSocketService = controllersByName.BackendWebSocketService; + this.accountActivityService = controllersByName.AccountActivityService; this.nftController = controllersByName.NftController; this.nftDetectionController = controllersByName.NftDetectionController; this.assetsContractController = controllersByName.AssetsContractController; @@ -7860,6 +7868,12 @@ export default class MetamaskController extends EventEmitter { // Notify Snaps that the client is open or closed. this.controllerMessenger.call('SnapController:setClientActive', open); + + if (open) { + this.controllerMessenger.call('BackendWebSocketService:connect'); + } else { + this.controllerMessenger.call('BackendWebSocketService:disconnect'); + } } /* eslint-enable accessor-pairs */ diff --git a/builds.yml b/builds.yml index 17228f8bd230..4201b70aeb27 100644 --- a/builds.yml +++ b/builds.yml @@ -242,6 +242,11 @@ env: - STORYBOOK: false - INFURA_STORYBOOK_PROJECT_ID + ### + # Core Backend Services + ### + - MM_BACKEND_WEBSOCKET_URL: wss://gateway.api.cx.metamask.io/v1 + ### # Notifications Feature ### diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 24bd423ad27d..349e54aac24e 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -967,6 +967,18 @@ "lodash": true } }, + "@metamask/core-backend": { + "globals": { + "URL": true, + "WebSocket": true, + "clearTimeout": true, + "setTimeout": true + }, + "packages": { + "@metamask/utils": true, + "uuid": true + } + }, "@metamask/delegation-controller": { "packages": { "@metamask/base-controller": true, diff --git a/lavamoat/browserify/experimental/policy.json b/lavamoat/browserify/experimental/policy.json index 24bd423ad27d..349e54aac24e 100644 --- a/lavamoat/browserify/experimental/policy.json +++ b/lavamoat/browserify/experimental/policy.json @@ -967,6 +967,18 @@ "lodash": true } }, + "@metamask/core-backend": { + "globals": { + "URL": true, + "WebSocket": true, + "clearTimeout": true, + "setTimeout": true + }, + "packages": { + "@metamask/utils": true, + "uuid": true + } + }, "@metamask/delegation-controller": { "packages": { "@metamask/base-controller": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 24bd423ad27d..349e54aac24e 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -967,6 +967,18 @@ "lodash": true } }, + "@metamask/core-backend": { + "globals": { + "URL": true, + "WebSocket": true, + "clearTimeout": true, + "setTimeout": true + }, + "packages": { + "@metamask/utils": true, + "uuid": true + } + }, "@metamask/delegation-controller": { "packages": { "@metamask/base-controller": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 24bd423ad27d..349e54aac24e 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -967,6 +967,18 @@ "lodash": true } }, + "@metamask/core-backend": { + "globals": { + "URL": true, + "WebSocket": true, + "clearTimeout": true, + "setTimeout": true + }, + "packages": { + "@metamask/utils": true, + "uuid": true + } + }, "@metamask/delegation-controller": { "packages": { "@metamask/base-controller": true, diff --git a/lavamoat/webpack/policy.json b/lavamoat/webpack/policy.json index 6fff940ad9b1..1112db09a340 100644 --- a/lavamoat/webpack/policy.json +++ b/lavamoat/webpack/policy.json @@ -1005,6 +1005,18 @@ "lodash": true } }, + "@metamask/core-backend": { + "globals": { + "URL": true, + "WebSocket": true, + "clearTimeout": true, + "setTimeout": true + }, + "packages": { + "@metamask/utils": true, + "uuid": true + } + }, "@metamask/delegation-controller": { "packages": { "@metamask/base-controller": true, diff --git a/package.json b/package.json index 08b7945c5a2a..2c83283ae04b 100644 --- a/package.json +++ b/package.json @@ -271,7 +271,7 @@ "@metamask/address-book-controller": "^6.1.0", "@metamask/announcement-controller": "^7.0.3", "@metamask/approval-controller": "^7.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A79.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-79.0.0-8b55992ea9.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A81.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-81.0.0-706c32028b.patch", "@metamask/base-controller": "^8.3.0", "@metamask/bitcoin-wallet-snap": "^1.3.0", "@metamask/bridge-controller": "^50.0.0", @@ -280,6 +280,7 @@ "@metamask/chain-agnostic-permission": "^1.1.0", "@metamask/contract-metadata": "^2.5.0", "@metamask/controller-utils": "^11.14.0", + "@metamask/core-backend": "^2.1.0", "@metamask/delegation-controller": "^0.7.0", "@metamask/delegation-core": "^0.2.0-rc.1", "@metamask/delegation-deployments": "^0.11.0", diff --git a/yarn.lock b/yarn.lock index 8ce2ff77a282..4916c684b68a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5504,9 +5504,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:79.0.0": - version: 79.0.0 - resolution: "@metamask/assets-controllers@npm:79.0.0" +"@metamask/assets-controllers@npm:81.0.0": + version: 81.0.0 + resolution: "@metamask/assets-controllers@npm:81.0.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5515,13 +5515,13 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/base-controller": "npm:^8.4.0" + "@metamask/base-controller": "npm:^8.4.1" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.14.0" + "@metamask/controller-utils": "npm:^11.14.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/polling-controller": "npm:^14.0.0" + "@metamask/polling-controller": "npm:^14.0.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" @@ -5541,6 +5541,7 @@ __metadata: "@metamask/account-tree-controller": ^1.0.0 "@metamask/accounts-controller": ^33.0.0 "@metamask/approval-controller": ^7.0.0 + "@metamask/core-backend": ^2.0.0 "@metamask/keyring-controller": ^23.0.0 "@metamask/network-controller": ^24.0.0 "@metamask/permission-controller": ^11.0.0 @@ -5550,13 +5551,13 @@ __metadata: "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^60.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/544257458c48bd99f444a9520a190cd559f2a08b2d9be3e9e48f7b2effd5f3514dac70febfbd009091d2938aec59fe7dfbd658ede1b4708b8c8656fd52e7239f + checksum: 10/62630d6ff66b19c701d90c5b127bf6c24af0825cf4396a4fb92c92e4e5f3b50104fccb6bc2dc7747f6e73605a0b0ec38544b243d28c7afe7b0c848abc2025e7b languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A79.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-79.0.0-8b55992ea9.patch": - version: 79.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A79.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-79.0.0-8b55992ea9.patch::version=79.0.0&hash=5bbfdf" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A81.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-81.0.0-706c32028b.patch": + version: 81.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A81.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-81.0.0-706c32028b.patch::version=81.0.0&hash=5bbfdf" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5565,13 +5566,13 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/base-controller": "npm:^8.4.0" + "@metamask/base-controller": "npm:^8.4.1" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.14.0" + "@metamask/controller-utils": "npm:^11.14.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/polling-controller": "npm:^14.0.0" + "@metamask/polling-controller": "npm:^14.0.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" @@ -5591,6 +5592,7 @@ __metadata: "@metamask/account-tree-controller": ^1.0.0 "@metamask/accounts-controller": ^33.0.0 "@metamask/approval-controller": ^7.0.0 + "@metamask/core-backend": ^2.0.0 "@metamask/keyring-controller": ^23.0.0 "@metamask/network-controller": ^24.0.0 "@metamask/permission-controller": ^11.0.0 @@ -5600,7 +5602,7 @@ __metadata: "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^60.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/18f72ea2b98fc9440a1b30671961654ed9e15a1b5c2cfcd572db5f1cff76b8a0eec6892d13497c3962cf16c5bba06787f265145280c25ff2f3a6515289a72102 + checksum: 10/1f40cf1278e00be6e97da5f3e3b978c49cb528787dc9ea26a928bb4fc1c32913fb06bfc6f175d6c729ba048d19c728695d3d9595c4fdbc1c00505208214d7dcd languageName: node linkType: hard @@ -5703,8 +5705,8 @@ __metadata: linkType: hard "@metamask/bridge-status-controller@npm:^50.0.0": - version: 50.0.0 - resolution: "@metamask/bridge-status-controller@npm:50.0.0" + version: 50.1.0 + resolution: "@metamask/bridge-status-controller@npm:50.1.0" dependencies: "@metamask/base-controller": "npm:^8.4.1" "@metamask/controller-utils": "npm:^11.14.1" @@ -5715,12 +5717,12 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^33.0.0 - "@metamask/bridge-controller": ^50.0.0 + "@metamask/bridge-controller": ^51.0.0 "@metamask/gas-fee-controller": ^24.0.0 "@metamask/network-controller": ^24.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^60.0.0 - checksum: 10/ca16e53713a97295f33b86cffc811de22d139faab0132973b9293bd9df84b06175dc49e4512714f8a6db80144313e2429e611d9522d6dcbe3cf2640b7aa238b5 + checksum: 10/b50f890b3a4f8214df2a36a41946269f85bbbe3becc986f752fea5babfb023cc5222b8718912f18adab12a028cf5636a482a6c471dfdc83708b66859a86d8515 languageName: node linkType: hard @@ -5786,6 +5788,22 @@ __metadata: languageName: node linkType: hard +"@metamask/core-backend@npm:^2.1.0": + version: 2.1.0 + resolution: "@metamask/core-backend@npm:2.1.0" + dependencies: + "@metamask/base-controller": "npm:^8.4.1" + "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/profile-sync-controller": "npm:^25.1.1" + "@metamask/utils": "npm:^11.8.1" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^33.1.0 + "@metamask/keyring-controller": ^23.0.0 + checksum: 10/7dce39ab1d433013491c36737767edf1ff7e3fdf3793bc8612d47e8d8e4d454e9d921ec5e0cc6d79c809a31d0e0491560d45d245b2c3bd61f139b305a77e63a4 + languageName: node + linkType: hard + "@metamask/delegation-abis@npm:^0.9.0": version: 0.9.0 resolution: "@metamask/delegation-abis@npm:0.9.0" @@ -7446,11 +7464,11 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^25.1.0": - version: 25.1.0 - resolution: "@metamask/profile-sync-controller@npm:25.1.0" +"@metamask/profile-sync-controller@npm:^25.1.0, @metamask/profile-sync-controller@npm:^25.1.1": + version: 25.1.1 + resolution: "@metamask/profile-sync-controller@npm:25.1.1" dependencies: - "@metamask/base-controller": "npm:^8.4.0" + "@metamask/base-controller": "npm:^8.4.1" "@metamask/snaps-sdk": "npm:^9.0.0" "@metamask/snaps-utils": "npm:^11.0.0" "@metamask/utils": "npm:^11.8.1" @@ -7465,7 +7483,7 @@ __metadata: "@metamask/providers": ^22.0.0 "@metamask/snaps-controllers": ^14.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/eb39da03198c21a161fa588a7e693592bee72df6f41b4320ec9f95406790ad05ddcd7d6a3fcd589b5f6854b7d351014a6fa39821bfe338dd23d106974bcd9ae7 + checksum: 10/50ec133a53af28c989ce13b93dbdc55f4c1b59dfabe6d113f15214cb2f9b4c8be9e23541d46ff23bcc1aa7c2091d9ac2566cfe19cf664f915912405e0f1d62dd languageName: node linkType: hard @@ -31893,7 +31911,7 @@ __metadata: "@metamask/announcement-controller": "npm:^7.0.3" "@metamask/api-specs": "npm:^0.13.0" "@metamask/approval-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A79.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-79.0.0-8b55992ea9.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A81.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-81.0.0-706c32028b.patch" "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^8.3.0" "@metamask/bitcoin-wallet-snap": "npm:^1.3.0" @@ -31904,6 +31922,7 @@ __metadata: "@metamask/chain-agnostic-permission": "npm:^1.1.0" "@metamask/contract-metadata": "npm:^2.5.0" "@metamask/controller-utils": "npm:^11.14.0" + "@metamask/core-backend": "npm:^2.1.0" "@metamask/delegation-controller": "npm:^0.7.0" "@metamask/delegation-core": "npm:^0.2.0-rc.1" "@metamask/delegation-deployments": "npm:^0.11.0"