From 7262f9acd6c1f394af4563a6ebca11789d34ec8b Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 07:38:23 +0700 Subject: [PATCH 1/7] feat(wallet): integrate ShieldController into wallet initialization --- .github/CODEOWNERS | 1 + README.md | 1 + codeowners.ts | 1 + packages/wallet-cli/CHANGELOG.md | 1 + .../src/daemon/wallet-factory.test.ts | 6 + .../wallet-cli/src/daemon/wallet-factory.ts | 7 + packages/wallet/CHANGELOG.md | 5 + packages/wallet/package.json | 1 + packages/wallet/src/Wallet.test.ts | 142 +++---- .../src/initialization/instances/index.ts | 1 + .../shield-controller.test.ts | 366 ++++++++++++++++++ .../shield-controller/shield-controller.ts | 88 +++++ .../instances/shield-controller/types.ts | 45 +++ .../transaction-controller.test.ts | 10 + packages/wallet/src/types.ts | 2 + packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 3 + yarn.lock | 3 +- 18 files changed, 594 insertions(+), 90 deletions(-) create mode 100644 packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts create mode 100644 packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts create mode 100644 packages/wallet/src/initialization/instances/shield-controller/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f7d8e29ec70..be1c9e9e961 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -154,6 +154,7 @@ /packages/wallet/src/initialization/instances/passkey-controller/ @MetaMask/web3auth /packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/wallet/src/initialization/instances/seedless-onboarding-controller/ @MetaMask/web3auth +/packages/wallet/src/initialization/instances/shield-controller/ @MetaMask/web3auth /packages/wallet/src/initialization/instances/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/wallet/src/initialization/instances/transaction-controller/ @MetaMask/confirmations diff --git a/README.md b/README.md index ec3d61dc7b9..c97bc44fb8c 100644 --- a/README.md +++ b/README.md @@ -655,6 +655,7 @@ linkStyle default opacity:0.5 wallet --> passkey_controller; wallet --> remote_feature_flag_controller; wallet --> seedless_onboarding_controller; + wallet --> shield_controller; wallet --> storage_service; wallet --> transaction_controller; wallet_cli --> base_controller; diff --git a/codeowners.ts b/codeowners.ts index ce77d6b6877..e5439aefe15 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -309,6 +309,7 @@ const PACKAGES: Record = { }, 'shield-controller': { teams: ['@MetaMask/web3auth'], + initializationPath: 'shield-controller', }, 'signature-controller': { teams: ['@MetaMask/confirmations'], diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 9e83315b448..be0a2c4d070 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Wire the `shieldController` slot in the daemon wallet's instance options with the production rule-engine base URL and `fetch`, so the daemon constructs `ShieldController` with explicit host configuration rather than relying on implicit defaults - Wire the `transactionController` slot in the daemon wallet's instance options, so the daemon runs the `TransactionController` with an explicit CLI-appropriate configuration (swaps processing disabled, no client hooks) rather than relying on the controller's implicit defaults ([#9509](https://github.com/MetaMask/core/pull/9509)) - Add the `mm wallet unlock` command, which dispatches `KeyringController:submitPassword` over the daemon socket, allowing the keyring to be unlocked after a daemon start with no password or after a `mm daemon call KeyringController:setLocked` ([#8821](https://github.com/MetaMask/core/pull/8821)) - Add the `mm daemon list` command, which prints the messenger actions the running daemon can dispatch via `daemon call`, enumerated from the live messenger so the list cannot drift from what `call` accepts ([#9339](https://github.com/MetaMask/core/pull/9339)) diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index de29cebdd7c..783996589c9 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -120,6 +120,12 @@ describe('createWallet', () => { ); expect(instanceOptions.transactionController?.disableSwaps).toBe(true); expect(instanceOptions.transactionController?.hooks).toStrictEqual({}); + expect(instanceOptions.shieldController.baseUrl).toBe( + 'https://rule-engine.metamask.io', + ); + expect(instanceOptions.shieldController.fetchFunction).toBe( + globalThis.fetch, + ); expect(ClientConfigApiService).toHaveBeenCalled(); await dispose(); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index f281c74f230..453c1ef6e9f 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -65,6 +65,9 @@ export type CreateWalletResult = { * - `transactionController` — swaps processing disabled and no client hooks; * see the slot's inline comment for why the daemon relies on the * controller's defaults for everything else. + * - `shieldController` — production rule-engine base URL and `fetch`; the daemon + * does not register `AuthenticationController` or `SignatureController`, so + * hosts must call `ShieldController:start` only after wiring those peers. * * The optional `keyringController` slot is intentionally omitted so the * controller's built-in defaults (e.g. the PBKDF2 encryptor) apply. @@ -116,6 +119,10 @@ function buildInstanceOptions( // the controller's default. hooks: {}, }, + shieldController: { + baseUrl: 'https://ruleset-engine.api.cx.metamask.io', + fetchFunction: globalThis.fetch, + }, }; } diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index c5dbf9d4b43..e77f9f82b43 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **BREAKING:** Wire `ShieldController` into the default wallet initialization + - Adds required `shieldController` slot to `instanceOptions` with `baseUrl` and `fetchFunction` (or an injected `backend` override) + - Default backend construction uses `ShieldRemoteBackend` with optional `getAccessToken`, `captureException`, polling, history limits, and `normalizeSignatureRequest` + - Delegates `AuthenticationController:getBearerToken` plus `TransactionController:stateChange` and `SignatureController:stateChange` on the shared messenger bus + - Hosts must register `AuthenticationController` and `SignatureController` on the wallet root messenger and explicitly call `ShieldController:start` after wiring - **BREAKING** Wire `SeedlessOnboardingController` and `PasskeyController` into the default wallet initialization ([#9533](https://github.com/MetaMask/core/pull/9533)) ### Changed diff --git a/packages/wallet/package.json b/packages/wallet/package.json index f7c0f359710..d8e1af3e502 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -69,6 +69,7 @@ "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/scure-bip39": "^2.1.1", "@metamask/seedless-onboarding-controller": "^10.0.3", + "@metamask/shield-controller": "^5.1.3", "@metamask/storage-service": "^1.0.2", "@metamask/transaction-controller": "^69.2.1", "@metamask/utils": "^11.11.0" diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 75dac034e92..b3bdc079f4b 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -1,6 +1,10 @@ import { getDefaultAddressBookControllerState } from '@metamask/address-book-controller'; import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import { Messenger } from '@metamask/messenger'; +import { + getDefaultShieldControllerState, + ShieldController, +} from '@metamask/shield-controller'; import { InMemoryStorageAdapter } from '@metamask/storage-service'; import { Json } from '@metamask/utils'; import { webcrypto } from 'crypto'; @@ -8,6 +12,7 @@ import { webcrypto } from 'crypto'; import MockEncryptor from '../../keyring-controller/tests/mocks/mockEncryptor.js'; import * as initializationModule from './initialization/initialization.js'; import { AlwaysOnlineAdapter } from './initialization/instances/connectivity-controller/always-online-adapter.js'; +import type { WalletOptions } from './types.js'; import { importSecretRecoveryPhrase } from './utilities.js'; import { Wallet } from './Wallet.js'; @@ -23,20 +28,36 @@ const REMOTE_FEATURE_FLAG_OPTIONS = { }, }; +const SHIELD_CONTROLLER_OPTIONS = { + baseUrl: 'https://rule-engine.metamask.io', + fetchFunction: jest.fn(), + backend: { + checkCoverage: jest.fn(), + checkSignatureCoverage: jest.fn(), + logSignature: jest.fn(), + logTransaction: jest.fn(), + }, +}; + +function getInstanceOptions(): WalletOptions['instanceOptions'] { + return { + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + networkController: { + infuraProjectId: 'fake-infura-project-id', + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + shieldController: SHIELD_CONTROLLER_OPTIONS, + }; +} + async function setupWallet(): Promise { const wallet = new Wallet({ - instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - }, + instanceOptions: getInstanceOptions(), }); await importSecretRecoveryPhrase(wallet, TEST_PASSWORD, TEST_SRP); @@ -86,19 +107,10 @@ describe('Wallet', () => { it('supports passing instance options', async () => { const wallet = new Wallet({ instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, + ...getInstanceOptions(), keyringController: { encryptor: new MockEncryptor(), }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, }, }); @@ -137,18 +149,7 @@ describe('Wallet', () => { init: (): DummyService => new DummyService(), }, ], - instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - }, + instanceOptions: getInstanceOptions(), }); const { state } = wallet; @@ -180,18 +181,7 @@ describe('Wallet', () => { }); const wallet = new Wallet({ - instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - }, + instanceOptions: getInstanceOptions(), }); expect(wallet.controllerMetadata).toStrictEqual({ @@ -290,18 +280,7 @@ describe('Wallet', () => { addressBook: { '0x1': { [ADDRESS]: entry } }, }, }, - instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - }, + instanceOptions: getInstanceOptions(), }); expect( @@ -322,18 +301,7 @@ describe('Wallet', () => { describe('ConnectivityController', () => { it('reports online connectivity status', () => { const wallet = new Wallet({ - instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - }, + instanceOptions: getInstanceOptions(), }); expect(wallet.state.ConnectivityController.connectivityStatus).toBe( @@ -362,18 +330,7 @@ describe('Wallet', () => { vault, }, }, - instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, - storageService: { - storage: new InMemoryStorageAdapter(), - }, - remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - }, + instanceOptions: getInstanceOptions(), }); await wallet.messenger.call( @@ -433,6 +390,19 @@ describe('Wallet', () => { }); }); + describe('ShieldController', () => { + it('is wired and exposes its state on the wallet messenger', async () => { + const wallet = await setupWallet(); + + expect(wallet.getInstance('ShieldController')).toBeInstanceOf( + ShieldController, + ); + expect(wallet.messenger.call('ShieldController:getState')).toStrictEqual( + getDefaultShieldControllerState(), + ); + }); + }); + describe('RemoteFeatureFlagController', () => { it('is wired and exposes its state on the wallet messenger', async () => { const wallet = await setupWallet(); @@ -451,14 +421,8 @@ describe('Wallet', () => { it('routes injected instanceOptions through to the controller', async () => { const wallet = new Wallet({ instanceOptions: { - connectivityController: { - connectivityAdapter: new AlwaysOnlineAdapter(), - }, - networkController: { - infuraProjectId: 'fake-infura-project-id', - }, + ...getInstanceOptions(), keyringController: { encryptor: new MockEncryptor() }, - storageService: { storage: new InMemoryStorageAdapter() }, remoteFeatureFlagController: { clientConfigApiService: { fetchRemoteFeatureFlags: async (): Promise<{ diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 858e36e9769..02f7a0e2dfe 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -7,5 +7,6 @@ export { networkController } from './network-controller/network-controller.js'; export { passkeyController } from './passkey-controller/passkey-controller.js'; export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller.js'; export { seedlessOnboardingController } from './seedless-onboarding-controller/seedless-onboarding-controller.js'; +export { shieldController } from './shield-controller/shield-controller.js'; export { storageService } from './storage-service/storage-service.js'; export { transactionController } from './transaction-controller/transaction-controller.js'; diff --git a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts new file mode 100644 index 00000000000..35104910f23 --- /dev/null +++ b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts @@ -0,0 +1,366 @@ +import { Messenger } from '@metamask/messenger'; +import { + getDefaultShieldControllerState, + ShieldController, +} from '@metamask/shield-controller'; +import type { TransactionControllerState } from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import { defaultConfigurations } from '../../defaults.js'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import type { ShieldBackend } from './types.js'; +import { shieldController } from './shield-controller.js'; + +const MOCK_COVERAGE_ID = 'coverage-id-1'; +const SHIELD_BASE_URL = 'https://rule-engine.metamask.io'; + +type ActionHandler = (...args: unknown[]) => unknown; + +type AnyMessenger = Messenger; + +const SHIELD_OPTIONS = { + baseUrl: SHIELD_BASE_URL, + fetchFunction: globalThis.fetch, +}; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +function registerActionHandler( + parent: RootMessenger, + namespace: string, + actionType: string, + handler: ActionHandler, +): void { + const messenger = new Messenger({ + namespace, + parent: parent as unknown as AnyMessenger, + }); + + ( + messenger as unknown as { + registerActionHandler(type: string, handler: ActionHandler): void; + } + ).registerActionHandler(actionType, handler); +} + +function createMockBackend(): jest.Mocked { + return { + checkCoverage: jest.fn().mockResolvedValue({ + coverageId: MOCK_COVERAGE_ID, + status: 'covered', + metrics: {}, + }), + checkSignatureCoverage: jest.fn().mockResolvedValue({ + coverageId: MOCK_COVERAGE_ID, + status: 'covered', + metrics: {}, + }), + logSignature: jest.fn(), + logTransaction: jest.fn(), + }; +} + +function createMockSignatureRequest(): Parameters< + ShieldController['checkSignatureCoverage'] +>[0] { + return { + chainId: '0x1', + id: 'signature-request-1', + type: 'personal_sign', + messageParams: { + data: '0x00', + from: '0x0000000000000000000000000000000000000000', + }, + networkClientId: 'mainnet', + status: 'unapproved', + time: Date.now(), + }; +} + +describe('shieldController', () => { + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain(shieldController); + }); + + it('initializes a ShieldController with default state', () => { + const messenger = shieldController.getMessenger(getRootMessenger()); + + const instance = shieldController.init({ + state: undefined, + messenger, + options: SHIELD_OPTIONS, + }); + + expect(instance).toBeInstanceOf(ShieldController); + expect(instance.state).toStrictEqual(getDefaultShieldControllerState()); + }); + + it('forwards the provided state to the controller', () => { + const messenger = shieldController.getMessenger(getRootMessenger()); + + const instance = shieldController.init({ + state: { + orderedTransactionHistory: ['tx-1'], + }, + messenger, + options: SHIELD_OPTIONS, + }); + + expect(instance.state.orderedTransactionHistory).toStrictEqual(['tx-1']); + }); + + it('uses a provided backend override', () => { + const messenger = shieldController.getMessenger(getRootMessenger()); + const mockBackend = createMockBackend(); + + const instance = shieldController.init({ + state: undefined, + messenger, + options: { + ...SHIELD_OPTIONS, + backend: mockBackend, + }, + }); + + expect(instance).toBeInstanceOf(ShieldController); + }); + + it('forwards transactionHistoryLimit and coverageHistoryLimit', () => { + const messenger = shieldController.getMessenger(getRootMessenger()); + const mockBackend = createMockBackend(); + + const instance = shieldController.init({ + state: undefined, + messenger, + options: { + ...SHIELD_OPTIONS, + backend: mockBackend, + transactionHistoryLimit: 5, + coverageHistoryLimit: 2, + }, + }); + + expect(instance).toBeInstanceOf(ShieldController); + }); + + it('forwards normalizeSignatureRequest to the controller', async () => { + const rootMessenger = getRootMessenger(); + const messenger = shieldController.getMessenger(rootMessenger); + const mockBackend = createMockBackend(); + const signatureRequest = createMockSignatureRequest(); + const normalizedSignatureRequest = { + ...signatureRequest, + messageParams: { + ...signatureRequest.messageParams, + data: 'normalized data', + }, + }; + const normalizeSignatureRequest = jest + .fn() + .mockReturnValue(normalizedSignatureRequest); + + const instance = shieldController.init({ + state: undefined, + messenger, + options: { + ...SHIELD_OPTIONS, + backend: mockBackend, + normalizeSignatureRequest, + }, + }); + + await instance.checkSignatureCoverage(signatureRequest); + + expect(normalizeSignatureRequest).toHaveBeenCalledWith(signatureRequest); + expect(mockBackend.checkSignatureCoverage).toHaveBeenCalledWith({ + signatureRequest: normalizedSignatureRequest, + }); + }); + + it('wires default getAccessToken to AuthenticationController:getBearerToken', async () => { + const rootMessenger = getRootMessenger(); + registerActionHandler( + rootMessenger, + 'AuthenticationController', + 'AuthenticationController:getBearerToken', + async () => 'test-bearer-token', + ); + const messenger = shieldController.getMessenger(rootMessenger); + const fetchFunction = jest.fn(async () => { + const callCount = fetchFunction.mock.calls.length; + if (callCount === 1) { + return new globalThis.Response( + JSON.stringify({ coverageId: MOCK_COVERAGE_ID }), + { status: 200 }, + ); + } + + return new globalThis.Response( + JSON.stringify({ + status: 'covered', + metrics: {}, + }), + { status: 200 }, + ); + }); + + const instance = shieldController.init({ + state: undefined, + messenger, + options: { + baseUrl: SHIELD_BASE_URL, + fetchFunction, + }, + }); + + await instance.checkCoverage({ + id: 'tx-1', + chainId: '0x1', + status: TransactionStatus.Unapproved, + time: Date.now(), + txParams: { + from: '0x0000000000000000000000000000000000000000', + }, + } as never); + + expect(fetchFunction).toHaveBeenCalled(); + const firstCall = fetchFunction.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + const [, requestInit] = firstCall; + const headers = new globalThis.Headers(requestInit.headers); + expect(headers.get('Authorization')).toBe('Bearer test-bearer-token'); + }); + + it('uses a provided getAccessToken override', async () => { + const rootMessenger = getRootMessenger(); + const messenger = shieldController.getMessenger(rootMessenger); + const fetchFunction = jest.fn(async () => { + const callCount = fetchFunction.mock.calls.length; + if (callCount === 1) { + return new globalThis.Response( + JSON.stringify({ coverageId: MOCK_COVERAGE_ID }), + { status: 200 }, + ); + } + + return new globalThis.Response( + JSON.stringify({ + status: 'covered', + metrics: {}, + }), + { status: 200 }, + ); + }); + const getAccessToken = jest.fn().mockResolvedValue('override-token'); + + const instance = shieldController.init({ + state: undefined, + messenger, + options: { + baseUrl: SHIELD_BASE_URL, + fetchFunction, + getAccessToken, + }, + }); + + await instance.checkCoverage({ + id: 'tx-1', + chainId: '0x1', + status: TransactionStatus.Unapproved, + time: Date.now(), + txParams: { + from: '0x0000000000000000000000000000000000000000', + }, + } as never); + + expect(getAccessToken).toHaveBeenCalled(); + const firstCall = fetchFunction.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + const [, requestInit] = firstCall; + const headers = new globalThis.Headers(requestInit.headers); + expect(headers.get('Authorization')).toBe('Bearer override-token'); + }); + + it('delegates AuthenticationController:getBearerToken and controller state-change events', () => { + const parent = getRootMessenger(); + const delegateSpy = jest.spyOn(parent, 'delegate'); + const messenger = shieldController.getMessenger(parent); + + expect(delegateSpy).toHaveBeenCalledWith({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + events: [ + 'TransactionController:stateChange', + 'SignatureController:stateChange', + ], + }); + }); + + it('exposes its actions through the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = shieldController.getMessenger(rootMessenger); + + shieldController.init({ + state: undefined, + messenger, + options: { + ...SHIELD_OPTIONS, + backend: createMockBackend(), + }, + }); + + expect(rootMessenger.call('ShieldController:getState')).toStrictEqual( + getDefaultShieldControllerState(), + ); + }); + + it('does not auto-start on initialization', () => { + const rootMessenger = getRootMessenger(); + const messenger = shieldController.getMessenger(rootMessenger); + const mockBackend = createMockBackend(); + + shieldController.init({ + state: undefined, + messenger, + options: { + ...SHIELD_OPTIONS, + backend: mockBackend, + }, + }); + + const transactionMessenger = new Messenger({ + namespace: 'TransactionController', + parent: rootMessenger as unknown as AnyMessenger, + }); + + transactionMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [ + { + id: 'tx-1', + chainId: '0x1', + status: TransactionStatus.Unapproved, + time: Date.now(), + txParams: { + from: '0x0000000000000000000000000000000000000000', + }, + }, + ], + } as TransactionControllerState, + undefined as never, + ); + + expect(mockBackend.checkCoverage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts new file mode 100644 index 00000000000..58d606c1121 --- /dev/null +++ b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.ts @@ -0,0 +1,88 @@ +import { Messenger } from '@metamask/messenger'; +import type { ShieldControllerMessenger } from '@metamask/shield-controller'; +import { + ShieldController, + ShieldRemoteBackend, +} from '@metamask/shield-controller'; +import type { ShieldControllerState } from '@metamask/shield-controller'; + +import type { InitializationConfiguration } from '../../types.js'; +import type { + ShieldBackend, + ShieldControllerInitializationMessenger, + ShieldControllerInstanceOptions, +} from './types.js'; + +export type { + ShieldControllerInitializationMessenger, + ShieldControllerInstanceOptions, +} from './types.js'; + +function resolveShieldBackend( + messenger: ShieldControllerInitializationMessenger, + options: ShieldControllerInstanceOptions, +): ShieldBackend { + if (options.backend) { + return options.backend; + } + + const getAccessToken = + options.getAccessToken ?? + ((): Promise => + messenger.call('AuthenticationController:getBearerToken')); + + return new ShieldRemoteBackend({ + baseUrl: options.baseUrl, + fetch: options.fetchFunction, + getAccessToken, + captureException: options.captureException, + getCoverageResultTimeout: options.getCoverageResultTimeout, + getCoverageResultPollInterval: options.getCoverageResultPollInterval, + }); +} + +export const shieldController: InitializationConfiguration< + ShieldController, + ShieldControllerInitializationMessenger +> = { + name: 'ShieldController', + init: ({ + state, + messenger, + options, + }: { + state: Partial | undefined; + messenger: ShieldControllerInitializationMessenger; + options: ShieldControllerInstanceOptions; + }) => { + return new ShieldController({ + messenger: messenger as unknown as ShieldControllerMessenger, + state, + backend: resolveShieldBackend(messenger, options), + transactionHistoryLimit: options.transactionHistoryLimit, + coverageHistoryLimit: options.coverageHistoryLimit, + normalizeSignatureRequest: options.normalizeSignatureRequest, + }); + }, + getMessenger: (parent) => { + const messenger: ShieldControllerInitializationMessenger = new Messenger({ + namespace: 'ShieldController', + parent, + }); + + parent.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + events: [ + // ShieldController subscribes to :stateChange internally; the + // delegation must match until those controllers migrate to :stateChanged. + // eslint-disable-next-line no-restricted-syntax + 'TransactionController:stateChange', + // eslint-disable-next-line no-restricted-syntax + 'SignatureController:stateChange', + ], + }); + + return messenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/shield-controller/types.ts b/packages/wallet/src/initialization/instances/shield-controller/types.ts new file mode 100644 index 00000000000..cdf70454288 --- /dev/null +++ b/packages/wallet/src/initialization/instances/shield-controller/types.ts @@ -0,0 +1,45 @@ +import type { Messenger } from '@metamask/messenger'; +import type { + NormalizeSignatureRequestFn, + ShieldControllerActions, + ShieldControllerEvents, + ShieldRemoteBackend, +} from '@metamask/shield-controller'; +import type { SignatureStateChange } from '@metamask/signature-controller'; +import type { TransactionControllerStateChangeEvent } from '@metamask/transaction-controller'; + +export type ShieldBackend = Pick< + ShieldRemoteBackend, + 'checkCoverage' | 'checkSignatureCoverage' | 'logSignature' | 'logTransaction' +>; + +type AuthenticationControllerGetBearerTokenAction = { + type: 'AuthenticationController:getBearerToken'; + handler: (entropySourceId?: string) => Promise; +}; + +export type ShieldControllerInitializationMessenger = Messenger< + 'ShieldController', + ShieldControllerActions | AuthenticationControllerGetBearerTokenAction, + | ShieldControllerEvents + | SignatureStateChange + | TransactionControllerStateChangeEvent +>; + +export type ShieldControllerInstanceOptions = { + /** + * When set, used as-is; `baseUrl`, `fetchFunction`, `getAccessToken`, and + * `captureException` are ignored for backend construction. + */ + backend?: ShieldBackend; + /** Required when building the default `ShieldRemoteBackend`. */ + baseUrl: string; + fetchFunction: typeof fetch; + getAccessToken?: () => Promise; + captureException?: (error: Error) => void; + getCoverageResultTimeout?: number; + getCoverageResultPollInterval?: number; + transactionHistoryLimit?: number; + coverageHistoryLimit?: number; + normalizeSignatureRequest?: NormalizeSignatureRequestFn; +}; diff --git a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts index 4870eca7c18..2d04e432675 100644 --- a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts +++ b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts @@ -122,6 +122,16 @@ function getInstanceOptions(): WalletOptions['instanceOptions'] { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + shieldController: { + baseUrl: 'https://rule-engine.metamask.io', + fetchFunction: jest.fn(), + backend: { + checkCoverage: jest.fn(), + checkSignatureCoverage: jest.fn(), + logSignature: jest.fn(), + logTransaction: jest.fn(), + }, + }, }; } diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 46bb9c5993c..02fad0a019a 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -12,6 +12,7 @@ import type { NetworkControllerInstanceOptions } from './initialization/instance import type { PasskeyControllerInstanceOptions } from './initialization/instances/passkey-controller/types.js'; import type { RemoteFeatureFlagControllerInstanceOptions } from './initialization/instances/remote-feature-flag-controller/types.js'; import type { SeedlessOnboardingControllerInstanceOptions } from './initialization/instances/seedless-onboarding-controller/types.js'; +import type { ShieldControllerInstanceOptions } from './initialization/instances/shield-controller/types.js'; import type { StorageServiceInstanceOptions } from './initialization/instances/storage-service/types.js'; import type { TransactionControllerInstanceOptions } from './initialization/instances/transaction-controller/types.js'; import type { InitializationConfiguration } from './initialization/types.js'; @@ -36,4 +37,5 @@ export type InstanceSpecificOptions = { transactionController?: TransactionControllerInstanceOptions; passkeyController?: PasskeyControllerInstanceOptions; seedlessOnboardingController?: SeedlessOnboardingControllerInstanceOptions; + shieldController: ShieldControllerInstanceOptions; }; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index d4caa50ec1c..cfeef50f9ce 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -18,6 +18,7 @@ { "path": "../passkey-controller/tsconfig.build.json" }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { "path": "../seedless-onboarding-controller/tsconfig.build.json" }, + { "path": "../shield-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" } ], diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 62f5137fc5b..0d0ae85a473 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -40,6 +40,9 @@ { "path": "../seedless-onboarding-controller" }, + { + "path": "../shield-controller" + }, { "path": "../storage-service" }, diff --git a/yarn.lock b/yarn.lock index 8b575edec4d..caab5830f62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8616,7 +8616,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/shield-controller@workspace:packages/shield-controller": +"@metamask/shield-controller@npm:^5.1.3, @metamask/shield-controller@workspace:packages/shield-controller": version: 0.0.0-use.local resolution: "@metamask/shield-controller@workspace:packages/shield-controller" dependencies: @@ -9268,6 +9268,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/seedless-onboarding-controller": "npm:^10.0.3" + "@metamask/shield-controller": "npm:^5.1.3" "@metamask/storage-service": "npm:^1.0.2" "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" From e30a66d4baddfdbffd7160093d3b6746206ddccc Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 07:44:27 +0700 Subject: [PATCH 2/7] fix: lint --- .../instances/shield-controller/shield-controller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts index 35104910f23..30b6fcfefa8 100644 --- a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts +++ b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts @@ -12,8 +12,8 @@ import type { DefaultEvents, RootMessenger, } from '../../defaults.js'; -import type { ShieldBackend } from './types.js'; import { shieldController } from './shield-controller.js'; +import type { ShieldBackend } from './types.js'; const MOCK_COVERAGE_ID = 'coverage-id-1'; const SHIELD_BASE_URL = 'https://rule-engine.metamask.io'; From 05a0cbc730f40fa4bc0cd8809cbec0c951372b1c Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 07:47:32 +0700 Subject: [PATCH 3/7] chore: update changelog --- packages/wallet-cli/CHANGELOG.md | 2 +- packages/wallet/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index be0a2c4d070..676d0d65593 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Wire the `shieldController` slot in the daemon wallet's instance options with the production rule-engine base URL and `fetch`, so the daemon constructs `ShieldController` with explicit host configuration rather than relying on implicit defaults +- Wire the `shieldController` slot in the daemon wallet's instance options with the production rule-engine base URL and `fetch`, so the daemon constructs `ShieldController` with explicit host configuration rather than relying on implicit defaults ([#9616](https://github.com/MetaMask/core/pull/9616)) - Wire the `transactionController` slot in the daemon wallet's instance options, so the daemon runs the `TransactionController` with an explicit CLI-appropriate configuration (swaps processing disabled, no client hooks) rather than relying on the controller's implicit defaults ([#9509](https://github.com/MetaMask/core/pull/9509)) - Add the `mm wallet unlock` command, which dispatches `KeyringController:submitPassword` over the daemon socket, allowing the keyring to be unlocked after a daemon start with no password or after a `mm daemon call KeyringController:setLocked` ([#8821](https://github.com/MetaMask/core/pull/8821)) - Add the `mm daemon list` command, which prints the messenger actions the running daemon can dispatch via `daemon call`, enumerated from the live messenger so the list cannot drift from what `call` accepts ([#9339](https://github.com/MetaMask/core/pull/9339)) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index e77f9f82b43..846001d36cf 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING:** Wire `ShieldController` into the default wallet initialization +- **BREAKING:** Wire `ShieldController` into the default wallet initialization ([#9616](https://github.com/MetaMask/core/pull/9616)) - Adds required `shieldController` slot to `instanceOptions` with `baseUrl` and `fetchFunction` (or an injected `backend` override) - Default backend construction uses `ShieldRemoteBackend` with optional `getAccessToken`, `captureException`, polling, history limits, and `normalizeSignatureRequest` - Delegates `AuthenticationController:getBearerToken` plus `TransactionController:stateChange` and `SignatureController:stateChange` on the shared messenger bus From 0fc96079a2654dc8405901e4809d958368500208 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 07:57:35 +0700 Subject: [PATCH 4/7] fix: changelog --- packages/wallet/CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index b6f95e7c70d..6a8e9d00729 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [8.0.0] - ### Added - **BREAKING:** Wire `ShieldController` into the default wallet initialization ([#9616](https://github.com/MetaMask/core/pull/9616)) @@ -16,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Default backend construction uses `ShieldRemoteBackend` with optional `getAccessToken`, `captureException`, polling, history limits, and `normalizeSignatureRequest` - Delegates `AuthenticationController:getBearerToken` plus `TransactionController:stateChange` and `SignatureController:stateChange` on the shared messenger bus - Hosts must register `AuthenticationController` and `SignatureController` on the wallet root messenger and explicitly call `ShieldController:start` after wiring + +## [8.0.0] + +### Added + - **BREAKING:** Wire `GasFeeController` into the default wallet initialization ([#9527](https://github.com/MetaMask/core/pull/9527)) - Adds a required `instanceOptions.gasFeeController` option whose `clientId` (sent as `X-Client-Id` to the gas API) is required, so every client identifies itself; all other fields are optional and fall back to platform-agnostic defaults. - **BREAKING** Wire `SeedlessOnboardingController` and `PasskeyController` into the default wallet initialization ([#9533](https://github.com/MetaMask/core/pull/9533)) From fb082f1d13e0ef9be2608f999b4aed3912b73858 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 08:28:14 +0700 Subject: [PATCH 5/7] fix: missing prebuilt referenced package --- packages/wallet/package.json | 1 + packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 3 +++ yarn.lock | 1 + 4 files changed, 6 insertions(+) diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 097f2027430..69fe467a194 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -71,6 +71,7 @@ "@metamask/scure-bip39": "^2.1.1", "@metamask/seedless-onboarding-controller": "^10.1.0", "@metamask/shield-controller": "^5.1.3", + "@metamask/signature-controller": "^39.2.7", "@metamask/storage-service": "^1.0.2", "@metamask/transaction-controller": "^69.2.1", "@metamask/utils": "^11.11.0" diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 30681a2464b..13467e2a1c2 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -20,6 +20,7 @@ { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { "path": "../seedless-onboarding-controller/tsconfig.build.json" }, { "path": "../shield-controller/tsconfig.build.json" }, + { "path": "../signature-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" } ], diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 97a46f58675..c4e99c0c861 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -46,6 +46,9 @@ { "path": "../shield-controller" }, + { + "path": "../signature-controller" + }, { "path": "../storage-service" }, diff --git a/yarn.lock b/yarn.lock index 8f71964a2cf..26015d30233 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9271,6 +9271,7 @@ __metadata: "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/seedless-onboarding-controller": "npm:^10.1.0" "@metamask/shield-controller": "npm:^5.1.3" + "@metamask/signature-controller": "npm:^39.2.7" "@metamask/storage-service": "npm:^1.0.2" "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" From b14ad1ea1b8b2f9156df53dea5bbb2bc268c49c1 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 08:29:30 +0700 Subject: [PATCH 6/7] fix: correct rule engine url --- packages/shield-controller/src/backend.test.ts | 2 +- packages/wallet-cli/src/daemon/wallet-factory.test.ts | 2 +- packages/wallet/src/Wallet.test.ts | 2 +- .../instances/shield-controller/shield-controller.test.ts | 2 +- .../transaction-controller/transaction-controller.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shield-controller/src/backend.test.ts b/packages/shield-controller/src/backend.test.ts index ab22c5d90b8..8d322289d50 100644 --- a/packages/shield-controller/src/backend.test.ts +++ b/packages/shield-controller/src/backend.test.ts @@ -50,7 +50,7 @@ function setup({ getCoverageResultTimeout, getCoverageResultPollInterval, fetch, - baseUrl: 'https://rule-engine.metamask.io', + baseUrl: 'https://ruleset-engine.api.cx.metamask.io', captureException: mockCaptureException, }); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index 643dea8bbcb..c922429769b 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -122,7 +122,7 @@ describe('createWallet', () => { expect(instanceOptions.transactionController?.disableSwaps).toBe(true); expect(instanceOptions.transactionController?.hooks).toStrictEqual({}); expect(instanceOptions.shieldController.baseUrl).toBe( - 'https://rule-engine.metamask.io', + 'https://ruleset-engine.api.cx.metamask.io', ); expect(instanceOptions.shieldController.fetchFunction).toBe( globalThis.fetch, diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index f9a04181a4f..eba260cd4df 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -29,7 +29,7 @@ const REMOTE_FEATURE_FLAG_OPTIONS = { }; const SHIELD_CONTROLLER_OPTIONS = { - baseUrl: 'https://rule-engine.metamask.io', + baseUrl: 'https://ruleset-engine.api.cx.metamask.io', fetchFunction: jest.fn(), backend: { checkCoverage: jest.fn(), diff --git a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts index 30b6fcfefa8..82d66894ee9 100644 --- a/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts +++ b/packages/wallet/src/initialization/instances/shield-controller/shield-controller.test.ts @@ -16,7 +16,7 @@ import { shieldController } from './shield-controller.js'; import type { ShieldBackend } from './types.js'; const MOCK_COVERAGE_ID = 'coverage-id-1'; -const SHIELD_BASE_URL = 'https://rule-engine.metamask.io'; +const SHIELD_BASE_URL = 'https://ruleset-engine.api.cx.metamask.io'; type ActionHandler = (...args: unknown[]) => unknown; diff --git a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts index a5fbeead709..587a0fa2cda 100644 --- a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts +++ b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts @@ -126,7 +126,7 @@ function getInstanceOptions(): WalletOptions['instanceOptions'] { }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, shieldController: { - baseUrl: 'https://rule-engine.metamask.io', + baseUrl: 'https://ruleset-engine.api.cx.metamask.io', fetchFunction: jest.fn(), backend: { checkCoverage: jest.fn(), From a905ac5e6c183a0cfe2dadf9fd9b41646f66c82a Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 23 Jul 2026 08:33:47 +0700 Subject: [PATCH 7/7] fix: update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c452ef1d6ed..1dbd7e66bb7 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,7 @@ linkStyle default opacity:0.5 wallet --> remote_feature_flag_controller; wallet --> seedless_onboarding_controller; wallet --> shield_controller; + wallet --> signature_controller; wallet --> storage_service; wallet --> transaction_controller; wallet_cli --> base_controller;