diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c9237aefc9a7..abb929dfa224 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,12 +56,13 @@ privacy-snapshot.json @MetaMask/extension-privacy-reviewers .devcontainer/ @MetaMask/extension-security-team @HowardBraham # Confirmations team to own code for confirmations on UI. -app/scripts/lib/ppom @MetaMask/confirmations -app/scripts/lib/signature @MetaMask/confirmations -app/scripts/lib/transaction/decode @MetaMask/confirmations -app/scripts/lib/transaction/metrics.* @MetaMask/confirmations -app/scripts/lib/transaction/util.* @MetaMask/confirmations -ui/pages/confirmations @MetaMask/confirmations +app/scripts/controller-init/confirmations @MetaMask/confirmations +app/scripts/lib/ppom @MetaMask/confirmations +app/scripts/lib/signature @MetaMask/confirmations +app/scripts/lib/transaction/decode @MetaMask/confirmations +app/scripts/lib/transaction/metrics.* @MetaMask/confirmations +app/scripts/lib/transaction/util.* @MetaMask/confirmations +ui/pages/confirmations @MetaMask/confirmations # Design System to own code for the component-library folder # Slack handle: @metamask-design-system-team | Slack channel: #metamask-design-system diff --git a/app/scripts/controller-init/confirmations/ppom-controller-init.test.ts b/app/scripts/controller-init/confirmations/ppom-controller-init.test.ts new file mode 100644 index 000000000000..778a4e390e08 --- /dev/null +++ b/app/scripts/controller-init/confirmations/ppom-controller-init.test.ts @@ -0,0 +1,106 @@ +import { + PPOMController, + PPOMControllerMessenger, +} from '@metamask/ppom-validator'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { PreferencesController } from '../../controllers/preferences-controller'; +import { buildControllerInitRequestMock, CHAIN_ID_MOCK } from '../test/utils'; +import { ControllerInitRequest } from '../types'; +import { + getPPOMControllerInitMessenger, + getPPOMControllerMessenger, + PPOMControllerInitMessenger, +} from '../messengers/ppom-controller-messenger'; +import { PPOMControllerInit } from './ppom-controller-init'; + +type PPOMControllerOptions = ConstructorParameters[0]; + +jest.mock('@metamask/ppom-validator'); + +/** + * Build a mock PreferencesController. + * + * @param partialMock - A partial mock object for the PreferencesController, merged + * with the default mock. + * @returns A mock PreferencesController. + */ +function buildControllerMock( + partialMock?: Partial, +): PreferencesController { + const defaultPreferencesControllerMock = { + state: { securityAlertsEnabled: true }, + }; + + // @ts-expect-error Incomplete mock, just includes properties used by code-under-test. + return { + ...defaultPreferencesControllerMock, + ...partialMock, + }; +} + +function buildInitRequestMock(): jest.Mocked< + ControllerInitRequest +> { + const baseControllerMessenger = new ControllerMessenger(); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getPPOMControllerMessenger(baseControllerMessenger), + initMessenger: getPPOMControllerInitMessenger(baseControllerMessenger), + }; + + requestMock.getController.mockReturnValue(buildControllerMock()); + + return requestMock; +} + +describe('PPOM Controller Init', () => { + const ppomControllerClassMock = jest.mocked(PPOMController); + + /** + * Extract a constructor option passed to the controller. + * + * @param option - The option to extract. + * @param dependencyProperties - Any properties required on the controller dependencies. + * @returns The extracted option. + */ + function testConstructorOption( + option: T, + dependencyProperties?: Record, + ): PPOMControllerOptions[T] { + const requestMock = buildInitRequestMock(); + + requestMock.getController.mockReturnValue( + buildControllerMock(dependencyProperties), + ); + + PPOMControllerInit(requestMock); + + return ppomControllerClassMock.mock.calls[0][0][option]; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns controller instance', () => { + const requestMock = buildInitRequestMock(); + expect(PPOMControllerInit(requestMock).controller).toBeInstanceOf( + PPOMController, + ); + }); + + it('determines if security alerts enabled using preference', () => { + const securityAlertsEnabled = testConstructorOption( + 'securityAlertsEnabled', + { state: { securityAlertsEnabled: true } }, + ); + + expect(securityAlertsEnabled).toBe(true); + }); + + it('sets chain ID to global chain ID', () => { + const chainId = testConstructorOption('chainId'); + expect(chainId).toBe(CHAIN_ID_MOCK); + }); +}); diff --git a/app/scripts/controller-init/confirmations/ppom-controller-init.ts b/app/scripts/controller-init/confirmations/ppom-controller-init.ts new file mode 100644 index 000000000000..7a0fa09585cd --- /dev/null +++ b/app/scripts/controller-init/confirmations/ppom-controller-init.ts @@ -0,0 +1,52 @@ +import { + PPOMController, + PPOMControllerMessenger, +} from '@metamask/ppom-validator'; +import { IndexedDBPPOMStorage } from '../../lib/ppom/indexed-db-backend'; +import * as PPOMModule from '../../lib/ppom/ppom'; +import { ControllerInitFunction } from '../types'; +import { PPOMControllerInitMessenger } from '../messengers/ppom-controller-messenger'; + +export const PPOMControllerInit: ControllerInitFunction< + PPOMController, + PPOMControllerMessenger, + PPOMControllerInitMessenger +> = (request) => { + const { + controllerMessenger, + initMessenger, + getController, + getGlobalChainId, + getProvider, + persistedState, + } = request; + + const preferencesController = () => getController('PreferencesController'); + + const controller = new PPOMController({ + messenger: controllerMessenger, + storageBackend: new IndexedDBPPOMStorage('PPOMDB', 1), + provider: getProvider(), + ppomProvider: { + // @ts-expect-error Controller and PPOM wrapper have different argument types in `new` and `validateJsonRpc` + PPOM: PPOMModule.PPOM, + ppomInit: () => PPOMModule.default(process.env.PPOM_URI), + }, + // @ts-expect-error State type is not `Partial` in controller. + state: persistedState.PPOMController, + chainId: getGlobalChainId(), + securityAlertsEnabled: preferencesController().state.securityAlertsEnabled, + // @ts-expect-error `onPreferencesChange` type signature is incorrect in `PPOMController` + onPreferencesChange: initMessenger.subscribe.bind( + initMessenger, + 'PreferencesController:stateChange', + ), + // Both values have defaults in `builds.yml` so should always be defined. + cdnBaseUrl: process.env.BLOCKAID_FILE_CDN as string, + blockaidPublicKey: process.env.BLOCKAID_PUBLIC_KEY as string, + }); + + return { + controller, + }; +}; diff --git a/app/scripts/controller-init/confirmations/transaction-controller-init.test.ts b/app/scripts/controller-init/confirmations/transaction-controller-init.test.ts new file mode 100644 index 000000000000..cfb175b16c7b --- /dev/null +++ b/app/scripts/controller-init/confirmations/transaction-controller-init.test.ts @@ -0,0 +1,189 @@ +import { + TransactionController, + TransactionControllerMessenger, + TransactionControllerOptions, +} from '@metamask/transaction-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { NetworkController } from '@metamask/network-controller'; +import { buildControllerInitRequestMock, CHAIN_ID_MOCK } from '../test/utils'; +import { + getTransactionControllerInitMessenger, + getTransactionControllerMessenger, + TransactionControllerInitMessenger, +} from '../messengers/transaction-controller-messenger'; +import { ControllerInitRequest } from '../types'; +import { TransactionControllerInit } from './transaction-controller-init'; + +jest.mock('@metamask/transaction-controller'); + +/** + * Build a mock NetworkController. + * + * @param partialMock - A partial mock object for the NetworkController, merged + * with the default mock. + * @returns A mock NetworkController. + */ +function buildControllerMock( + partialMock?: Partial, +): NetworkController { + const defaultNetworkControllerMock = { + getNetworkClientRegistry: jest.fn().mockReturnValue({}), + }; + + // @ts-expect-error Incomplete mock, just includes properties used by code-under-test. + return { + ...defaultNetworkControllerMock, + ...partialMock, + }; +} + +function buildInitRequestMock(): jest.Mocked< + ControllerInitRequest< + TransactionControllerMessenger, + TransactionControllerInitMessenger + > +> { + const baseControllerMessenger = new ControllerMessenger(); + + const requestMock = { + ...buildControllerInitRequestMock(), + controllerMessenger: getTransactionControllerMessenger( + baseControllerMessenger, + ), + initMessenger: getTransactionControllerInitMessenger( + baseControllerMessenger, + ), + }; + + requestMock.getController.mockReturnValue(buildControllerMock()); + + return requestMock; +} + +describe('Transaction Controller Init', () => { + const transactionControllerClassMock = jest.mocked(TransactionController); + + /** + * Extract a constructor option passed to the controller. + * + * @param option - The option to extract. + * @param dependencyProperties - Any properties required on the controller dependencies. + * @returns The extracted option. + */ + function testConstructorOption( + option: T, + dependencyProperties: Record = {}, + ): TransactionControllerOptions[T] { + const requestMock = buildInitRequestMock(); + + requestMock.getController.mockReturnValue( + buildControllerMock(dependencyProperties), + ); + + TransactionControllerInit(requestMock); + + return transactionControllerClassMock.mock.calls[0][0][option]; + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns controller instance', () => { + const requestMock = buildInitRequestMock(); + expect(TransactionControllerInit(requestMock).controller).toBeInstanceOf( + TransactionController, + ); + }); + + it('retrieves saved gas fees from preferences', () => { + const getSavedGasFees = testConstructorOption('getSavedGasFees', { + state: { + advancedGasFee: { + [CHAIN_ID_MOCK]: { + maxBaseFee: '0x1', + priorityFee: '0x2', + }, + }, + }, + }); + + expect(getSavedGasFees?.(CHAIN_ID_MOCK)).toStrictEqual({ + maxBaseFee: '0x1', + priorityFee: '0x2', + }); + }); + + describe('determines incoming transactions is enabled', () => { + it('when enabled in preferences and onboarding complete', () => { + const incomingTransactionsIsEnabled = testConstructorOption( + 'incomingTransactions', + { + state: { + completedOnboarding: true, + incomingTransactionsPreferences: { + [CHAIN_ID_MOCK]: true, + }, + }, + }, + )?.isEnabled; + + expect(incomingTransactionsIsEnabled?.()).toBe(true); + }); + + it('unless enabled in preferences but onboarding incomplete', () => { + const incomingTransactionsIsEnabled = testConstructorOption( + 'incomingTransactions', + { + state: { + completedOnboarding: false, + incomingTransactionsPreferences: { + [CHAIN_ID_MOCK]: true, + }, + }, + }, + )?.isEnabled; + + expect(incomingTransactionsIsEnabled?.()).toBe(false); + }); + + it('unless disabled in preferences and onboarding complete', () => { + const incomingTransactionsIsEnabled = testConstructorOption( + 'incomingTransactions', + { + state: { + completedOnboarding: true, + incomingTransactionsPreferences: { + [CHAIN_ID_MOCK]: false, + }, + }, + }, + )?.isEnabled; + + expect(incomingTransactionsIsEnabled?.()).toBe(false); + }); + }); + + it('determines if first time interaction enabled using preference', () => { + const isFirstTimeInteractionEnabled = testConstructorOption( + 'isFirstTimeInteractionEnabled', + { + state: { + securityAlertsEnabled: true, + }, + }, + ); + + expect(isFirstTimeInteractionEnabled?.()).toBe(true); + }); + + it('determines if simulation enabled using preference', () => { + const isSimulationEnabled = testConstructorOption('isSimulationEnabled', { + state: { + useTransactionSimulations: true, + }, + }); + + expect(isSimulationEnabled?.()).toBe(true); + }); +}); diff --git a/app/scripts/controller-init/confirmations/transaction-controller-init.ts b/app/scripts/controller-init/confirmations/transaction-controller-init.ts new file mode 100644 index 000000000000..4d1e73b00582 --- /dev/null +++ b/app/scripts/controller-init/confirmations/transaction-controller-init.ts @@ -0,0 +1,321 @@ +import { + CHAIN_IDS, + TransactionController, + TransactionControllerMessenger, + TransactionMeta, +} from '@metamask/transaction-controller'; +import SmartTransactionsController from '@metamask/smart-transactions-controller'; +import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller/dist/types'; +import { Hex } from '@metamask/utils'; +import { + getCurrentChainSupportsSmartTransactions, + getFeatureFlagsByChainId, + getIsSmartTransaction, + getSmartTransactionsPreferenceEnabled, + isHardwareWallet, +} from '../../../../shared/modules/selectors'; +import { + SmartTransactionHookMessenger, + submitSmartTransactionHook, +} from '../../lib/transaction/smart-transactions'; +import { trace } from '../../../../shared/lib/trace'; +///: BEGIN:ONLY_INCLUDE_IF(build-mmi) +import { + afterTransactionSign as afterTransactionSignMMI, + beforeCheckPendingTransaction as beforeCheckPendingTransactionMMI, + beforeTransactionPublish as beforeTransactionPublishMMI, + getAdditionalSignArguments as getAdditionalSignArgumentsMMI, +} from '../../lib/transaction/mmi-hooks'; +///: END:ONLY_INCLUDE_IF +import { + handlePostTransactionBalanceUpdate, + handleTransactionAdded, + handleTransactionApproved, + handleTransactionConfirmed, + handleTransactionDropped, + handleTransactionFailed, + handleTransactionRejected, + handleTransactionSubmitted, + TransactionMetricsRequest, +} from '../../lib/transaction/metrics'; +import { + ControllerInitFunction, + ControllerInitRequest, + ControllerInitResult, +} from '../types'; +import { TransactionControllerInitMessenger } from '../messengers/transaction-controller-messenger'; +import { ControllerFlatState } from '../controller-list'; + +export const TransactionControllerInit: ControllerInitFunction< + TransactionController, + TransactionControllerMessenger, + TransactionControllerInitMessenger +> = (request) => { + const { + controllerMessenger, + initMessenger, + getFlatState, + getGlobalChainId, + getPermittedAccounts, + getTransactionMetricsRequest, + persistedState, + } = request; + + const { + gasFeeController, + keyringController, + networkController, + onboardingController, + preferencesController, + smartTransactionsController, + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + transactionUpdateController, + ///: END:ONLY_INCLUDE_IF + } = getControllers(request); + + const controller: TransactionController = new TransactionController({ + getCurrentNetworkEIP1559Compatibility: () => + // @ts-expect-error Controller type does not support undefined return value + initMessenger.call('NetworkController:getEIP1559Compatibility'), + getCurrentAccountEIP1559Compatibility: async () => true, + // @ts-expect-error Mismatched types + getExternalPendingTransactions: (address) => + getExternalPendingTransactions(smartTransactionsController(), address), + getGasFeeEstimates: (...args) => + gasFeeController().fetchGasFeeEstimates(...args), + getNetworkClientRegistry: (...args) => + networkController().getNetworkClientRegistry(...args), + getNetworkState: () => networkController().state, + // @ts-expect-error Controller type does not support undefined return value + getPermittedAccounts, + // @ts-expect-error Preferences controller uses Record rather than specific type + getSavedGasFees: () => { + const globalChainId = getGlobalChainId(); + return preferencesController().state.advancedGasFee[globalChainId]; + }, + incomingTransactions: { + etherscanApiKeysByChainId: { + // @ts-expect-error Controller does not support undefined values + [CHAIN_IDS.MAINNET]: process.env.ETHERSCAN_API_KEY, + // @ts-expect-error Controller does not support undefined values + [CHAIN_IDS.SEPOLIA]: process.env.ETHERSCAN_API_KEY, + }, + includeTokenTransfers: false, + isEnabled: () => + preferencesController().state.incomingTransactionsPreferences?.[ + // @ts-expect-error PreferencesController incorrectly expects number index + getGlobalChainId() + ] && onboardingController().state.completedOnboarding, + queryEntireHistory: false, + updateTransactions: false, + }, + isFirstTimeInteractionEnabled: () => + preferencesController().state.securityAlertsEnabled, + isSimulationEnabled: () => + preferencesController().state.useTransactionSimulations, + messenger: controllerMessenger, + pendingTransactions: { + isResubmitEnabled: () => { + const uiState = getUIState(getFlatState()); + return !( + getSmartTransactionsPreferenceEnabled(uiState) && + getCurrentChainSupportsSmartTransactions(uiState) + ); + }, + }, + testGasFeeFlows: Boolean(process.env.TEST_GAS_FEE_FLOWS === 'true'), + // @ts-expect-error Controller uses string for names rather than enum + trace, + hooks: { + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + afterSign: (txMeta, signedEthTx) => + afterTransactionSignMMI( + txMeta, + signedEthTx, + transactionUpdateController().addTransactionToWatchList.bind( + transactionUpdateController(), + ), + ), + beforeCheckPendingTransaction: + beforeCheckPendingTransactionMMI.bind(this), + beforePublish: beforeTransactionPublishMMI.bind(this), + getAdditionalSignArguments: getAdditionalSignArgumentsMMI.bind(this), + ///: END:ONLY_INCLUDE_IF + // @ts-expect-error Controller type does not support undefined return value + publish: (transactionMeta, rawTx: Hex) => + publishSmartTransactionHook( + controller, + smartTransactionsController(), + // Init messenger cannot yet be further restricted so is a superset of what is needed + initMessenger as SmartTransactionHookMessenger, + getFlatState(), + transactionMeta, + rawTx, + ), + }, + // @ts-expect-error Keyring controller expects TxData returned but TransactionController expects TypedTransaction + sign: (...args) => keyringController().signTransaction(...args), + state: persistedState.TransactionController, + }); + + addTransactionControllerListeners( + initMessenger, + getTransactionMetricsRequest, + ); + + const api = getApi(controller); + + return { controller, api, memStateKey: 'TxController' }; +}; + +function getApi( + controller: TransactionController, +): ControllerInitResult['api'] { + return { + abortTransactionSigning: + controller.abortTransactionSigning.bind(controller), + getLayer1GasFee: controller.getLayer1GasFee.bind(controller), + getTransactions: controller.getTransactions.bind(controller), + updateEditableParams: controller.updateEditableParams.bind(controller), + updatePreviousGasParams: + controller.updatePreviousGasParams.bind(controller), + updateTransactionGasFees: + controller.updateTransactionGasFees.bind(controller), + updateTransactionSendFlowHistory: + controller.updateTransactionSendFlowHistory.bind(controller), + }; +} + +function getControllers( + request: ControllerInitRequest< + TransactionControllerMessenger, + TransactionControllerInitMessenger + >, +) { + return { + gasFeeController: () => request.getController('GasFeeController'), + keyringController: () => request.getController('KeyringController'), + networkController: () => request.getController('NetworkController'), + onboardingController: () => request.getController('OnboardingController'), + preferencesController: () => request.getController('PreferencesController'), + smartTransactionsController: () => + request.getController('SmartTransactionsController'), + transactionUpdateController: () => + request.getController('TransactionUpdateController'), + }; +} + +function publishSmartTransactionHook( + transactionController: TransactionController, + smartTransactionsController: SmartTransactionsController, + hookControllerMessenger: SmartTransactionHookMessenger, + flatState: ControllerFlatState, + transactionMeta: TransactionMeta, + signedTransactionInHex: Hex, +) { + // UI state is required to support shared selectors to avoid duplicate logic in frontend and backend. + // Ideally all backend logic would instead rely on messenger event / state subscriptions. + const uiState = getUIState(flatState); + + // @ts-expect-error Smart transaction selector types does not match controller state + const isSmartTransaction = getIsSmartTransaction(uiState); + + if (!isSmartTransaction) { + // Will cause TransactionController to publish to the RPC provider as normal. + return { transactionHash: undefined }; + } + + // @ts-expect-error Smart transaction selector types does not match controller state + const featureFlags = getFeatureFlagsByChainId(uiState); + + return submitSmartTransactionHook({ + transactionMeta, + signedTransactionInHex, + transactionController, + smartTransactionsController, + controllerMessenger: hookControllerMessenger, + isSmartTransaction, + isHardwareWallet: isHardwareWallet(uiState), + // @ts-expect-error Smart transaction selector return type does not match FeatureFlags type from hook + featureFlags, + }); +} + +function getExternalPendingTransactions( + smartTransactionsController: SmartTransactionsController, + address: string, +) { + return smartTransactionsController.getTransactions({ + addressFrom: address, + status: SmartTransactionStatuses.PENDING, + }); +} + +function addTransactionControllerListeners( + initMessenger: TransactionControllerInitMessenger, + getTransactionMetricsRequest: () => TransactionMetricsRequest, +) { + const transactionMetricsRequest = getTransactionMetricsRequest(); + + initMessenger.subscribe( + 'TransactionController:postTransactionBalanceUpdated', + handlePostTransactionBalanceUpdate.bind(null, transactionMetricsRequest), + ); + + initMessenger.subscribe( + 'TransactionController:unapprovedTransactionAdded', + (transactionMeta) => + handleTransactionAdded(transactionMetricsRequest, { transactionMeta }), + ); + + initMessenger.subscribe( + 'TransactionController:transactionApproved', + handleTransactionApproved.bind(null, transactionMetricsRequest), + ); + + initMessenger.subscribe( + 'TransactionController:transactionDropped', + handleTransactionDropped.bind(null, transactionMetricsRequest), + ); + + initMessenger.subscribe( + 'TransactionController:transactionConfirmed', + // @ts-expect-error Error is string in metrics code but TransactionError in TransactionMeta type from controller + handleTransactionConfirmed.bind(null, transactionMetricsRequest), + ); + + initMessenger.subscribe( + 'TransactionController:transactionFailed', + handleTransactionFailed.bind(null, transactionMetricsRequest), + ); + + initMessenger.subscribe( + 'TransactionController:transactionNewSwap', + ({ transactionMeta }) => + // TODO: This can be called internally by the TransactionController + // since Swaps Controller registers this action handler + initMessenger.call('SwapsController:setTradeTxId', transactionMeta.id), + ); + + initMessenger.subscribe( + 'TransactionController:transactionNewSwapApproval', + ({ transactionMeta }) => + // TODO: This can be called internally by the TransactionController + // since Swaps Controller registers this action handler + initMessenger.call('SwapsController:setApproveTxId', transactionMeta.id), + ); + + initMessenger.subscribe( + 'TransactionController:transactionRejected', + handleTransactionRejected.bind(null, transactionMetricsRequest), + ); + + initMessenger.subscribe( + 'TransactionController:transactionSubmitted', + handleTransactionSubmitted.bind(null, transactionMetricsRequest), + ); +} + +function getUIState(flatState: ControllerFlatState) { + return { metamask: flatState }; +} diff --git a/app/scripts/controller-init/controller-list.ts b/app/scripts/controller-init/controller-list.ts new file mode 100644 index 000000000000..437083913e68 --- /dev/null +++ b/app/scripts/controller-init/controller-list.ts @@ -0,0 +1,56 @@ +import { GasFeeController } from '@metamask/gas-fee-controller'; +import { KeyringController } from '@metamask/keyring-controller'; +import { NetworkController } from '@metamask/network-controller'; +import { + CaveatSpecificationConstraint, + PermissionController, + PermissionSpecificationConstraint, +} from '@metamask/permission-controller'; +import { PPOMController } from '@metamask/ppom-validator'; +import SmartTransactionsController from '@metamask/smart-transactions-controller'; +import { TransactionController } from '@metamask/transaction-controller'; +import { TransactionUpdateController } from '@metamask-institutional/transaction-update'; +import { AccountsController } from '@metamask/accounts-controller'; +import OnboardingController from '../controllers/onboarding'; +import { PreferencesController } from '../controllers/preferences-controller'; +import SwapsController from '../controllers/swaps'; + +/** + * Union of all controllers supporting or required by modular initialization. + */ +export type Controller = + | GasFeeController + | KeyringController + | NetworkController + | OnboardingController + | PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + > + | PPOMController + | PreferencesController + | SmartTransactionsController + | TransactionController + | (TransactionUpdateController & { + name: 'TransactionUpdateController'; + state: Record; + }); + +/** + * Flat state object for all controllers supporting or required by modular initialization. + * e.g. `{ transactions: [] }`. + */ +export type ControllerFlatState = AccountsController['state'] & + GasFeeController['state'] & + KeyringController['state'] & + NetworkController['state'] & + OnboardingController['state'] & + PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint + >['state'] & + PPOMController['state'] & + PreferencesController['state'] & + SmartTransactionsController['state'] & + TransactionController['state'] & + SwapsController['state']; diff --git a/app/scripts/controller-init/messengers/index.ts b/app/scripts/controller-init/messengers/index.ts new file mode 100644 index 000000000000..fdc3a12c2150 --- /dev/null +++ b/app/scripts/controller-init/messengers/index.ts @@ -0,0 +1,19 @@ +import { + getPPOMControllerMessenger, + getPPOMControllerInitMessenger, +} from './ppom-controller-messenger'; +import { + getTransactionControllerMessenger, + getTransactionControllerInitMessenger, +} from './transaction-controller-messenger'; + +export const CONTROLLER_MESSENGERS = { + PPOMController: { + getMessenger: getPPOMControllerMessenger, + getInitMessenger: getPPOMControllerInitMessenger, + }, + TransactionController: { + getMessenger: getTransactionControllerMessenger, + getInitMessenger: getTransactionControllerInitMessenger, + }, +} as const; diff --git a/app/scripts/controller-init/messengers/ppom-controller-messenger.ts b/app/scripts/controller-init/messengers/ppom-controller-messenger.ts new file mode 100644 index 000000000000..fcfda3677b27 --- /dev/null +++ b/app/scripts/controller-init/messengers/ppom-controller-messenger.ts @@ -0,0 +1,42 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerNetworkDidChangeEvent, + NetworkControllerStateChangeEvent, +} from '@metamask/network-controller'; +import { PPOMControllerMessenger } from '@metamask/ppom-validator'; +import { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; + +type MessengerActions = NetworkControllerGetNetworkClientByIdAction; + +type MessengerEvents = + | NetworkControllerStateChangeEvent + | NetworkControllerNetworkDidChangeEvent + | PreferencesControllerStateChangeEvent; + +export type PPOMControllerInitMessenger = ReturnType< + typeof getPPOMControllerInitMessenger +>; + +export function getPPOMControllerMessenger( + controllerMessenger: ControllerMessenger, +): PPOMControllerMessenger { + return controllerMessenger.getRestricted({ + name: 'PPOMController', + allowedEvents: [ + 'NetworkController:stateChange', + 'NetworkController:networkDidChange', + ], + allowedActions: ['NetworkController:getNetworkClientById'], + }); +} + +export function getPPOMControllerInitMessenger( + controllerMessenger: ControllerMessenger, +) { + return controllerMessenger.getRestricted({ + name: 'PPOMControllerInit', + allowedEvents: ['PreferencesController:stateChange'], + allowedActions: [], + }); +} diff --git a/app/scripts/controller-init/messengers/transaction-controller-messenger.ts b/app/scripts/controller-init/messengers/transaction-controller-messenger.ts new file mode 100644 index 000000000000..445f39b151b1 --- /dev/null +++ b/app/scripts/controller-init/messengers/transaction-controller-messenger.ts @@ -0,0 +1,95 @@ +import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import { ApprovalControllerActions } from '@metamask/approval-controller'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetEIP1559CompatibilityAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerStateChangeEvent, +} from '@metamask/network-controller'; +import { + TransactionControllerMessenger, + TransactionControllerPostTransactionBalanceUpdatedEvent, + TransactionControllerTransactionApprovedEvent, + TransactionControllerTransactionConfirmedEvent, + TransactionControllerTransactionDroppedEvent, + TransactionControllerTransactionFailedEvent, + TransactionControllerTransactionNewSwapApprovalEvent, + TransactionControllerTransactionNewSwapEvent, + TransactionControllerTransactionRejectedEvent, + TransactionControllerTransactionSubmittedEvent, + TransactionControllerUnapprovedTransactionAddedEvent, +} from '@metamask/transaction-controller'; +import { SmartTransactionsControllerSmartTransactionEvent } from '@metamask/smart-transactions-controller'; +import { + SwapsControllerSetApproveTxIdAction, + SwapsControllerSetTradeTxIdAction, +} from '../../controllers/swaps/swaps.types'; + +type MessengerActions = + | ApprovalControllerActions + | AccountsControllerGetSelectedAccountAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetEIP1559CompatibilityAction + | NetworkControllerGetNetworkClientByIdAction + | SwapsControllerSetApproveTxIdAction + | SwapsControllerSetTradeTxIdAction; + +type MessengerEvents = + | TransactionControllerTransactionApprovedEvent + | TransactionControllerTransactionConfirmedEvent + | TransactionControllerTransactionDroppedEvent + | TransactionControllerTransactionFailedEvent + | TransactionControllerTransactionNewSwapApprovalEvent + | TransactionControllerTransactionNewSwapEvent + | TransactionControllerTransactionRejectedEvent + | TransactionControllerTransactionSubmittedEvent + | TransactionControllerPostTransactionBalanceUpdatedEvent + | TransactionControllerUnapprovedTransactionAddedEvent + | NetworkControllerStateChangeEvent + | SmartTransactionsControllerSmartTransactionEvent; + +export type TransactionControllerInitMessenger = ReturnType< + typeof getTransactionControllerInitMessenger +>; + +export function getTransactionControllerMessenger( + controllerMessenger: ControllerMessenger, +): TransactionControllerMessenger { + return controllerMessenger.getRestricted({ + name: 'TransactionController', + allowedActions: [ + 'AccountsController:getSelectedAccount', + `ApprovalController:addRequest`, + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + ], + allowedEvents: [`NetworkController:stateChange`], + }); +} + +export function getTransactionControllerInitMessenger( + controllerMessenger: ControllerMessenger, +) { + return controllerMessenger.getRestricted({ + name: 'TransactionControllerInit', + allowedEvents: [ + 'TransactionController:transactionApproved', + 'TransactionController:transactionConfirmed', + 'TransactionController:transactionDropped', + 'TransactionController:transactionFailed', + 'TransactionController:transactionNewSwapApproval', + 'TransactionController:transactionNewSwap', + 'TransactionController:transactionRejected', + 'TransactionController:transactionSubmitted', + 'TransactionController:postTransactionBalanceUpdated', + 'TransactionController:unapprovedTransactionAdded', + 'SmartTransactionsController:smartTransaction', + ], + allowedActions: [ + 'NetworkController:getEIP1559Compatibility', + 'SwapsController:setApproveTxId', + 'SwapsController:setTradeTxId', + ], + }); +} diff --git a/app/scripts/controller-init/test/utils.ts b/app/scripts/controller-init/test/utils.ts new file mode 100644 index 000000000000..7e917c8f4bbe --- /dev/null +++ b/app/scripts/controller-init/test/utils.ts @@ -0,0 +1,26 @@ +import { + BaseRestrictedControllerMessenger, + ControllerInitRequest, +} from '../types'; + +export const CHAIN_ID_MOCK = '0x123'; + +export function buildControllerInitRequestMock(): jest.Mocked< + Omit< + ControllerInitRequest< + BaseRestrictedControllerMessenger, + BaseRestrictedControllerMessenger + >, + 'controllerMessenger' | 'initMessenger' + > +> { + return { + getController: jest.fn(), + getFlatState: jest.fn(), + getGlobalChainId: jest.fn().mockReturnValue(CHAIN_ID_MOCK), + getPermittedAccounts: jest.fn(), + getProvider: jest.fn(), + getTransactionMetricsRequest: jest.fn(), + persistedState: {}, + }; +} diff --git a/app/scripts/controller-init/types.ts b/app/scripts/controller-init/types.ts new file mode 100644 index 000000000000..cea346db0652 --- /dev/null +++ b/app/scripts/controller-init/types.ts @@ -0,0 +1,167 @@ +import { Provider } from '@metamask/network-controller'; +import { + ActionConstraint, + ControllerMessenger, + EventConstraint, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { Hex } from '@metamask/utils'; +import { TransactionMetricsRequest } from '../lib/transaction/metrics'; +import { Controller, ControllerFlatState } from './controller-list'; + +/** The supported controller names. */ +export type ControllerName = Controller['name']; + +/** All controller types by name. */ +export type ControllerByName = { + [name in ControllerName]: Controller & { name: name }; +}; + +/** + * Persisted state for all controllers. + * e.g. `{ TransactionController: { transactions: [] } }`. + */ +export type ControllerPersistedState = Partial<{ + [name in ControllerName]: Partial; +}>; + +/** Generic controller messenger using base template types. */ +export type BaseControllerMessenger = ControllerMessenger< + ActionConstraint, + EventConstraint +>; + +/** Generic restricted controller messenger using base template types. */ +export type BaseRestrictedControllerMessenger = RestrictedControllerMessenger< + string, + ActionConstraint, + EventConstraint, + string, + string +>; + +/** + * Request to initialize and return a controller instance. + * Includes standard data and methods not coupled to any specific controller. + */ +export type ControllerInitRequest< + ControllerMessengerType extends BaseRestrictedControllerMessenger, + InitMessengerType extends void | BaseRestrictedControllerMessenger = void, +> = { + /** + * Required controller messenger instance. + * Generated using the callback specified in `getControllerMessengerCallback`. + */ + controllerMessenger: ControllerMessengerType; + + /** + * Retrieve a controller instance by name. + * Throws an error if the controller is not yet initialized. + * + * @param name - The name of the controller to retrieve. + */ + getController( + name: Name, + ): ControllerByName[Name]; + + /** + * Retrieve the flat state for all controllers. + * For example: `{ transactions: [] }`. + * + * @deprecated Subscribe to other controller state via the messenger. + */ + getFlatState: () => ControllerFlatState; + + /** + * Retrieve the chain ID of the globally selected network. + * + * @deprecated Will be removed in the future pending multi-chain support. + */ + getGlobalChainId(): Hex; + + /** + * Retrieve the permitted accounts for a given origin. + * + * @param origin - The origin for which to retrieve permitted accounts. + * @param options - Additional options for the request. + * @param options.suppressUnauthorizedError - Whether to not throw if an unauthorized error occurs. Defaults to `true`. + */ + getPermittedAccounts( + origin: string, + options?: { suppressUnauthorizedError?: boolean }, + ): Promise; + + /** + * Retrieve the provider instance for the globally selected network. + * + * @deprecated Will be removed in the future pending multi-chain support. + */ + getProvider: () => Provider; + + /** + * Retrieve a transaction metrics request instance. + * Includes data and callbacks required to generate metrics. + */ + getTransactionMetricsRequest(): TransactionMetricsRequest; + + /** + * The full persisted state for all controllers. + * Includes controller name properties. + * e.g. `{ TransactionController: { transactions: [] } }`. + */ + persistedState: ControllerPersistedState; +} & (InitMessengerType extends BaseRestrictedControllerMessenger + ? { + /** + * Required initialization messenger instance. + * Generated using the callback specified in `getInitMessengerCallback`. + */ + initMessenger: InitMessengerType; + } + : unknown); + +/** + * A single background API method available to the UI. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ControllerApi = (...args: any[]) => unknown; + +/** + * Result of initializing a controller instance. + */ +export type ControllerInitResult = { + /** + * The initialized controller instance. + */ + controller: ControllerType; + + /** + * The background API methods available for the controller. + */ + api?: Record; + + /** + * The key used to store the controller state in the persisted store. + * Defaults to the controller `name` property if `undefined`. + * If `null`, the controller state will not be persisted. + */ + persistedStateKey?: string | null; + + /** + * The key used to store the controller state in the memory-only store. + * Defaults to the controller `name` property if `undefined`. + * If `null`, the controller state will not be synchronized with the UI state. + */ + memStateKey?: string | null; +}; + +/** + * Function to initialize a controller instance and return associated data. + */ +export type ControllerInitFunction< + ControllerType extends Controller, + ControllerMessengerType extends BaseRestrictedControllerMessenger, + InitMessengerType extends void | BaseRestrictedControllerMessenger = void, +> = ( + request: ControllerInitRequest, +) => ControllerInitResult; diff --git a/app/scripts/controller-init/utils.test.ts b/app/scripts/controller-init/utils.test.ts new file mode 100644 index 000000000000..6fcd62e38a45 --- /dev/null +++ b/app/scripts/controller-init/utils.test.ts @@ -0,0 +1,336 @@ +import { PPOMController } from '@metamask/ppom-validator'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { buildControllerInitRequestMock } from './test/utils'; +import { ControllerApi, ControllerName } from './types'; +import { initControllers } from './utils'; + +type InitFunctions = Parameters[0]['initFunctions']; + +const CONTROLLER_NAME_MOCK = 'PPOMController'; +const CONTROLLER_NAME_2_MOCK = 'TransactionController'; + +function buildControllerMock(name?: string) { + return { name: name ?? CONTROLLER_NAME_MOCK } as unknown as PPOMController; +} + +function buildControllerInitResultMock({ + name, + api, + persistedStateKey, + memStateKey, +}: { + name?: string; + api?: Record; + persistedStateKey?: string | null; + memStateKey?: string | null; +} = {}) { + return { + controller: buildControllerMock(name), + api, + persistedStateKey, + memStateKey, + }; +} + +function buildControllerFunctionMock() { + return jest.fn().mockReturnValue(buildControllerInitResultMock()); +} + +function buildInitRequestMock() { + return buildControllerInitRequestMock(); +} + +function buildControllerMessenger() { + return new ControllerMessenger(); +} + +describe('Controller Init Utils', () => { + describe('initControllers', () => { + it('returns controllers by name', () => { + const requestMock = buildInitRequestMock(); + const init1Mock = buildControllerFunctionMock(); + const init2Mock = buildControllerFunctionMock(); + + init2Mock.mockReturnValue( + buildControllerInitResultMock({ name: CONTROLLER_NAME_2_MOCK }), + ); + + const { controllersByName } = initControllers({ + baseControllerMessenger: new ControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: init1Mock, + [CONTROLLER_NAME_2_MOCK]: init2Mock, + }, + initRequest: requestMock, + }); + + expect(controllersByName).toStrictEqual({ + [CONTROLLER_NAME_MOCK]: { name: CONTROLLER_NAME_MOCK }, + [CONTROLLER_NAME_2_MOCK]: { name: CONTROLLER_NAME_2_MOCK }, + }); + }); + + it('invokes with request', () => { + const requestMock = buildControllerInitRequestMock(); + const init1Mock = buildControllerFunctionMock(); + const init2Mock = buildControllerFunctionMock(); + + init2Mock.mockReturnValue( + buildControllerInitResultMock({ name: CONTROLLER_NAME_2_MOCK }), + ); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: init1Mock, + [CONTROLLER_NAME_2_MOCK]: init2Mock, + }, + initRequest: requestMock, + }); + + expect(init1Mock).toHaveBeenCalledTimes(1); + expect(init2Mock).toHaveBeenCalledTimes(1); + }); + + describe('provides getController method', () => { + it('that returns initialized controller', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + }, + initRequest: requestMock, + }); + + const { getController } = initMock.mock.calls[0][0]; + + expect( + getController(CONTROLLER_NAME_MOCK as ControllerName), + ).toStrictEqual({ name: CONTROLLER_NAME_MOCK }); + }); + + it('that throws if controller not found', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + }, + initRequest: requestMock, + }); + + const { getController } = initMock.mock.calls[0][0]; + + expect(() => + getController('InvalidController' as ControllerName), + ).toThrow( + 'Controller requested before it was initialized: InvalidController', + ); + }); + + it('that returns existing controllers', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + existingControllers: [buildControllerMock(CONTROLLER_NAME_2_MOCK)], + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + }, + initRequest: requestMock, + }); + + const { getController } = initMock.mock.calls[0][0]; + + expect( + getController(CONTROLLER_NAME_2_MOCK as ControllerName), + ).toStrictEqual({ name: CONTROLLER_NAME_2_MOCK }); + }); + }); + + it('returns all API methods', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + const init2Mock = buildControllerFunctionMock(); + + initMock.mockReturnValue( + buildControllerInitResultMock({ + api: { test1: jest.fn(), test2: jest.fn() }, + }), + ); + + init2Mock.mockReturnValue( + buildControllerInitResultMock({ api: { test3: jest.fn() } }), + ); + + const { controllerApi } = initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + [CONTROLLER_NAME_2_MOCK]: init2Mock, + }, + initRequest: requestMock, + }); + + expect(controllerApi).toStrictEqual({ + test1: expect.any(Function), + test2: expect.any(Function), + test3: expect.any(Function), + }); + }); + + it('returns all persisted state entries', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + const init2Mock = buildControllerFunctionMock(); + const init3Mock = buildControllerFunctionMock(); + + initMock.mockReturnValue( + buildControllerInitResultMock({ persistedStateKey: 'test1' }), + ); + + init2Mock.mockReturnValue( + buildControllerInitResultMock({ + name: CONTROLLER_NAME_2_MOCK, + persistedStateKey: null, + }), + ); + + init3Mock.mockReturnValue( + buildControllerInitResultMock({ + name: 'TestController3', + persistedStateKey: 'test3', + }), + ); + + const { controllerPersistedState } = initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + [CONTROLLER_NAME_2_MOCK]: init2Mock, + TestController3: init3Mock, + } as InitFunctions, + initRequest: requestMock, + }); + + expect(controllerPersistedState).toStrictEqual({ + test1: { name: CONTROLLER_NAME_MOCK }, + test3: { name: 'TestController3' }, + }); + }); + + it('returns all memory state entries', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + const init2Mock = buildControllerFunctionMock(); + const init3Mock = buildControllerFunctionMock(); + + initMock.mockReturnValue( + buildControllerInitResultMock({ memStateKey: 'test1' }), + ); + + init2Mock.mockReturnValue( + buildControllerInitResultMock({ + name: CONTROLLER_NAME_2_MOCK, + memStateKey: null, + }), + ); + + init3Mock.mockReturnValue( + buildControllerInitResultMock({ + name: 'TestController3', + memStateKey: 'test3', + }), + ); + + const { controllerMemState } = initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + [CONTROLLER_NAME_2_MOCK]: init2Mock, + TestController3: init3Mock, + } as InitFunctions, + initRequest: requestMock, + }); + + expect(controllerMemState).toStrictEqual({ + test1: { name: CONTROLLER_NAME_MOCK }, + test3: { name: 'TestController3' }, + }); + }); + + it('provides controller messenger using callback', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + }, + initRequest: requestMock, + }); + + const { controllerMessenger } = initMock.mock.calls[0][0]; + + expect(controllerMessenger).toBeDefined(); + }); + + it('provides no controller messenger if no callback', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + TestName: initMock, + } as InitFunctions, + initRequest: requestMock, + }); + + const { controllerMessenger } = initMock.mock.calls[0][0]; + + expect(controllerMessenger).toBeUndefined(); + }); + + it('provides initialization messenger using callback', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + [CONTROLLER_NAME_MOCK]: initMock, + }, + initRequest: requestMock, + }); + + const { initMessenger } = initMock.mock.calls[0][0]; + + expect(initMessenger).toBeDefined(); + }); + + it('provides no initialization messenger if no callback', () => { + const requestMock = buildControllerInitRequestMock(); + const initMock = buildControllerFunctionMock(); + + initControllers({ + baseControllerMessenger: buildControllerMessenger(), + initFunctions: { + TestName: initMock, + } as InitFunctions, + initRequest: requestMock, + }); + + const { initMessenger } = initMock.mock.calls[0][0]; + + expect(initMessenger).toBeUndefined(); + }); + }); +}); diff --git a/app/scripts/controller-init/utils.ts b/app/scripts/controller-init/utils.ts new file mode 100644 index 000000000000..1d3a682e2731 --- /dev/null +++ b/app/scripts/controller-init/utils.ts @@ -0,0 +1,181 @@ +import { createProjectLogger } from '@metamask/utils'; +import { + BaseControllerMessenger, + BaseRestrictedControllerMessenger, + ControllerByName, + ControllerInitFunction, + ControllerInitRequest, + ControllerName, +} from './types'; +import { Controller } from './controller-list'; +import { CONTROLLER_MESSENGERS } from './messengers'; + +const log = createProjectLogger('controller-init'); + +/** Result of initializing controllers. */ +export type InitControllersResult = { + /** All API methods exposed by the controllers. */ + controllerApi: Record; + + /** All controllers that provided a memory state key. */ + controllerMemState: Record; + + /** All controllers that provided a persisted state key. */ + controllerPersistedState: Record; + + /** All initialized controllers keyed by name. */ + controllersByName: ControllerByName; +}; + +type BaseControllerInitRequest = ControllerInitRequest< + BaseRestrictedControllerMessenger, + BaseRestrictedControllerMessenger +>; + +type ControllerMessengerCallback = ( + BaseControllerMessenger: BaseControllerMessenger, +) => BaseRestrictedControllerMessenger; + +type ControllersToInitialize = 'PPOMController' | 'TransactionController'; + +type InitFunction = + ControllerInitFunction< + ControllerByName[Name], + ReturnType<(typeof CONTROLLER_MESSENGERS)[Name]['getMessenger']>, + ReturnType<(typeof CONTROLLER_MESSENGERS)[Name]['getInitMessenger']> + >; + +type InitFunctions = Partial<{ + [name in ControllersToInitialize]: InitFunction; +}>; + +/** + * Initialize the controllers according to the provided init objects. + * Each init object can be a function that returns a controller, or a `ControllerInit` instance. + * + * @param options - Options bag. + * @param options.baseControllerMessenger - Unrestricted base controller messenger. + * @param options.existingControllers - All required controllers that have already been initialized. + * @param options.initFunctions - Map of init functions keyed by controller name. + * @param options.initRequest - Base request used to initialize the controllers. + * Excluding the properties that are generated by this function. + * @returns The initialized controllers and associated data. + */ +export function initControllers({ + baseControllerMessenger, + existingControllers = [], + initFunctions, + initRequest, +}: { + baseControllerMessenger: BaseControllerMessenger; + existingControllers?: Controller[]; + initFunctions: InitFunctions; + initRequest: Omit< + BaseControllerInitRequest, + 'controllerMessenger' | 'getController' | 'initMessenger' + >; +}): InitControllersResult { + log('Initializing controllers', Object.keys(initFunctions).length); + + const partialControllersByName = existingControllers.reduce< + Partial + >((acc, controller) => { + // @ts-expect-error: Union too complex. + acc[controller.name] = controller; + return acc; + }, {}); + + const controllerPersistedState: Record = {}; + const controllerMemState: Record = {}; + let controllerApi = {}; + + const getController = ( + name: Name, + ): ControllerByName[Name] => + getControllerOrThrow(partialControllersByName as ControllerByName, name); + + for (const [key, value] of Object.entries(initFunctions)) { + const controllerName = key as ControllersToInitialize; + const initFunction = value as InitFunction; + const messengerCallbacks = CONTROLLER_MESSENGERS[controllerName]; + + const controllerMessengerCallback = + messengerCallbacks?.getMessenger as ControllerMessengerCallback; + + const initMessengerCallback = + messengerCallbacks?.getInitMessenger as ControllerMessengerCallback; + + const controllerMessenger = controllerMessengerCallback?.( + baseControllerMessenger, + ); + + const initMessenger = initMessengerCallback?.(baseControllerMessenger); + + const finalInitRequest: BaseControllerInitRequest = { + ...initRequest, + controllerMessenger, + getController, + initMessenger, + }; + + const result = initFunction(finalInitRequest); + + const { + controller, + persistedStateKey: persistedStateKeyRaw, + memStateKey: memStateKeyRaw, + } = result; + + const api = result.api ?? {}; + + const persistedStateKey = + persistedStateKeyRaw === null + ? undefined + : persistedStateKeyRaw ?? controllerName; + + const memStateKey = + memStateKeyRaw === null ? undefined : memStateKeyRaw ?? controllerName; + + partialControllersByName[controllerName] = controller as Controller & + undefined; + + controllerApi = { + ...controllerApi, + ...api, + }; + + if (persistedStateKey) { + controllerPersistedState[persistedStateKey] = controller; + } + + if (memStateKey) { + controllerMemState[memStateKey] = controller; + } + + log('Initialized controller', controllerName, { + api: Object.keys(api), + persistedStateKey, + memStateKey, + }); + } + + return { + controllerApi, + controllerMemState, + controllerPersistedState, + controllersByName: partialControllersByName as ControllerByName, + }; +} + +function getControllerOrThrow( + controllersByName: ControllerByName, + name: Name, +): ControllerByName[Name] { + const controller = controllersByName[name]; + + if (!controller) { + throw new Error(`Controller requested before it was initialized: ${name}`); + } + + return controller; +} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index cf18d7e882e2..bf9b50104d17 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -118,7 +118,6 @@ import { CustodyController } from '@metamask-institutional/custody-controller'; import { TransactionUpdateController } from '@metamask-institutional/transaction-update'; ///: END:ONLY_INCLUDE_IF import { SignatureController } from '@metamask/signature-controller'; -import { PPOMController } from '@metamask/ppom-validator'; import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; import { @@ -137,7 +136,6 @@ import { import { UserOperationController } from '@metamask/user-operation-controller'; import { - TransactionController, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; @@ -242,11 +240,8 @@ import { isManifestV3 } from '../../shared/modules/mv3.utils'; import { convertNetworkId } from '../../shared/modules/network.utils'; import { getIsSmartTransaction, - isHardwareWallet, getFeatureFlagsByChainId, - getCurrentChainSupportsSmartTransactions, getHardwareWalletType, - getSmartTransactionsPreferenceEnabled, } from '../../shared/modules/selectors'; import { createCaipStream } from '../../shared/modules/caip-stream'; import { BaseUrl } from '../../shared/constants/urls'; @@ -267,25 +262,8 @@ import { ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) handleMMITransactionUpdate, ///: END:ONLY_INCLUDE_IF - handleTransactionAdded, - handleTransactionApproved, - handleTransactionFailed, - handleTransactionConfirmed, - handleTransactionDropped, - handleTransactionRejected, - handleTransactionSubmitted, - handlePostTransactionBalanceUpdate, createTransactionEventFragmentWithTxId, } from './lib/transaction/metrics'; -///: BEGIN:ONLY_INCLUDE_IF(build-mmi) -import { - afterTransactionSign as afterTransactionSignMMI, - beforeCheckPendingTransaction as beforeCheckPendingTransactionMMI, - beforeTransactionPublish as beforeTransactionPublishMMI, - getAdditionalSignArguments as getAdditionalSignArgumentsMMI, -} from './lib/transaction/mmi-hooks'; -///: END:ONLY_INCLUDE_IF -import { submitSmartTransactionHook } from './lib/transaction/smart-transactions'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { keyringSnapPermissionsBuilder } from './lib/snap-keyring/keyring-snaps-permissions'; ///: END:ONLY_INCLUDE_IF @@ -294,7 +272,6 @@ import { SnapsNameProvider } from './lib/SnapsNameProvider'; import { AddressBookPetnamesBridge } from './lib/AddressBookPetnamesBridge'; import { AccountIdentitiesPetnamesBridge } from './lib/AccountIdentitiesPetnamesBridge'; import { createPPOMMiddleware } from './lib/ppom/ppom-middleware'; -import * as PPOMModule from './lib/ppom/ppom'; import { onMessageReceived, checkForMultipleVersionsRunning, @@ -353,7 +330,6 @@ import { import { MetaMetricsDataDeletionController } from './controllers/metametrics-data-deletion/metametrics-data-deletion'; import { DataDeletionService } from './services/data-deletion-service'; import createRPCMethodTrackingMiddleware from './lib/createRPCMethodTrackingMiddleware'; -import { IndexedDBPPOMStorage } from './lib/ppom/indexed-db-backend'; import { updateCurrentLocale } from './translate'; import { TrezorOffscreenBridge } from './lib/offscreen-bridge/trezor-offscreen-bridge'; import { LedgerOffscreenBridge } from './lib/offscreen-bridge/ledger-offscreen-bridge'; @@ -388,6 +364,9 @@ import { sanitizeUIState } from './lib/state-utils'; import BridgeStatusController from './controllers/bridge-status/bridge-status-controller'; import { BRIDGE_STATUS_CONTROLLER_NAME } from './controllers/bridge-status/constants'; import { rejectAllApprovals } from './lib/approval/utils'; +import { TransactionControllerInit } from './controller-init/confirmations/transaction-controller-init'; +import { PPOMControllerInit } from './controller-init/confirmations/ppom-controller-init'; +import { initControllers } from './controller-init/utils'; const { TRIGGER_TYPES } = NotificationServicesController.Constants; export const METAMASK_CONTROLLER_EVENTS = { @@ -956,33 +935,6 @@ export default class MetamaskController extends EventEmitter { stalelistRefreshInterval: process.env.IN_TEST ? 30 * SECOND : undefined, }); - this.ppomController = new PPOMController({ - messenger: this.controllerMessenger.getRestricted({ - name: 'PPOMController', - allowedEvents: [ - 'NetworkController:stateChange', - 'NetworkController:networkDidChange', - ], - allowedActions: ['NetworkController:getNetworkClientById'], - }), - storageBackend: new IndexedDBPPOMStorage('PPOMDB', 1), - provider: this.provider, - ppomProvider: { - PPOM: PPOMModule.PPOM, - ppomInit: () => PPOMModule.default(process.env.PPOM_URI), - }, - state: initState.PPOMController, - chainId: this.#getGlobalChainId(), - securityAlertsEnabled: - this.preferencesController.state.securityAlertsEnabled, - onPreferencesChange: preferencesMessenger.subscribe.bind( - preferencesMessenger, - 'PreferencesController:stateChange', - ), - cdnBaseUrl: process.env.BLOCKAID_FILE_CDN, - blockaidPublicKey: process.env.BLOCKAID_PUBLIC_KEY, - }); - const announcementMessenger = this.controllerMessenger.getRestricted({ name: 'AnnouncementController', }); @@ -1968,92 +1920,6 @@ export default class MetamaskController extends EventEmitter { }), }; - const transactionControllerMessenger = - this.controllerMessenger.getRestricted({ - name: 'TransactionController', - allowedActions: [ - `${this.approvalController.name}:addRequest`, - 'NetworkController:findNetworkClientIdByChainId', - 'NetworkController:getNetworkClientById', - 'AccountsController:getSelectedAccount', - ], - allowedEvents: [`NetworkController:stateChange`], - }); - - this.txController = new TransactionController({ - getCurrentNetworkEIP1559Compatibility: - this.networkController.getEIP1559Compatibility.bind( - this.networkController, - ), - getCurrentAccountEIP1559Compatibility: - this.getCurrentAccountEIP1559Compatibility.bind(this), - getExternalPendingTransactions: - this.getExternalPendingTransactions.bind(this), - getGasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind( - this.gasFeeController, - ), - getNetworkClientRegistry: - this.networkController.getNetworkClientRegistry.bind( - this.networkController, - ), - getNetworkState: () => this.networkController.state, - getPermittedAccounts: this.getPermittedAccounts.bind(this), - getSavedGasFees: () => { - const globalChainId = this.#getGlobalChainId(); - return this.preferencesController.state.advancedGasFee[globalChainId]; - }, - incomingTransactions: { - etherscanApiKeysByChainId: { - [CHAIN_IDS.MAINNET]: process.env.ETHERSCAN_API_KEY, - [CHAIN_IDS.SEPOLIA]: process.env.ETHERSCAN_API_KEY, - }, - includeTokenTransfers: false, - isEnabled: () => - this.preferencesController.state.incomingTransactionsPreferences?.[ - this.#getGlobalChainId() - ] && this.onboardingController.state.completedOnboarding, - queryEntireHistory: false, - updateTransactions: false, - }, - isFirstTimeInteractionEnabled: () => - this.preferencesController.state.securityAlertsEnabled, - isSimulationEnabled: () => - this.preferencesController.state.useTransactionSimulations, - messenger: transactionControllerMessenger, - pendingTransactions: { - isResubmitEnabled: () => { - const state = this._getMetaMaskState(); - return !( - getSmartTransactionsPreferenceEnabled(state) && - getCurrentChainSupportsSmartTransactions(state) - ); - }, - }, - testGasFeeFlows: process.env.TEST_GAS_FEE_FLOWS, - trace, - hooks: { - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - afterSign: (txMeta, signedEthTx) => - afterTransactionSignMMI( - txMeta, - signedEthTx, - this.transactionUpdateController.addTransactionToWatchList.bind( - this.transactionUpdateController, - ), - ), - beforeCheckPendingTransaction: - beforeCheckPendingTransactionMMI.bind(this), - beforePublish: beforeTransactionPublishMMI.bind(this), - getAdditionalSignArguments: getAdditionalSignArgumentsMMI.bind(this), - ///: END:ONLY_INCLUDE_IF - publish: this._publishSmartTransactionHook.bind(this), - }, - sign: (...args) => this.keyringController.signTransaction(...args), - state: initState.TransactionController, - }); - - this._addTransactionControllerListeners(); - this.decryptMessageController = new DecryptMessageController({ getState: this.getState.bind(this), messenger: this.controllerMessenger.getRestricted({ @@ -2158,9 +2024,7 @@ export default class MetamaskController extends EventEmitter { signatureController: this.signatureController, platform: this.platform, extension: this.extension, - getTransactions: this.txController.getTransactions.bind( - this.txController, - ), + getTransactions: (...args) => this.txController.getTransactions(...args), setTxStatusSigned: (id) => this.txController.updateCustodialTransaction(id, { status: TransactionStatus.signed, @@ -2224,9 +2088,8 @@ export default class MetamaskController extends EventEmitter { this.gasFeeController, ), // TODO: Remove once TransactionController exports this action type - getLayer1GasFee: this.txController.getLayer1GasFee.bind( - this.txController, - ), + getLayer1GasFee: (...args) => + this.txController.getLayer1GasFee(...args), trackMetaMetricsEvent: this.metaMetricsController.trackEvent.bind( this.metaMetricsController, ), @@ -2246,9 +2109,7 @@ export default class MetamaskController extends EventEmitter { this.bridgeController = new BridgeController({ messenger: bridgeControllerMessenger, // TODO: Remove once TransactionController exports this action type - getLayer1GasFee: this.txController.getLayer1GasFee.bind( - this.txController, - ), + getLayer1GasFee: (...args) => this.txController.getLayer1GasFee(...args), }); const bridgeStatusControllerMessenger = @@ -2285,19 +2146,16 @@ export default class MetamaskController extends EventEmitter { address, this.#getGlobalNetworkClientId(), ), - confirmExternalTransaction: - this.txController.confirmExternalTransaction.bind(this.txController), + confirmExternalTransaction: (...args) => + this.txController.confirmExternalTransaction(...args), trackMetaMetricsEvent: this.metaMetricsController.trackEvent.bind( this.metaMetricsController, ), state: initState.SmartTransactionsController, messenger: smartTransactionsControllerMessenger, - getTransactions: this.txController.getTransactions.bind( - this.txController, - ), - updateTransaction: this.txController.updateTransaction.bind( - this.txController, - ), + getTransactions: (...args) => this.txController.getTransactions(...args), + updateTransaction: (...args) => + this.txController.updateTransaction(...args), getFeatureFlags: () => { const state = this._getMetaMaskState(); return getFeatureFlagsByChainId(state); @@ -2448,6 +2306,50 @@ export default class MetamaskController extends EventEmitter { }), }); + const existingControllers = [ + this.networkController, + this.preferencesController, + this.gasFeeController, + this.onboardingController, + this.keyringController, + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + this.transactionUpdateController, + ///: END:ONLY_INCLUDE_IF + this.smartTransactionsController, + ]; + + const controllerInitFunctions = { + PPOMController: PPOMControllerInit, + TransactionController: TransactionControllerInit, + }; + + const { + controllerApi, + controllerMemState, + controllerPersistedState, + controllersByName, + } = this.#initControllers({ + existingControllers, + initFunctions: controllerInitFunctions, + initState, + }); + + this.controllerApi = controllerApi; + this.controllerMemState = controllerMemState; + this.controllerPersistedState = controllerPersistedState; + this.controllersByName = controllersByName; + + // Backwards compatibility for existing references + this.ppomController = controllersByName.PPOMController; + this.txController = controllersByName.TransactionController; + + this.controllerMessenger.subscribe( + 'TransactionController:transactionStatusUpdated', + ({ transactionMeta }) => { + this._onFinishedTransaction(transactionMeta); + }, + ); + this.metamaskMiddleware = createMetamaskMiddleware({ static: { eth_syncing: false, @@ -2555,7 +2457,6 @@ export default class MetamaskController extends EventEmitter { BridgeStatusController: this.bridgeStatusController, EnsController: this.ensController, ApprovalController: this.approvalController, - PPOMController: this.ppomController, }; this.store.updateStructure({ @@ -2566,7 +2467,6 @@ export default class MetamaskController extends EventEmitter { ///: BEGIN:ONLY_INCLUDE_IF(build-flask) MultichainTransactionsController: this.multichainTransactionsController, ///: END:ONLY_INCLUDE_IF - TransactionController: this.txController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, MetaMetricsController: this.metaMetricsController, @@ -2603,7 +2503,6 @@ export default class MetamaskController extends EventEmitter { this.institutionalFeaturesController.store, MmiConfigurationController: this.mmiConfigurationController.store, ///: END:ONLY_INCLUDE_IF - PPOMController: this.ppomController, NameController: this.nameController, UserOperationController: this.userOperationController, // Notification Controllers @@ -2614,6 +2513,7 @@ export default class MetamaskController extends EventEmitter { this.notificationServicesPushController, RemoteFeatureFlagController: this.remoteFeatureFlagController, ...resetOnRestartStore, + ...controllerPersistedState, }); this.memStore = new ComposableObservableStore({ @@ -2649,7 +2549,6 @@ export default class MetamaskController extends EventEmitter { NftController: this.nftController, SelectedNetworkController: this.selectedNetworkController, LoggingController: this.loggingController, - TxController: this.txController, MultichainRatesController: this.multichainRatesController, SnapController: this.snapController, CronjobController: this.cronjobController, @@ -2673,6 +2572,7 @@ export default class MetamaskController extends EventEmitter { this.notificationServicesPushController, RemoteFeatureFlagController: this.remoteFeatureFlagController, ...resetOnRestartStore, + ...controllerMemState, }, controllerMessenger: this.controllerMessenger, }); @@ -3915,21 +3815,6 @@ export default class MetamaskController extends EventEmitter { null, this.getTransactionMetricsRequest(), ), - getTransactions: this.txController.getTransactions.bind( - this.txController, - ), - updateEditableParams: this.txController.updateEditableParams.bind( - this.txController, - ), - updateTransactionGasFees: - txController.updateTransactionGasFees.bind(txController), - updateTransactionSendFlowHistory: - txController.updateTransactionSendFlowHistory.bind(txController), - updatePreviousGasParams: - txController.updatePreviousGasParams.bind(txController), - abortTransactionSigning: - txController.abortTransactionSigning.bind(txController), - getLayer1GasFee: txController.getLayer1GasFee.bind(txController), // decryptMessageController decryptMessage: this.decryptMessageController.decryptMessage.bind( @@ -5997,6 +5882,7 @@ export default class MetamaskController extends EventEmitter { const api = { ...this.getApi(), + ...this.controllerApi, startPatches: () => { uiReady = true; handleUpdate(); @@ -7030,83 +6916,6 @@ export default class MetamaskController extends EventEmitter { }); } - /** - * A method for setting TransactionController event listeners - */ - _addTransactionControllerListeners() { - const transactionMetricsRequest = this.getTransactionMetricsRequest(); - - this.controllerMessenger.subscribe( - 'TransactionController:postTransactionBalanceUpdated', - handlePostTransactionBalanceUpdate.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:unapprovedTransactionAdded', - (transactionMeta) => - handleTransactionAdded(transactionMetricsRequest, { transactionMeta }), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionApproved', - handleTransactionApproved.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionDropped', - handleTransactionDropped.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionConfirmed', - handleTransactionConfirmed.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionFailed', - handleTransactionFailed.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionNewSwap', - ({ transactionMeta }) => - // TODO: This can be called internally by the TransactionController - // since Swaps Controller registers this action handler - this.controllerMessenger.call( - 'SwapsController:setTradeTxId', - transactionMeta.id, - ), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionNewSwapApproval', - ({ transactionMeta }) => - // TODO: This can be called internally by the TransactionController - // since Swaps Controller registers this action handler - this.controllerMessenger.call( - 'SwapsController:setApproveTxId', - transactionMeta.id, - ), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionRejected', - handleTransactionRejected.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionSubmitted', - handleTransactionSubmitted.bind(null, transactionMetricsRequest), - ); - - this.controllerMessenger.subscribe( - 'TransactionController:transactionStatusUpdated', - ({ transactionMeta }) => { - this._onFinishedTransaction(transactionMeta); - }, - ); - } - getTransactionMetricsRequest() { const controllerActions = { // Metametrics Actions @@ -7497,6 +7306,11 @@ export default class MetamaskController extends EventEmitter { }); } + /** + * @deprecated + * Controllers should subscribe to messenger events internally rather than relying on the client. + * @param transactionMeta - Metadata for the transaction. + */ async _onFinishedTransaction(transactionMeta) { if ( ![TransactionStatus.confirmed, TransactionStatus.failed].includes( @@ -7772,26 +7586,6 @@ export default class MetamaskController extends EventEmitter { ); } - _publishSmartTransactionHook(transactionMeta, signedTransactionInHex) { - const state = this._getMetaMaskState(); - const isSmartTransaction = getIsSmartTransaction(state); - if (!isSmartTransaction) { - // Will cause TransactionController to publish to the RPC provider as normal. - return { transactionHash: undefined }; - } - const featureFlags = getFeatureFlagsByChainId(state); - return submitSmartTransactionHook({ - transactionMeta, - signedTransactionInHex, - transactionController: this.txController, - smartTransactionsController: this.smartTransactionsController, - controllerMessenger: this.controllerMessenger, - isSmartTransaction, - isHardwareWallet: isHardwareWallet(state), - featureFlags, - }); - } - _getMetaMaskState() { return { metamask: this.getState(), @@ -7917,4 +7711,24 @@ export default class MetamaskController extends EventEmitter { #getGlobalNetworkClientId() { return this.networkController.state.selectedNetworkClientId; } + + #initControllers({ existingControllers, initFunctions, initState }) { + const initRequest = { + getFlatState: this.getState.bind(this), + getGlobalChainId: this.#getGlobalChainId.bind(this), + getPermittedAccounts: this.getPermittedAccounts.bind(this), + getProvider: () => this.provider, + getStateUI: this._getMetaMaskState.bind(this), + getTransactionMetricsRequest: + this.getTransactionMetricsRequest.bind(this), + persistedState: initState, + }; + + return initControllers({ + baseControllerMessenger: this.controllerMessenger, + existingControllers, + initFunctions, + initRequest, + }); + } }