From 94672993329c4cf09afcc79c28d0f2571926c06c Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 22 Jul 2026 19:46:25 +0700 Subject: [PATCH 1/5] feat(wallet): Integrate SubscriptionController into wallet initialization --- .github/CODEOWNERS | 1 + README.md | 1 + codeowners.ts | 1 + .../src/daemon/wallet-factory.test.ts | 5 + .../wallet-cli/src/daemon/wallet-factory.ts | 8 + packages/wallet/CHANGELOG.md | 5 + packages/wallet/package.json | 1 + packages/wallet/src/Wallet.test.ts | 28 ++ packages/wallet/src/index.ts | 1 + .../src/initialization/instances/index.ts | 1 + .../subscription-controller.test.ts | 249 ++++++++++++++++++ .../subscription-controller.ts | 83 ++++++ .../subscription-controller/types.ts | 18 ++ .../transaction-controller.test.ts | 5 + packages/wallet/src/types.ts | 2 + packages/wallet/tsconfig.build.json | 1 + packages/wallet/tsconfig.json | 3 + yarn.lock | 3 +- 18 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts create mode 100644 packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts create mode 100644 packages/wallet/src/initialization/instances/subscription-controller/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f7d8e29ec70..596456c2b02 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -155,6 +155,7 @@ /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/storage-service/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/wallet/src/initialization/instances/subscription-controller/ @MetaMask/web3auth /packages/wallet/src/initialization/instances/transaction-controller/ @MetaMask/confirmations ## Package Release related diff --git a/README.md b/README.md index ec3d61dc7b9..b26615ac7de 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,7 @@ linkStyle default opacity:0.5 wallet --> remote_feature_flag_controller; wallet --> seedless_onboarding_controller; wallet --> storage_service; + wallet --> subscription_controller; wallet --> transaction_controller; wallet_cli --> base_controller; wallet_cli --> remote_feature_flag_controller; diff --git a/codeowners.ts b/codeowners.ts index ce77d6b6877..6c115b32efe 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -346,6 +346,7 @@ const PACKAGES: Record = { }, 'subscription-controller': { teams: ['@MetaMask/web3auth'], + initializationPath: 'subscription-controller', }, 'transaction-controller': { teams: ['@MetaMask/confirmations'], diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index de29cebdd7c..ea8aabee146 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -2,6 +2,7 @@ import { ClientConfigApiService } from '@metamask/remote-feature-flag-controller import { InMemoryStorageAdapter } from '@metamask/storage-service'; import { AlwaysOnlineAdapter, + Env, importSecretRecoveryPhrase, Wallet, } from '@metamask/wallet'; @@ -118,6 +119,10 @@ describe('createWallet', () => { expect(instanceOptions.storageService.storage).toBeInstanceOf( InMemoryStorageAdapter, ); + expect(instanceOptions.subscriptionController).toStrictEqual({ + env: Env.PRD, + fetchFunction: globalThis.fetch, + }); expect(instanceOptions.transactionController?.disableSwaps).toBe(true); expect(instanceOptions.transactionController?.hooks).toStrictEqual({}); expect(ClientConfigApiService).toHaveBeenCalled(); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index f281c74f230..3218fd97243 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -8,6 +8,7 @@ import { InMemoryStorageAdapter } from '@metamask/storage-service'; import type { Json } from '@metamask/utils'; import { AlwaysOnlineAdapter, + Env, importSecretRecoveryPhrase, Wallet, } from '@metamask/wallet'; @@ -65,6 +66,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. + * - `subscriptionController` — production subscription API environment and + * platform `fetch`; authenticated calls require host `AuthenticationController` + * wiring on the wallet root messenger (not configured in the CLI). * * The optional `keyringController` slot is intentionally omitted so the * controller's built-in defaults (e.g. the PBKDF2 encryptor) apply. @@ -103,6 +107,10 @@ function buildInstanceOptions( storageService: { storage: new InMemoryStorageAdapter(), }, + subscriptionController: { + env: Env.PRD, + fetchFunction: globalThis.fetch, + }, transactionController: { // The CLI exposes no swaps surface, so skip the swaps-specific // post-processing a full wallet client runs (mobile makes the same diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 4f9e6d2e42a..c04660a0db9 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 `SubscriptionController` into the default wallet initialization + - Adds a required `subscriptionController` slot to `instanceOptions`. When `subscriptionService` is omitted, the wallet constructs a default `SubscriptionService` from `env`, `fetchFunction`, and optional `getAccessToken` (defaults to `AuthenticationController:getBearerToken` via messenger delegation), `captureException`, and `pollingInterval` (defaults to five minutes). + - Hosts must register `AuthenticationController` on the wallet root messenger for authenticated subscription API calls; `AuthenticationController:getBearerToken`, `AuthenticationController:performSignOut`, and `AuthenticationController:stateChange` are delegated to the controller messenger. + - Re-export `Env` from `@metamask/subscription-controller` for consumers configuring `instanceOptions.subscriptionController.env`. + - **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 fc025b9f49f..79f8276004d 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -70,6 +70,7 @@ "@metamask/scure-bip39": "^2.1.1", "@metamask/seedless-onboarding-controller": "^10.0.3", "@metamask/storage-service": "^1.0.2", + "@metamask/subscription-controller": "^6.2.1", "@metamask/transaction-controller": "^69.2.0", "@metamask/utils": "^11.11.0" }, diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 75dac034e92..c51f827fafa 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -2,6 +2,10 @@ import { getDefaultAddressBookControllerState } from '@metamask/address-book-con import { CONNECTIVITY_STATUSES } from '@metamask/connectivity-controller'; import { Messenger } from '@metamask/messenger'; import { InMemoryStorageAdapter } from '@metamask/storage-service'; +import { + Env, + getDefaultSubscriptionControllerState, +} from '@metamask/subscription-controller'; import { Json } from '@metamask/utils'; import { webcrypto } from 'crypto'; @@ -23,6 +27,11 @@ const REMOTE_FEATURE_FLAG_OPTIONS = { }, }; +const SUBSCRIPTION_CONTROLLER_OPTIONS = { + env: Env.DEV, + fetchFunction: globalThis.fetch, +}; + async function setupWallet(): Promise { const wallet = new Wallet({ instanceOptions: { @@ -36,6 +45,7 @@ async function setupWallet(): Promise { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -99,6 +109,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -148,6 +159,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); const { state } = wallet; @@ -191,6 +203,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -301,6 +314,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -333,6 +347,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -373,6 +388,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -433,6 +449,17 @@ describe('Wallet', () => { }); }); + describe('SubscriptionController', () => { + it('is wired and exposes its state on the wallet messenger', async () => { + const wallet = await setupWallet(); + + expect( + wallet.messenger.call('SubscriptionController:getState'), + ).toStrictEqual(getDefaultSubscriptionControllerState()); + expect(wallet.getInstance('SubscriptionController')).toBeDefined(); + }); + }); + describe('RemoteFeatureFlagController', () => { it('is wired and exposes its state on the wallet messenger', async () => { const wallet = await setupWallet(); @@ -470,6 +497,7 @@ describe('Wallet', () => { }), }, }, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); const { messenger } = wallet; diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 50e9b004160..700823c338e 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,6 +1,7 @@ export { Wallet } from './Wallet.js'; export { AlwaysOnlineAdapter } from './initialization/instances/connectivity-controller/always-online-adapter.js'; export { importSecretRecoveryPhrase } from './utilities.js'; +export { Env } from '@metamask/subscription-controller'; export type { WalletOptions } from './types.js'; export type { DefaultActions, diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 858e36e9769..273a3191cf4 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -8,4 +8,5 @@ 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 { storageService } from './storage-service/storage-service.js'; +export { subscriptionController } from './subscription-controller/subscription-controller.js'; export { transactionController } from './transaction-controller/transaction-controller.js'; diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts new file mode 100644 index 00000000000..a463c50a6ce --- /dev/null +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts @@ -0,0 +1,249 @@ +import { Messenger } from '@metamask/messenger'; +import { + Env, + getDefaultSubscriptionControllerState, + SubscriptionController, +} from '@metamask/subscription-controller'; +import type { ISubscriptionService } from '@metamask/subscription-controller'; + +import { defaultConfigurations } from '../../defaults.js'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import { subscriptionController } from './subscription-controller.js'; + +type ActionHandler = (...args: unknown[]) => unknown; + +type AnyMessenger = Messenger; + +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 createMockSubscriptionService(): jest.Mocked { + return { + getSubscriptions: jest.fn(), + getPricing: jest.fn(), + cancelSubscription: jest.fn(), + unCancelSubscription: jest.fn(), + startSubscriptionWithCard: jest.fn(), + startSubscriptionWithCrypto: jest.fn(), + getBillingPortalUrl: jest.fn(), + updatePaymentMethodCard: jest.fn(), + updatePaymentMethodCrypto: jest.fn(), + getSubscriptionsEligibilities: jest.fn(), + submitSponsorshipIntents: jest.fn(), + submitUserEvent: jest.fn(), + assignUserToCohort: jest.fn(), + linkRewards: jest.fn(), + }; +} + +describe('subscriptionController', () => { + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain( + subscriptionController, + ); + }); + + it('initializes a SubscriptionController with default state', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + + const instance = subscriptionController.init({ + state: undefined, + messenger, + options: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, + }); + + expect(instance).toBeInstanceOf(SubscriptionController); + expect(instance.state).toStrictEqual( + getDefaultSubscriptionControllerState(), + ); + }); + + it('forwards the provided state to the controller', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + + const instance = subscriptionController.init({ + state: { + subscriptions: [], + customerId: 'cus_test', + trialedProducts: [], + }, + messenger, + options: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, + }); + + expect(instance.state.customerId).toBe('cus_test'); + }); + + it('constructs a default SubscriptionService from options', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + + const instance = subscriptionController.init({ + state: undefined, + messenger, + options: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, + }); + + expect(instance).toBeInstanceOf(SubscriptionController); + }); + + it('uses a provided subscriptionService override', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + const mockService = createMockSubscriptionService(); + + const instance = subscriptionController.init({ + state: undefined, + messenger, + options: { + subscriptionService: mockService, + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, + }); + + expect(instance).toBeInstanceOf(SubscriptionController); + }); + + it('forwards pollingInterval to the controller', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + const pollingInterval = 60_000; + + const instance = subscriptionController.init({ + state: undefined, + messenger, + options: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + pollingInterval, + }, + }); + + expect(instance.getIntervalLength()).toBe(pollingInterval); + }); + + it('defaults pollingInterval to five minutes', () => { + const messenger = subscriptionController.getMessenger(getRootMessenger()); + + const instance = subscriptionController.init({ + state: undefined, + messenger, + options: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, + }); + + expect(instance.getIntervalLength()).toBe(5 * 60 * 1_000); + }); + + it('wires default getAccessToken to AuthenticationController:getBearerToken', async () => { + const rootMessenger = getRootMessenger(); + registerActionHandler( + rootMessenger, + 'AuthenticationController', + 'AuthenticationController:getBearerToken', + async () => 'test-bearer-token', + ); + registerActionHandler( + rootMessenger, + 'AuthenticationController', + 'AuthenticationController:performSignOut', + jest.fn(), + ); + const messenger = subscriptionController.getMessenger(rootMessenger); + const fetchFunction = jest.fn( + async () => + new globalThis.Response( + JSON.stringify({ + customerId: 'cus_1', + subscriptions: [], + trialedProducts: [], + }), + { status: 200 }, + ), + ); + + subscriptionController.init({ + state: undefined, + messenger, + options: { + env: Env.DEV, + fetchFunction, + }, + }); + + await rootMessenger.call('SubscriptionController:getSubscriptions'); + + expect(fetchFunction).toHaveBeenCalled(); + const [, requestInit] = fetchFunction.mock.calls[0] as [ + string, + RequestInit, + ]; + const headers = new globalThis.Headers(requestInit.headers); + expect(headers.get('Authorization')).toBe('Bearer test-bearer-token'); + }); + + it('delegates AuthenticationController actions and events', () => { + const parent = getRootMessenger(); + const delegateSpy = jest.spyOn(parent, 'delegate'); + const messenger = subscriptionController.getMessenger(parent); + + expect(delegateSpy).toHaveBeenCalledWith({ + messenger, + actions: [ + 'AuthenticationController:getBearerToken', + 'AuthenticationController:performSignOut', + ], + events: ['AuthenticationController:stateChange'], + }); + }); + + it('exposes its actions through the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = subscriptionController.getMessenger(rootMessenger); + + subscriptionController.init({ + state: undefined, + messenger, + options: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, + }); + + expect( + rootMessenger.call('SubscriptionController:getState'), + ).toStrictEqual(getDefaultSubscriptionControllerState()); + }); +}); diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts new file mode 100644 index 00000000000..b2bc4dcb467 --- /dev/null +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts @@ -0,0 +1,83 @@ +import { Messenger } from '@metamask/messenger'; +import type { + ISubscriptionService, + SubscriptionControllerMessenger, + SubscriptionControllerState, +} from '@metamask/subscription-controller'; +import { + SubscriptionController, + SubscriptionService, +} from '@metamask/subscription-controller'; + +import type { InitializationConfiguration } from '../../types.js'; +import type { SubscriptionControllerInstanceOptions } from './types.js'; + +export type { SubscriptionControllerInstanceOptions } from './types.js'; + +function resolveSubscriptionService( + messenger: SubscriptionControllerMessenger, + options: SubscriptionControllerInstanceOptions, +): ISubscriptionService { + if (options.subscriptionService) { + return options.subscriptionService; + } + + const getAccessToken = + options.getAccessToken ?? + ((): Promise => + messenger.call('AuthenticationController:getBearerToken')); + + return new SubscriptionService({ + env: options.env, + auth: { getAccessToken }, + fetchFunction: options.fetchFunction, + captureException: options.captureException, + }); +} + +export const subscriptionController: InitializationConfiguration< + SubscriptionController, + SubscriptionControllerMessenger +> = { + name: 'SubscriptionController', + init: ({ + state, + messenger, + options, + }: { + state: Partial | undefined; + messenger: SubscriptionControllerMessenger; + options: SubscriptionControllerInstanceOptions; + }) => { + const { pollingInterval, ...serviceOptions } = options; + + return new SubscriptionController({ + messenger, + state, + subscriptionService: resolveSubscriptionService(messenger, serviceOptions), + pollingInterval, + }); + }, + getMessenger: (parent) => { + const messenger: SubscriptionControllerMessenger = new Messenger({ + namespace: 'SubscriptionController', + parent, + }); + + parent.delegate({ + messenger, + actions: [ + 'AuthenticationController:getBearerToken', + 'AuthenticationController:performSignOut', + ], + events: [ + // SubscriptionController subscribes to :stateChange internally; the + // delegation must match until that package migrates to :stateChanged. + // eslint-disable-next-line no-restricted-syntax + 'AuthenticationController:stateChange', + ], + }); + + return messenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/subscription-controller/types.ts b/packages/wallet/src/initialization/instances/subscription-controller/types.ts new file mode 100644 index 00000000000..392b8e8fde9 --- /dev/null +++ b/packages/wallet/src/initialization/instances/subscription-controller/types.ts @@ -0,0 +1,18 @@ +import type { + Env, + ISubscriptionService, +} from '@metamask/subscription-controller'; + +export type SubscriptionControllerInstanceOptions = { + /** + * When set, used as-is; `env`, `fetchFunction`, `getAccessToken`, and + * `captureException` are ignored for service construction. + */ + subscriptionService?: ISubscriptionService; + /** Required when building the default `SubscriptionService`. */ + env: Env; + fetchFunction: typeof fetch; + getAccessToken?: () => Promise; + captureException?: (error: Error) => void; + pollingInterval?: number; +}; 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..001c600f0ca 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 @@ -1,5 +1,6 @@ import { Messenger } from '@metamask/messenger'; import { InMemoryStorageAdapter } from '@metamask/storage-service'; +import { Env } from '@metamask/subscription-controller'; import { TransactionController } from '@metamask/transaction-controller'; import type { WalletOptions } from '../../../types.js'; @@ -122,6 +123,10 @@ function getInstanceOptions(): WalletOptions['instanceOptions'] { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + subscriptionController: { + env: Env.DEV, + fetchFunction: globalThis.fetch, + }, }; } diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 46bb9c5993c..d695316e102 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -13,6 +13,7 @@ import type { PasskeyControllerInstanceOptions } from './initialization/instance 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 { StorageServiceInstanceOptions } from './initialization/instances/storage-service/types.js'; +import type { SubscriptionControllerInstanceOptions } from './initialization/instances/subscription-controller/types.js'; import type { TransactionControllerInstanceOptions } from './initialization/instances/transaction-controller/types.js'; import type { InitializationConfiguration } from './initialization/types.js'; @@ -33,6 +34,7 @@ export type InstanceSpecificOptions = { networkController: NetworkControllerInstanceOptions; remoteFeatureFlagController: RemoteFeatureFlagControllerInstanceOptions; storageService: StorageServiceInstanceOptions; + subscriptionController: SubscriptionControllerInstanceOptions; transactionController?: TransactionControllerInstanceOptions; passkeyController?: PasskeyControllerInstanceOptions; seedlessOnboardingController?: SeedlessOnboardingControllerInstanceOptions; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index d4caa50ec1c..36ec211fb60 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { "path": "../seedless-onboarding-controller/tsconfig.build.json" }, { "path": "../storage-service/tsconfig.build.json" }, + { "path": "../subscription-controller/tsconfig.build.json" }, { "path": "../transaction-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index 62f5137fc5b..df6ac4d01a6 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -43,6 +43,9 @@ { "path": "../storage-service" }, + { + "path": "../subscription-controller" + }, { "path": "../transaction-controller" } diff --git a/yarn.lock b/yarn.lock index d50f2f41027..ffbcedb5eef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8870,7 +8870,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/subscription-controller@workspace:packages/subscription-controller": +"@metamask/subscription-controller@npm:^6.2.1, @metamask/subscription-controller@workspace:packages/subscription-controller": version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: @@ -9178,6 +9178,7 @@ __metadata: "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/seedless-onboarding-controller": "npm:^10.0.3" "@metamask/storage-service": "npm:^1.0.2" + "@metamask/subscription-controller": "npm:^6.2.1" "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" From 1e946cfa2082009731b85dc0ca4e69025e2e0810 Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 22 Jul 2026 20:06:33 +0700 Subject: [PATCH 2/5] chore: update changelog --- packages/wallet/CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 6b1ea587a72..6d181d87e68 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,10 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING** Wire `SubscriptionController` into the default wallet initialization - - Adds a required `subscriptionController` slot to `instanceOptions`. When `subscriptionService` is omitted, the wallet constructs a default `SubscriptionService` from `env`, `fetchFunction`, and optional `getAccessToken` (defaults to `AuthenticationController:getBearerToken` via messenger delegation), `captureException`, and `pollingInterval` (defaults to five minutes). - - Hosts must register `AuthenticationController` on the wallet root messenger for authenticated subscription API calls; `AuthenticationController:getBearerToken`, `AuthenticationController:performSignOut`, and `AuthenticationController:stateChange` are delegated to the controller messenger. - - Re-export `Env` from `@metamask/subscription-controller` for consumers configuring `instanceOptions.subscriptionController.env`. +- **BREAKING** Wire `SubscriptionController` into the default wallet initialization ([#9598](https://github.com/MetaMask/core/pull/9598)) - **BREAKING** Wire `SeedlessOnboardingController` and `PasskeyController` into the default wallet initialization ([#9533](https://github.com/MetaMask/core/pull/9533)) From 7737faa5a8706a0ea9203fd43a12569d2a5be8d5 Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 22 Jul 2026 20:16:27 +0700 Subject: [PATCH 3/5] refactor: separate subscription env export --- packages/wallet-cli/src/daemon/wallet-factory.test.ts | 4 ++-- packages/wallet-cli/src/daemon/wallet-factory.ts | 4 ++-- packages/wallet/CHANGELOG.md | 3 +++ packages/wallet/src/index.ts | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index ea8aabee146..9ff8eefb024 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -2,8 +2,8 @@ import { ClientConfigApiService } from '@metamask/remote-feature-flag-controller import { InMemoryStorageAdapter } from '@metamask/storage-service'; import { AlwaysOnlineAdapter, - Env, importSecretRecoveryPhrase, + SubscriptionEnv, Wallet, } from '@metamask/wallet'; import { rmSync } from 'node:fs'; @@ -120,7 +120,7 @@ describe('createWallet', () => { InMemoryStorageAdapter, ); expect(instanceOptions.subscriptionController).toStrictEqual({ - env: Env.PRD, + env: SubscriptionEnv.PRD, fetchFunction: globalThis.fetch, }); expect(instanceOptions.transactionController?.disableSwaps).toBe(true); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 3218fd97243..02fcc8762ac 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -8,8 +8,8 @@ import { InMemoryStorageAdapter } from '@metamask/storage-service'; import type { Json } from '@metamask/utils'; import { AlwaysOnlineAdapter, - Env, importSecretRecoveryPhrase, + SubscriptionEnv, Wallet, } from '@metamask/wallet'; import type { WalletOptions } from '@metamask/wallet'; @@ -108,7 +108,7 @@ function buildInstanceOptions( storage: new InMemoryStorageAdapter(), }, subscriptionController: { - env: Env.PRD, + env: SubscriptionEnv.PRD, fetchFunction: globalThis.fetch, }, transactionController: { diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 6d181d87e68..bb8e9ee0c4a 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **BREAKING** Wire `SubscriptionController` into the default wallet initialization ([#9598](https://github.com/MetaMask/core/pull/9598)) + - Adds a required `subscriptionController` slot to `instanceOptions` (`env`, `fetchFunction`; optional `subscriptionService`, `getAccessToken`, `captureException`, `pollingInterval`). + - Hosts must register `AuthenticationController` on the wallet root messenger for authenticated subscription API calls. + - Re-exports `Env` from `@metamask/subscription-controller` as `SubscriptionEnv` for `instanceOptions.subscriptionController.env`. - **BREAKING** Wire `SeedlessOnboardingController` and `PasskeyController` into the default wallet initialization ([#9533](https://github.com/MetaMask/core/pull/9533)) diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index 700823c338e..ef78fd60e5b 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1,7 +1,7 @@ export { Wallet } from './Wallet.js'; export { AlwaysOnlineAdapter } from './initialization/instances/connectivity-controller/always-online-adapter.js'; export { importSecretRecoveryPhrase } from './utilities.js'; -export { Env } from '@metamask/subscription-controller'; +export { Env as SubscriptionEnv } from '@metamask/subscription-controller'; export type { WalletOptions } from './types.js'; export type { DefaultActions, From 95ccbf61c705446de683b856e0673f7ef1f549bc Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 22 Jul 2026 20:25:53 +0700 Subject: [PATCH 4/5] fix: lint --- packages/wallet/src/Wallet.test.ts | 12 ++++++------ .../subscription-controller.test.ts | 6 +++--- .../subscription-controller.ts | 5 ++++- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index c51f827fafa..b04d6a4662c 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -109,7 +109,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -159,7 +159,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); const { state } = wallet; @@ -203,7 +203,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -314,7 +314,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -347,7 +347,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); @@ -388,7 +388,7 @@ describe('Wallet', () => { storage: new InMemoryStorageAdapter(), }, remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, - subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, + subscriptionController: SUBSCRIPTION_CONTROLLER_OPTIONS, }, }); diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts index a463c50a6ce..3f82d1a9f92 100644 --- a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts @@ -242,8 +242,8 @@ describe('subscriptionController', () => { }, }); - expect( - rootMessenger.call('SubscriptionController:getState'), - ).toStrictEqual(getDefaultSubscriptionControllerState()); + expect(rootMessenger.call('SubscriptionController:getState')).toStrictEqual( + getDefaultSubscriptionControllerState(), + ); }); }); diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts index b2bc4dcb467..0d52e586a4f 100644 --- a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.ts @@ -54,7 +54,10 @@ export const subscriptionController: InitializationConfiguration< return new SubscriptionController({ messenger, state, - subscriptionService: resolveSubscriptionService(messenger, serviceOptions), + subscriptionService: resolveSubscriptionService( + messenger, + serviceOptions, + ), pollingInterval, }); }, From 061ed461c0f8a87e24febe782de3385a425442d2 Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 22 Jul 2026 20:30:26 +0700 Subject: [PATCH 5/5] chore: update changelog --- packages/wallet-cli/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 9e83315b448..70bfcc121a7 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 `subscriptionController` slot in the daemon wallet's instance options (`SubscriptionEnv.PRD` and `globalThis.fetch`), so the daemon initializes `SubscriptionController` with the wallet's required configuration ([#9598](https://github.com/MetaMask/core/pull/9598)) - 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))