From c74dd4d7a2fd7cf89de540165ca68d8546a088d7 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 1 Sep 2025 16:07:47 +0700 Subject: [PATCH 01/26] feat: add get price info method --- .../src/SubscriptionController.test.ts | 21 +++++++- .../src/SubscriptionController.ts | 13 ++++- .../src/SubscriptionService.test.ts | 36 ++++++++++++- .../src/SubscriptionService.ts | 6 +++ packages/subscription-controller/src/types.ts | 53 ++++++++++++++++--- 5 files changed, 120 insertions(+), 9 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 531d644640..2f96031fe3 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -14,7 +14,7 @@ import { type SubscriptionControllerOptions, type SubscriptionControllerState, } from './SubscriptionController'; -import type { Subscription } from './types'; +import type { PriceInfoResponse, Subscription } from './types'; import { PaymentType, ProductType } from './types'; // Mock data @@ -37,6 +37,11 @@ const MOCK_SUBSCRIPTION: Subscription = { }, }; +const MOCK_PRICE_INFO_RESPONSE: PriceInfoResponse = { + products: [MOCK_SUBSCRIPTION.products[0]], + paymentMethods: [MOCK_SUBSCRIPTION.paymentMethod], +}; + /** * Creates a custom subscription messenger, in case tests need different permissions * @@ -108,16 +113,19 @@ function createMockSubscriptionMessenger(): { function createMockSubscriptionService() { const mockGetSubscriptions = jest.fn().mockImplementation(); const mockCancelSubscription = jest.fn(); + const mockGetPriceInfo = jest.fn(); const mockService = { getSubscriptions: mockGetSubscriptions, cancelSubscription: mockCancelSubscription, + getPriceInfo: mockGetPriceInfo, }; return { mockService, mockGetSubscriptions, mockCancelSubscription, + mockGetPriceInfo, }; } @@ -390,6 +398,17 @@ describe('SubscriptionController', () => { }); }); + describe('getPriceInfo', () => { + it('should get price info successfully', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getPriceInfo.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); + const result = await controller.getPriceInfo(); + expect(result).toStrictEqual(MOCK_PRICE_INFO_RESPONSE); + expect(mockService.getPriceInfo).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('integration scenarios', () => { it('should handle complete subscription lifecycle with updated logic', async () => { await withController(async ({ controller, mockService }) => { diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index f1a543c516..39066e3ccf 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -24,7 +24,9 @@ type CreateActionsObj = { handler: SubscriptionController[K]; }; }; -type ActionsObj = CreateActionsObj<'getSubscriptions' | 'cancelSubscription'>; +type ActionsObj = CreateActionsObj< + 'getSubscriptions' | 'cancelSubscription' | 'getPriceInfo' +>; export type SubscriptionControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -149,6 +151,11 @@ export class SubscriptionController extends BaseController< 'SubscriptionController:cancelSubscription', this.cancelSubscription.bind(this), ); + + this.messagingSystem.registerActionHandler( + 'SubscriptionController:getPriceInfo', + this.getPriceInfo.bind(this), + ); } async getSubscriptions() { @@ -178,6 +185,10 @@ export class SubscriptionController extends BaseController< }); } + async getPriceInfo() { + return await this.#subscriptionService.getPriceInfo(); + } + #assertIsUserSubscribed(request: { subscriptionId: string }) { if ( !this.state.subscriptions.find( diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 3c45ee8b47..c325e64ccd 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -3,7 +3,7 @@ import nock, { cleanAll, isDone } from 'nock'; import { Env, getEnvUrls } from './constants'; import { SubscriptionServiceError } from './errors'; import { SubscriptionService } from './SubscriptionService'; -import type { Subscription } from './types'; +import type { PriceInfoResponse, Subscription } from './types'; import { PaymentType, ProductType } from './types'; // Mock data @@ -33,6 +33,11 @@ const MOCK_ERROR_RESPONSE = { error: 'NOT_FOUND', }; +const MOCK_PRICE_INFO_RESPONSE: PriceInfoResponse = { + products: [MOCK_SUBSCRIPTION.products[0]], + paymentMethods: [MOCK_SUBSCRIPTION.paymentMethod], +}; + /** * Creates a mock subscription service config for testing * @@ -220,4 +225,33 @@ describe('SubscriptionService', () => { }); }); }); + + describe('get price info', () => { + it('should get price info successfully', async () => { + await withMockSubscriptionService( + async ({ service, testUrl, config }) => { + nock(testUrl) + .get('/api/v1/pricing') + .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) + .reply(200, MOCK_PRICE_INFO_RESPONSE); + + const result = await service.getPriceInfo(); + + expect(result).toStrictEqual(MOCK_PRICE_INFO_RESPONSE); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + expect(isDone()).toBe(true); + }, + ); + }); + + it('should throw SubscriptionServiceError for error responses', async () => { + await withMockSubscriptionService(async ({ service, testUrl }) => { + nock(testUrl).get('/api/v1/pricing').reply(400, MOCK_ERROR_RESPONSE); + + await expect(service.getPriceInfo()).rejects.toThrow( + /Subscription not found/u, + ); + }); + }); + }); }); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index e48c0d702e..bd7677450e 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -4,6 +4,7 @@ import type { AuthUtils, GetSubscriptionsResponse, ISubscriptionService, + PriceInfoResponse, } from './types'; export type SubscriptionServiceConfig = { @@ -43,6 +44,11 @@ export class SubscriptionService implements ISubscriptionService { return await this.#makeRequest(path, 'DELETE'); } + async getPriceInfo(): Promise { + const path = 'pricing'; + return await this.#makeRequest(path); + } + async #makeRequest( path: string, method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH' = 'GET', diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 22ee8e0e61..9022647669 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -16,11 +16,39 @@ export enum PaymentType { export type PaymentMethod = { type: PaymentType; - crypto?: { - payerAddress: string; - chainId: string; - tokenSymbol: string; - }; + chains?: PaymentMethodChain[]; +}; + +export type PaymentMethodChain = { + chainId: string; + paymentAddress: string; + tokens: PaymentToken[]; +}; + +export type PaymentToken = { + symbol: string; + address: string; + decimals: number; + /** + * example: { + usd: '1.0', + }, + */ + conversionRate: Record; +}; + +export type PriceInfo = { + interval: 'month' | 'year'; + currency: string; + unitAmount: number; + unitDecimals: number; + trialPeriodDays: number; + minBillingCycles: number; +}; + +export type ProductPrice = { + name: ProductType; + prices: PriceInfo[]; }; // state @@ -32,7 +60,14 @@ export type Subscription = { billingCycles?: number; status: 'active' | 'inactive' | 'trialing' | 'cancelled'; interval: 'month' | 'year'; - paymentMethod: PaymentMethod; + paymentMethod: { + type: PaymentType; + crypto?: { + payerAddress: string; + chainId: string; + tokenSymbol: string; + }; + }; }; export type GetSubscriptionsResponse = { @@ -41,6 +76,11 @@ export type GetSubscriptionsResponse = { trialedProducts: ProductType[]; }; +export type PriceInfoResponse = { + products: Product[]; + paymentMethods: PaymentMethod[]; +}; + export type AuthUtils = { getAccessToken: () => Promise; }; @@ -48,4 +88,5 @@ export type AuthUtils = { export type ISubscriptionService = { getSubscriptions(): Promise; cancelSubscription(request: { subscriptionId: string }): Promise; + getPriceInfo(): Promise; }; From bc8653915befeeb5c3222567efcd7e68ff7a725b Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 10:32:36 +0700 Subject: [PATCH 02/26] feat: update fetch fn --- .../src/SubscriptionService.test.ts | 59 ++++++++----------- .../src/SubscriptionService.ts | 20 ++----- .../src/constants.test.ts | 8 +-- .../subscription-controller/src/constants.ts | 6 +- packages/subscription-controller/src/types.ts | 8 ++- 5 files changed, 42 insertions(+), 59 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index c325e64ccd..66e872d335 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -1,9 +1,10 @@ +import { handleFetch } from '@metamask/controller-utils'; import nock, { cleanAll, isDone } from 'nock'; import { Env, getEnvUrls } from './constants'; import { SubscriptionServiceError } from './errors'; import { SubscriptionService } from './SubscriptionService'; -import type { PriceInfoResponse, Subscription } from './types'; +import type { PriceInfoResponse, ProductPrice, Subscription } from './types'; import { PaymentType, ProductType } from './types'; // Mock data @@ -26,6 +27,20 @@ const MOCK_SUBSCRIPTION: Subscription = { }, }; +const MOCK_PRODUCT_PRICE: ProductPrice = { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 9.99, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], +}; + const MOCK_ACCESS_TOKEN = 'mock-access-token-12345'; const MOCK_ERROR_RESPONSE = { @@ -34,7 +49,7 @@ const MOCK_ERROR_RESPONSE = { }; const MOCK_PRICE_INFO_RESPONSE: PriceInfoResponse = { - products: [MOCK_SUBSCRIPTION.products[0]], + products: [MOCK_PRODUCT_PRICE], paymentMethods: [MOCK_SUBSCRIPTION.paymentMethod], }; @@ -48,7 +63,7 @@ const MOCK_PRICE_INFO_RESPONSE: PriceInfoResponse = { */ function createMockConfig({ env = Env.DEV, - fetchFn = fetch, + fetchFn = handleFetch, }: { env?: Env; fetchFn?: typeof fetch } = {}) { return { env, @@ -117,7 +132,7 @@ describe('SubscriptionService', () => { await withMockSubscriptionService( async ({ service, testUrl, config }) => { nock(testUrl) - .get('/api/v1/subscriptions') + .get('/v1/subscriptions') .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) .reply(200, { customerId: 'cus_1', @@ -140,9 +155,7 @@ describe('SubscriptionService', () => { it('should throw SubscriptionServiceError for error responses', async () => { await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl) - .get('/api/v1/subscriptions') - .reply(404, MOCK_ERROR_RESPONSE); + nock(testUrl).get('/v1/subscriptions').reply(404, MOCK_ERROR_RESPONSE); await expect(service.getSubscriptions()).rejects.toThrow( SubscriptionServiceError, @@ -152,9 +165,7 @@ describe('SubscriptionService', () => { it('should throw SubscriptionServiceError for network errors', async () => { await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl) - .get('/api/v1/subscriptions') - .replyWithError('Network error'); + nock(testUrl).get('/v1/subscriptions').replyWithError('Network error'); await expect(service.getSubscriptions()).rejects.toThrow( SubscriptionServiceError, @@ -189,7 +200,7 @@ describe('SubscriptionService', () => { await withMockSubscriptionService( async ({ service, testUrl, config }) => { nock(testUrl) - .delete('/api/v1/subscriptions/sub_123456789') + .delete('/v1/subscriptions/sub_123456789') .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) .reply(200, {}); @@ -201,22 +212,10 @@ describe('SubscriptionService', () => { ); }); - it('should throw SubscriptionServiceError for error responses', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl) - .delete('/api/v1/subscriptions/sub_123456789') - .reply(400, MOCK_ERROR_RESPONSE); - - await expect( - service.cancelSubscription({ subscriptionId: 'sub_123456789' }), - ).rejects.toThrow(/Subscription not found/u); - }); - }); - it('should throw SubscriptionServiceError for network errors', async () => { await withMockSubscriptionService(async ({ service, testUrl }) => { nock(testUrl) - .delete('/api/v1/subscriptions/sub_123456789') + .delete('/v1/subscriptions/sub_123456789') .replyWithError('Network error'); await expect( @@ -231,7 +230,7 @@ describe('SubscriptionService', () => { await withMockSubscriptionService( async ({ service, testUrl, config }) => { nock(testUrl) - .get('/api/v1/pricing') + .get('/v1/pricing') .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) .reply(200, MOCK_PRICE_INFO_RESPONSE); @@ -243,15 +242,5 @@ describe('SubscriptionService', () => { }, ); }); - - it('should throw SubscriptionServiceError for error responses', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl).get('/api/v1/pricing').reply(400, MOCK_ERROR_RESPONSE); - - await expect(service.getPriceInfo()).rejects.toThrow( - /Subscription not found/u, - ); - }); - }); }); }); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index bd7677450e..b5ad715b14 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -2,6 +2,7 @@ import { getEnvUrls, type Env } from './constants'; import { SubscriptionServiceError } from './errors'; import type { AuthUtils, + FetchFunction, GetSubscriptionsResponse, ISubscriptionService, PriceInfoResponse, @@ -10,21 +11,16 @@ import type { export type SubscriptionServiceConfig = { env: Env; auth: AuthUtils; - fetchFn: typeof globalThis.fetch; -}; - -type ErrorMessage = { - message: string; - error: string; + fetchFn: FetchFunction; }; export const SUBSCRIPTION_URL = (env: Env, path: string) => - `${getEnvUrls(env).subscriptionApiUrl}/api/v1/${path}`; + `${getEnvUrls(env).subscriptionApiUrl}/v1/${path}`; export class SubscriptionService implements ISubscriptionService { readonly #env: Env; - readonly #fetch: typeof globalThis.fetch; + readonly #fetch: FetchFunction; public authUtils: AuthUtils; @@ -65,13 +61,7 @@ export class SubscriptionService implements ISubscriptionService { }, }); - const responseBody = await response.json(); - if (!response.ok) { - const { message, error } = responseBody as ErrorMessage; - throw new Error(`HTTP error message: ${message}, error: ${error}`); - } - - return responseBody as Result; + return response; } catch (e) { const errorMessage = e instanceof Error ? e.message : JSON.stringify(e ?? 'unknown error'); diff --git a/packages/subscription-controller/src/constants.test.ts b/packages/subscription-controller/src/constants.test.ts index d190b62582..a53ba708ab 100644 --- a/packages/subscription-controller/src/constants.test.ts +++ b/packages/subscription-controller/src/constants.test.ts @@ -5,23 +5,21 @@ describe('constants', () => { it('should return correct URLs for dev environment', () => { const result = getEnvUrls(Env.DEV); expect(result).toStrictEqual({ - subscriptionApiUrl: - 'https://subscription-service.dev-api.cx.metamask.io', + subscriptionApiUrl: 'https://subscription.dev-api.cx.metamask.io', }); }); it('should return correct URLs for uat environment', () => { const result = getEnvUrls(Env.UAT); expect(result).toStrictEqual({ - subscriptionApiUrl: - 'https://subscription-service.uat-api.cx.metamask.io', + subscriptionApiUrl: 'https://subscription.uat-api.cx.metamask.io', }); }); it('should return correct URLs for prd environment', () => { const result = getEnvUrls(Env.PRD); expect(result).toStrictEqual({ - subscriptionApiUrl: 'https://subscription-service.api.cx.metamask.io', + subscriptionApiUrl: 'https://subscription.api.cx.metamask.io', }); }); diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index bad491cf1a..4eb3a9b35c 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -12,13 +12,13 @@ type EnvUrlsEntry = { const ENV_URLS: Record = { dev: { - subscriptionApiUrl: 'https://subscription-service.dev-api.cx.metamask.io', + subscriptionApiUrl: 'https://subscription.dev-api.cx.metamask.io', }, uat: { - subscriptionApiUrl: 'https://subscription-service.uat-api.cx.metamask.io', + subscriptionApiUrl: 'https://subscription.uat-api.cx.metamask.io', }, prd: { - subscriptionApiUrl: 'https://subscription-service.api.cx.metamask.io', + subscriptionApiUrl: 'https://subscription.api.cx.metamask.io', }, }; diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 9022647669..9435651121 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -77,7 +77,7 @@ export type GetSubscriptionsResponse = { }; export type PriceInfoResponse = { - products: Product[]; + products: ProductPrice[]; paymentMethods: PaymentMethod[]; }; @@ -85,6 +85,12 @@ export type AuthUtils = { getAccessToken: () => Promise; }; +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Promise; + export type ISubscriptionService = { getSubscriptions(): Promise; cancelSubscription(request: { subscriptionId: string }): Promise; From 55e415d4e2c7ddba220a1664b830ee61435b1f9f Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 10:32:59 +0700 Subject: [PATCH 03/26] feat: handle create crypto approve --- packages/subscription-controller/package.json | 15 +- .../src/SubscriptionController.test.ts | 736 +++++++++++++++++- .../src/SubscriptionController.ts | 300 ++++++- .../subscription-controller/src/utils.test.ts | 123 +++ packages/subscription-controller/src/utils.ts | 54 ++ .../tsconfig.build.json | 12 + .../subscription-controller/tsconfig.json | 14 +- yarn.lock | 22 + 8 files changed, 1266 insertions(+), 10 deletions(-) create mode 100644 packages/subscription-controller/src/utils.test.ts create mode 100644 packages/subscription-controller/src/utils.ts diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 72b4f8f1dc..92019026b2 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -47,12 +47,23 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@ethersproject/units": "^5.7.0", "@metamask/base-controller": "^8.2.0", - "@metamask/utils": "^11.4.2" + "@metamask/controller-utils": "^11.12.0", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/utils": "^11.4.2", + "bignumber.js": "^9.1.2" }, "devDependencies": { + "@metamask/accounts-controller": "^33.0.0", "@metamask/auto-changelog": "^3.4.4", + "@metamask/gas-fee-controller": "^24.0.0", + "@metamask/network-controller": "^24.1.0", "@metamask/profile-sync-controller": "^24.0.0", + "@metamask/transaction-controller": "^60.1.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", @@ -69,4 +80,4 @@ "access": "public", "registry": "https://registry.npmjs.org/" } -} +} \ No newline at end of file diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 2f96031fe3..f56568ebf4 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -1,4 +1,13 @@ +import { BigNumber } from '@ethersproject/bignumber'; +import { Contract } from '@ethersproject/contracts'; +import type { AccountsControllerGetSelectedMultichainAccountAction } from '@metamask/accounts-controller'; import { Messenger } from '@metamask/base-controller'; +import type { GetGasFeeState } from '@metamask/gas-fee-controller'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; import { controllerName, @@ -14,9 +23,11 @@ import { type SubscriptionControllerOptions, type SubscriptionControllerState, } from './SubscriptionController'; -import type { PriceInfoResponse, Subscription } from './types'; +import type { PriceInfoResponse, ProductPrice, Subscription } from './types'; import { PaymentType, ProductType } from './types'; +jest.mock('@ethersproject/contracts'); + // Mock data const MOCK_SUBSCRIPTION: Subscription = { id: 'sub_123456789', @@ -37,8 +48,22 @@ const MOCK_SUBSCRIPTION: Subscription = { }, }; +const MOCK_PRODUCT_PRICE: ProductPrice = { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 9.99, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], +}; + const MOCK_PRICE_INFO_RESPONSE: PriceInfoResponse = { - products: [MOCK_SUBSCRIPTION.products[0]], + products: [MOCK_PRODUCT_PRICE], paymentMethods: [MOCK_SUBSCRIPTION.paymentMethod], }; @@ -60,7 +85,14 @@ function createCustomSubscriptionMessenger(props?: { AllowedEvents['type'] >({ name: controllerName, - allowedActions: ['AuthenticationController:getBearerToken'], + allowedActions: [ + 'AuthenticationController:getBearerToken', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'NetworkController:findNetworkClientIdByChainId', + 'AccountsController:getSelectedMultichainAccount', + 'GasFeeController:getState', + ], allowedEvents: props?.overrideEvents ?? [ 'AuthenticationController:stateChange', ], @@ -136,7 +168,10 @@ type WithControllerCallback = (params: { controller: SubscriptionController; initialState: SubscriptionControllerState; messenger: SubscriptionControllerMessenger; + baseMessenger: Messenger; mockService: ReturnType['mockService']; + mockAddTransactionFn: jest.Mock; + mockEstimateGasFeeFn: jest.Mock; }) => Promise | ReturnValue; type WithControllerOptions = Partial; @@ -155,12 +190,16 @@ async function withController( ...args: WithControllerArgs ) { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { messenger } = createMockSubscriptionMessenger(); + const { messenger, baseMessenger } = createMockSubscriptionMessenger(); const { mockService } = createMockSubscriptionService(); + const mockAddTransactionFn = jest.fn(); + const mockEstimateGasFeeFn = jest.fn(); const controller = new SubscriptionController({ messenger, subscriptionService: mockService, + addTransactionFn: mockAddTransactionFn, + estimateGasFeeFn: mockEstimateGasFeeFn, ...rest, }); @@ -168,11 +207,19 @@ async function withController( controller, initialState: controller.state, messenger, + baseMessenger, mockService, + mockAddTransactionFn, + mockEstimateGasFeeFn, }); } describe('SubscriptionController', () => { + beforeEach(() => { + // Clear all instances and calls to constructor and all methods: + (Contract as unknown as jest.Mock).mockClear(); + }); + describe('constructor', () => { it('should be able to instantiate with default options', async () => { await withController(async ({ controller }) => { @@ -188,11 +235,15 @@ describe('SubscriptionController', () => { const initialState: Partial = { subscriptions: [MOCK_SUBSCRIPTION], }; + const mockAddTransactionFn = jest.fn(); + const mockEstimateGasFeeFn = jest.fn(); const controller = new SubscriptionController({ messenger, state: initialState, subscriptionService: mockService, + addTransactionFn: mockAddTransactionFn, + estimateGasFeeFn: mockEstimateGasFeeFn, }); expect(controller).toBeDefined(); @@ -202,10 +253,14 @@ describe('SubscriptionController', () => { it('should be able to instantiate with custom subscription service', () => { const { messenger } = createMockSubscriptionMessenger(); const { mockService } = createMockSubscriptionService(); + const mockAddTransactionFn = jest.fn(); + const mockEstimateGasFeeFn = jest.fn(); const controller = new SubscriptionController({ messenger, subscriptionService: mockService, + addTransactionFn: mockAddTransactionFn, + estimateGasFeeFn: mockEstimateGasFeeFn, }); expect(controller).toBeDefined(); @@ -449,4 +504,677 @@ describe('SubscriptionController', () => { }); }); }); + + describe('createCryptoApproveTransaction', () => { + const ContractCtor = Contract as unknown as jest.Mock; + beforeEach(() => { + // ethers.js Contract method is added at runtime depend on abi so we need to mock it dynamically here + const instance = { + allowance: jest + .fn() + .mockResolvedValue(BigNumber.from('1000000000000000000000')), + } as unknown as Contract; + ContractCtor.mockImplementation(() => instance); + }); + + it('returns alreadyAllowed when allowance is sufficient', async () => { + await withController( + async ({ + controller, + mockService, + mockAddTransactionFn, + mockEstimateGasFeeFn, + baseMessenger, + }) => { + // Register mocks for required cross-controller actions on the provided base messenger + baseMessenger.registerActionHandler( + 'NetworkController:getState', + (..._args) => + ({ + selectedNetworkClientId: 'test-client-id', + }) as ReturnType, + ); + baseMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (..._args) => + ({ + provider: { request: jest.fn() }, + }) as unknown as ReturnType< + NetworkControllerGetNetworkClientByIdAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + (..._args) => + ({ + address: '0xabc', + }) as ReturnType< + AccountsControllerGetSelectedMultichainAccountAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + (..._args) => + 'test-client-id' as ReturnType< + NetworkControllerFindNetworkClientIdByChainIdAction['handler'] + >, + ); + + // Provide product pricing and crypto payment info + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + decimals: 18, + conversionRate: { USD: '1.0' }, + }, + ], + }, + ], + }, + ], + }); + + const result = await controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }); + + expect(result).toStrictEqual({ alreadyAllowed: true }); + expect(mockAddTransactionFn).not.toHaveBeenCalled(); + expect(mockEstimateGasFeeFn).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns transactionResult when allowance is insufficient and adds approve tx', async () => { + // Override Contract mock to simulate low allowance and encode approve data + const encodeFunctionDataMock = jest.fn().mockReturnValue('0xabcdef'); + const instance = { + allowance: jest.fn().mockResolvedValue(BigNumber.from('0')), + interface: { + encodeFunctionData: encodeFunctionDataMock, + }, + } as unknown as Contract; + ContractCtor.mockImplementation(() => instance); + + await withController( + async ({ + controller, + mockService, + mockAddTransactionFn, + mockEstimateGasFeeFn, + baseMessenger, + }) => { + // Register required cross-controller handlers + baseMessenger.registerActionHandler( + 'NetworkController:getState', + (..._args) => + ({ + selectedNetworkClientId: 'test-client-id', + }) as ReturnType, + ); + baseMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (..._args) => + ({ + provider: { request: jest.fn() }, + }) as unknown as ReturnType< + NetworkControllerGetNetworkClientByIdAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + (..._args) => + ({ + address: '0xabc', + }) as ReturnType< + AccountsControllerGetSelectedMultichainAccountAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + (..._args) => + 'test-client-id' as ReturnType< + NetworkControllerFindNetworkClientIdByChainIdAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'GasFeeController:getState', + (..._args) => + ({ + gasFeeEstimates: { estimatedBaseFee: '1' }, + }) as ReturnType, + ); + + // Gas estimates returned by TransactionController.estimateGasFee + mockEstimateGasFeeFn.mockResolvedValue({ + estimates: { + high: { + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x2', + }, + }, + }); + + // Mock transaction addition result + const mockTxResult = { transactionMeta: { id: 'tx-123' } }; + mockAddTransactionFn.mockResolvedValue(mockTxResult); + + // Provide product pricing and crypto payment info with unitDecimals small to avoid integer div to 0 + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 0, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + decimals: 18, + conversionRate: { USD: '1.0' }, + }, + ], + }, + ], + }, + ], + }); + + const result = await controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }); + + expect(result).toStrictEqual({ transactionResult: mockTxResult }); + expect(mockAddTransactionFn).toHaveBeenCalledTimes(1); + expect(mockEstimateGasFeeFn).toHaveBeenCalledTimes(1); + // Ensure approve calldata was built + expect(encodeFunctionDataMock).toHaveBeenCalledWith('approve', [ + '0xspender', + expect.anything(), + ]); + // Ensure the encoded data is used in the transaction params + const [txParamsArg] = mockAddTransactionFn.mock.calls[0]; + expect(txParamsArg).toMatchObject({ + to: '0xtoken', + from: '0xabc', + value: '0', + data: '0xabcdef', + }); + }, + ); + }); + + it('throws when product price not found', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getPriceInfo.mockResolvedValue({ + products: [], + paymentMethods: [], + }); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('Product price not found'); + }); + }); + + it('throws when price not found for interval', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'year', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [], + }); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('Price not found'); + }); + }); + + it('throws when chains payment info not found', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CARD, + }, + ], + }); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('Chains payment info not found'); + }); + }); + + it('throws when invalid chain id', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x2', + paymentAddress: '0xspender', + tokens: [], + }, + ], + }, + ], + }); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('Invalid chain id'); + }); + }); + + it('throws when invalid token address', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xothertoken', + decimals: 18, + conversionRate: { USD: '1.0' }, + }, + ], + }, + ], + }, + ], + }); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('Invalid token address'); + }); + }); + + it('throws when no provider found', async () => { + await withController( + async ({ controller, mockService, baseMessenger }) => { + // Provide full, valid pricing so it reaches provider retrieval + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + decimals: 18, + conversionRate: { USD: '1.0' }, + }, + ], + }, + ], + }, + ], + }); + + // Selected network client id exists, but provider missing + baseMessenger.registerActionHandler( + 'NetworkController:getState', + (..._args) => + ({ + selectedNetworkClientId: 'test-client-id', + }) as ReturnType, + ); + baseMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (..._args) => + ({}) as unknown as ReturnType< + NetworkControllerGetNetworkClientByIdAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + (..._args) => + 'test-client-id' as ReturnType< + NetworkControllerFindNetworkClientIdByChainIdAction['handler'] + >, + ); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('No provider found'); + }, + ); + }); + + it('throws when conversion rate not found', async () => { + await withController( + async ({ controller, mockService, baseMessenger }) => { + // Valid product and chain/token, but token lacks conversion rate for currency + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + decimals: 18, + conversionRate: {}, + }, + ], + }, + ], + }, + ], + }); + + // Set up required cross-controller handlers so we reach conversion rate check + baseMessenger.registerActionHandler( + 'NetworkController:getState', + (..._args) => + ({ + selectedNetworkClientId: 'test-client-id', + }) as ReturnType, + ); + baseMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (..._args) => + ({ + provider: { request: jest.fn() }, + }) as unknown as ReturnType< + NetworkControllerGetNetworkClientByIdAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + (..._args) => + ({ + address: '0xabc', + }) as ReturnType< + AccountsControllerGetSelectedMultichainAccountAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + (..._args) => + 'test-client-id' as ReturnType< + NetworkControllerFindNetworkClientIdByChainIdAction['handler'] + >, + ); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('Conversion rate not found'); + }, + ); + }); + + it('throws when no wallet address found', async () => { + const ContractCtorLocal = Contract as unknown as jest.Mock; + const instance = { + allowance: jest.fn().mockResolvedValue(BigNumber.from('0')), + interface: { encodeFunctionData: jest.fn() }, + } as unknown as Contract; + ContractCtorLocal.mockImplementation(() => instance); + + await withController( + async ({ controller, mockService, baseMessenger }) => { + baseMessenger.registerActionHandler( + 'NetworkController:getState', + (..._args) => + ({ + selectedNetworkClientId: 'test-client-id', + }) as ReturnType, + ); + baseMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (..._args) => + ({ + provider: { request: jest.fn() }, + }) as unknown as ReturnType< + NetworkControllerGetNetworkClientByIdAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + (..._args) => + undefined as ReturnType< + AccountsControllerGetSelectedMultichainAccountAction['handler'] + >, + ); + baseMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + (..._args) => + 'test-client-id' as ReturnType< + NetworkControllerFindNetworkClientIdByChainIdAction['handler'] + >, + ); + + mockService.getPriceInfo.mockResolvedValue({ + products: [ + { + name: ProductType.SHIELD, + prices: [ + { + interval: 'month', + currency: 'USD', + unitAmount: 800, + unitDecimals: 2, + trialPeriodDays: 0, + minBillingCycles: 12, + }, + ], + }, + ], + paymentMethods: [ + { + type: PaymentType.CRYPTO, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + decimals: 18, + conversionRate: { USD: '1.0' }, + }, + ], + }, + ], + }, + ], + }); + + await expect( + controller.createCryptoApproveTransaction({ + chainId: '0x1', + tokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: 'month', + }), + ).rejects.toThrow('No wallet address found'); + }, + ); + }); + }); }); diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 39066e3ccf..e5dd2b76e1 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -1,3 +1,8 @@ +import { BigNumber as EthersBigNumber } from '@ethersproject/bignumber'; +import { BigNumber } from '@ethersproject/bignumber'; +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { AccountsControllerGetSelectedMultichainAccountAction } from '@metamask/accounts-controller'; import { BaseController, type StateMetadata, @@ -5,13 +10,38 @@ import { type ControllerGetStateAction, type RestrictedMessenger, } from '@metamask/base-controller'; +import { toHex } from '@metamask/controller-utils'; +import type { GetGasFeeState } from '@metamask/gas-fee-controller'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { + AutoManagedNetworkClient, + CustomNetworkClientConfiguration, + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import { + type Result, + TransactionType, + type TransactionController, + type TransactionParams, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; import { controllerName, SubscriptionControllerErrorMessage, } from './constants'; -import type { ISubscriptionService, Subscription } from './types'; +import type { PaymentToken, PriceInfo } from './types'; +import { + PaymentType, + type ISubscriptionService, + type PaymentMethodChain, + type ProductType, + type Subscription, +} from './types'; +import { generateActionId, getTxGasEstimates } from './utils'; export type SubscriptionControllerState = { subscriptions: Subscription[]; @@ -37,7 +67,12 @@ export type SubscriptionControllerActions = | SubscriptionControllerGetStateAction; export type AllowedActions = - AuthenticationController.AuthenticationControllerGetBearerToken; + | AuthenticationController.AuthenticationControllerGetBearerToken + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | AccountsControllerGetSelectedMultichainAccountAction + | GetGasFeeState; // Events export type SubscriptionControllerStateChangeEvent = ControllerStateChangeEvent< @@ -74,6 +109,10 @@ export type SubscriptionControllerOptions = { * Subscription service to use for the subscription controller. */ subscriptionService: ISubscriptionService; + + addTransactionFn: typeof TransactionController.prototype.addTransaction; + + estimateGasFeeFn: typeof TransactionController.prototype.estimateGasFee; }; /** @@ -109,6 +148,10 @@ export class SubscriptionController extends BaseController< > { readonly #subscriptionService: ISubscriptionService; + readonly #addTransactionFn: typeof TransactionController.prototype.addTransaction; + + readonly #estimateGasFeeFn: typeof TransactionController.prototype.estimateGasFee; + /** * Creates a new SubscriptionController instance. * @@ -116,11 +159,15 @@ export class SubscriptionController extends BaseController< * @param options.messenger - A restricted messenger. * @param options.state - Initial state to set on this controller. * @param options.subscriptionService - The subscription service for communicating with subscription server. + * @param options.addTransactionFn - The function to add a transaction. + * @param options.estimateGasFeeFn - The function to estimate gas fee. */ constructor({ messenger, state, subscriptionService, + addTransactionFn, + estimateGasFeeFn, }: SubscriptionControllerOptions) { super({ name: controllerName, @@ -133,7 +180,8 @@ export class SubscriptionController extends BaseController< }); this.#subscriptionService = subscriptionService; - + this.#addTransactionFn = addTransactionFn; + this.#estimateGasFeeFn = estimateGasFeeFn; this.#registerMessageHandlers(); } @@ -189,6 +237,252 @@ export class SubscriptionController extends BaseController< return await this.#subscriptionService.getPriceInfo(); } + /** + * Create a crypto approve transaction for subscription payment + * + * @param request - The request object + * @param request.chainId - The chain ID + * @param request.tokenAddress - The address of the token + * @param request.productType - The product type + * @param request.interval - The interval + * @returns The transaction raw or already allowed flag + */ + async createCryptoApproveTransaction(request: { + chainId: string; + tokenAddress: string; + productType: ProductType; + interval: 'month' | 'year'; + }): Promise<{ + alreadyAllowed?: boolean; + transactionResult?: Result; + }> { + const pricing = await this.#subscriptionService.getPriceInfo(); + const product = pricing.products.find( + (p) => p.name === request.productType, + ); + if (!product) { + throw new Error('Product price not found'); + } + + const price = product.prices.find((p) => p.interval === request.interval); + if (!price) { + throw new Error('Price not found'); + } + + const chainsPaymentInfo = pricing.paymentMethods.find( + (t) => t.type === PaymentType.CRYPTO, + ); + if (!chainsPaymentInfo) { + throw new Error('Chains payment info not found'); + } + const chainPaymentInfo = chainsPaymentInfo.chains?.find( + (t) => t.chainId === request.chainId, + ); + if (!chainPaymentInfo) { + throw new Error('Invalid chain id'); + } + const tokenPaymentInfo = chainPaymentInfo.tokens.find( + (t) => t.address === request.tokenAddress, + ); + if (!tokenPaymentInfo) { + throw new Error('Invalid token address'); + } + + const networkClientId = this.messagingSystem.call( + 'NetworkController:findNetworkClientIdByChainId', + request.chainId as Hex, + ); + + const provider = this.#getSelectedNetworkClient()?.provider; + if (!provider) { + throw new Error('No provider found'); + } + + const tokenApproveAmount = this.#getTokenApproveAmount( + price, + tokenPaymentInfo, + ); + // no need to create transaction if already allowed enough amount + const allowance = await this.#getCryptoAllowance({ + tokenAddress: request.tokenAddress, + chainPaymentInfo, + chainId: request.chainId, + provider, + }); + if (allowance.gte(tokenApproveAmount)) { + return { + alreadyAllowed: true, + }; + } + + const spender = chainPaymentInfo.paymentAddress; + + const ethersProvider = new Web3Provider(provider); + const { address: walletAddress } = + this.#getMultichainSelectedAccount() ?? {}; + if (!walletAddress) { + throw new Error('No wallet address found'); + } + + const token = new Contract(request.tokenAddress, abiERC20, ethersProvider); + // Build call data only + const txData = token.interface.encodeFunctionData('approve', [ + spender, + tokenApproveAmount, + ]); + const transactionParams = { + to: request.tokenAddress, + data: txData, + from: walletAddress, + value: '0', + }; + + const actionId = generateActionId().toString(); + const requestOptions = { + actionId, + networkClientId, + requireApproval: true, + type: TransactionType.tokenMethodApprove, + origin: 'metamask', + }; + + const transactionParamsWithMaxGas: TransactionParams = { + ...transactionParams, + ...(await this.#calculateGasFees( + transactionParams, + networkClientId, + request.chainId as Hex, + )), + }; + + const result = await this.#addTransactionFn( + transactionParamsWithMaxGas, + requestOptions, + ); + + return { + transactionResult: result, + }; + } + + readonly #calculateGasFees = async ( + transactionParams: TransactionParams, + networkClientId: string, + chainId: Hex, + ) => { + const { gasFeeEstimates } = this.messagingSystem.call( + 'GasFeeController:getState', + ); + const { estimates: txGasFeeEstimates } = await this.#estimateGasFeeFn({ + transactionParams, + chainId, + networkClientId, + }); + const { maxFeePerGas, maxPriorityFeePerGas } = getTxGasEstimates({ + networkGasFeeEstimates: gasFeeEstimates, + txGasFeeEstimates, + }); + const maxGasLimit = toHex(transactionParams.gas ?? 0); + + return { + maxFeePerGas, + maxPriorityFeePerGas, + gas: maxGasLimit, + }; + }; + + /** + * Get the allowance of the crypto token for payment address + * + * @param request - The request object + * @param request.tokenAddress - The address of the token + * @param request.chainPaymentInfo - The chain payment info + * @param request.chainId - The chain ID + * @param request.provider - The network client provider + * @returns The allowance of the crypto token + */ + async #getCryptoAllowance(request: { + tokenAddress: string; + chainPaymentInfo: PaymentMethodChain; + chainId: string; + provider: AutoManagedNetworkClient['provider']; + }) { + const { provider } = request; + + const ethersProvider = new Web3Provider(provider); + const { address: walletAddress } = + this.#getMultichainSelectedAccount() ?? {}; + const contract = new Contract( + request.tokenAddress, + abiERC20, + ethersProvider, + ); + const allowance: BigNumber = await contract.allowance( + walletAddress, + request.chainPaymentInfo.paymentAddress, + ); + return allowance; + } + + #getMultichainSelectedAccount() { + return this.messagingSystem.call( + 'AccountsController:getSelectedMultichainAccount', + ); + } + + /** + * Calculate total subscription price amount from price info + * e.g: $8 per month * 12 months min billing cycles = $96 + * + * @param price - The price info + * @returns The price amount + */ + #getSubscriptionPriceAmount(price: PriceInfo) { + const amount = EthersBigNumber.from(price.unitAmount) + .div(EthersBigNumber.from(10).pow(price.unitDecimals)) + .mul(price.minBillingCycles); + return amount; + } + + /** + * Calculate token approve amount from price info + * + * @param price - The price info + * @param tokenPaymentInfo - The token price info + * @returns The token approve amount + */ + #getTokenApproveAmount(price: PriceInfo, tokenPaymentInfo: PaymentToken) { + const conversionRate = tokenPaymentInfo.conversionRate[price.currency]; + if (!conversionRate) { + throw new Error('Conversion rate not found'); + } + // conversion rate is a float string e.g: "1.0" + // ether.js bignumber does not support float string, only handle integer string + // we need to convert it to integer string or use bignumber.js + const conversionRateBigNumber = BigNumber.from(Number(conversionRate)); + + const amount = this.#getSubscriptionPriceAmount(price) + .mul(EthersBigNumber.from(10).pow(tokenPaymentInfo.decimals)) + .div(conversionRateBigNumber); + return amount; + } + + #getSelectedNetworkClientId() { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + return selectedNetworkClientId; + } + + #getSelectedNetworkClient() { + const selectedNetworkClientId = this.#getSelectedNetworkClientId(); + const networkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return networkClient; + } + #assertIsUserSubscribed(request: { subscriptionId: string }) { if ( !this.state.subscriptions.find( diff --git a/packages/subscription-controller/src/utils.test.ts b/packages/subscription-controller/src/utils.test.ts new file mode 100644 index 0000000000..ceee2de1b3 --- /dev/null +++ b/packages/subscription-controller/src/utils.test.ts @@ -0,0 +1,123 @@ +import type { GasFeeEstimates } from '@metamask/gas-fee-controller'; +import type { + FeeMarketGasFeeEstimates, + TransactionController, +} from '@metamask/transaction-controller'; + +import { + generateActionId, + getTransaction1559GasFeeEstimates, + getTxGasEstimates, +} from './utils'; + +describe('utils', () => { + describe('generateActionId', () => { + it('returns a deterministic string when time and random are mocked', () => { + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const randomSpy = jest + .spyOn(global.Math, 'random') + .mockReturnValueOnce(0.1) + .mockReturnValueOnce(0.2); + + const id1 = generateActionId(); + const id2 = generateActionId(); + + expect(typeof id1).toBe('string'); + expect(typeof id2).toBe('string'); + expect(id1).toBe('1700000000000.1'); + expect(id2).toBe('1700000000000.2'); + + nowSpy.mockRestore(); + randomSpy.mockRestore(); + }); + + it('generates different values for subsequent calls (sanity)', () => { + const a = generateActionId(); + const b = generateActionId(); + expect(a).not.toBe(b); + }); + }); + + describe('getTransaction1559GasFeeEstimates', () => { + it('computes baseAndPriorityFeePerGas when maxPriorityFeePerGas is provided', () => { + const input = { + high: { + maxFeePerGas: '0x5208', + maxPriorityFeePerGas: 'a', + }, + } as unknown as FeeMarketGasFeeEstimates; + + const estimates = getTransaction1559GasFeeEstimates(input, '2'); + + expect(estimates.maxFeePerGas).toBe('0x5208'); + expect(estimates.maxPriorityFeePerGas).toBe('a'); + // 2 gwei -> 2e9 wei, plus 10 (priority) = 2000000010 + expect(estimates.baseAndPriorityFeePerGas?.toString()).toBe('2000000010'); + }); + + it('returns undefined baseAndPriorityFeePerGas when maxPriorityFeePerGas is missing', () => { + const input = { + high: undefined, + } as unknown as FeeMarketGasFeeEstimates; + + const estimates = getTransaction1559GasFeeEstimates(input, '1'); + + expect(estimates.maxPriorityFeePerGas).toBeUndefined(); + expect(estimates.baseAndPriorityFeePerGas).toBeUndefined(); + }); + }); + + describe('getTxGasEstimates', () => { + it('derives estimates using provided tx and network fee data', () => { + type EstimateReturn = Awaited< + ReturnType + >['estimates']; + + const txGasFeeEstimates = { + high: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: 'a', // 10 (hex) + }, + } as unknown as EstimateReturn; + + const networkGasFeeEstimates = { + estimatedBaseFee: '3', // 3 gwei -> 3e9 wei + } as unknown as GasFeeEstimates; + + const result = getTxGasEstimates({ + txGasFeeEstimates, + networkGasFeeEstimates, + }); + + expect(result.maxFeePerGas).toBe('0x2'); + expect(result.maxPriorityFeePerGas).toBe('a'); + // 3e9 + 10 + expect(result.baseAndPriorityFeePerGas?.toString()).toBe('3000000010'); + }); + + it('defaults estimatedBaseFee to 0 when missing', () => { + type EstimateReturn = Awaited< + ReturnType + >['estimates']; + + const txGasFeeEstimates = { + high: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: 'a', + }, + } as unknown as EstimateReturn; + + const networkGasFeeEstimates = {} as unknown as GasFeeEstimates; + + const result = getTxGasEstimates({ + txGasFeeEstimates, + networkGasFeeEstimates, + }); + + // base=0 gwei -> 0 wei; 0 + 10 = 10 + expect(result.baseAndPriorityFeePerGas?.toString()).toBe('10'); + expect(result.maxFeePerGas).toBe('0x2'); + expect(result.maxPriorityFeePerGas).toBe('a'); + }); + }); +}); diff --git a/packages/subscription-controller/src/utils.ts b/packages/subscription-controller/src/utils.ts new file mode 100644 index 0000000000..897c94f66f --- /dev/null +++ b/packages/subscription-controller/src/utils.ts @@ -0,0 +1,54 @@ +import type { + GasFeeEstimates, + GasFeeState, +} from '@metamask/gas-fee-controller'; +import type { + FeeMarketGasFeeEstimates, + TransactionController, +} from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; + +export const generateActionId = () => (Date.now() + Math.random()).toString(); + +export const getTransaction1559GasFeeEstimates = ( + txGasFeeEstimates: FeeMarketGasFeeEstimates, + estimatedBaseFee: string, +) => { + const { maxFeePerGas, maxPriorityFeePerGas } = txGasFeeEstimates?.high ?? {}; + + const baseAndPriorityFeePerGas = maxPriorityFeePerGas + ? new BigNumber(estimatedBaseFee, 10) + .times(10 ** 9) + .plus(maxPriorityFeePerGas, 16) + : undefined; + + return { + baseAndPriorityFeePerGas, + maxFeePerGas, + maxPriorityFeePerGas, + }; +}; + +/** + * Get the gas fee estimates for a transaction + * + * @param params - The parameters for the gas fee estimates + * @param params.txGasFeeEstimates - The gas fee estimates for the transaction (TransactionController) + * @param params.networkGasFeeEstimates - The gas fee estimates for the network (GasFeeController) + * @returns The gas fee estimates for the transaction + */ +export const getTxGasEstimates = ({ + txGasFeeEstimates, + networkGasFeeEstimates, +}: { + txGasFeeEstimates: Awaited< + ReturnType + >['estimates']; + networkGasFeeEstimates: GasFeeState['gasFeeEstimates']; +}) => { + const { estimatedBaseFee = '0' } = networkGasFeeEstimates as GasFeeEstimates; + return getTransaction1559GasFeeEstimates( + txGasFeeEstimates as FeeMarketGasFeeEstimates, + estimatedBaseFee, + ); +}; diff --git a/packages/subscription-controller/tsconfig.build.json b/packages/subscription-controller/tsconfig.build.json index 470351ab50..73a19c0e7a 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -6,9 +6,21 @@ "rootDir": "./src" }, "references": [ + { + "path": "../accounts-controller/tsconfig.build.json" + }, { "path": "../base-controller/tsconfig.build.json" }, + { + "path": "../gas-fee-controller/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + }, { "path": "../profile-sync-controller/tsconfig.build.json" } diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 4828147b53..95056e6ef2 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -4,12 +4,24 @@ "baseUrl": "./" }, "references": [ + { + "path": "../accounts-controller" + }, { "path": "../base-controller" }, + { + "path": "../gas-fee-controller" + }, + { + "path": "../network-controller" + }, { "path": "../profile-sync-controller" - } + }, + { + "path": "../transaction-controller" + }, ], "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index 0e1019004e..8057d4e538 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1230,6 +1230,17 @@ __metadata: languageName: node linkType: hard +"@ethersproject/units@npm:^5.7.0": + version: 5.8.0 + resolution: "@ethersproject/units@npm:5.8.0" + dependencies: + "@ethersproject/bignumber": "npm:^5.8.0" + "@ethersproject/constants": "npm:^5.8.0" + "@ethersproject/logger": "npm:^5.8.0" + checksum: 10/cc7180c85f695449c20572602971145346fc5c169ee32f23d79ac31cc8c9c66a2049e3ac852b940ddccbe39ab1db3b81e3e093b604d9ab7ab27639ecb933b270 + languageName: node + linkType: hard + "@ethersproject/wallet@npm:^5.7.0": version: 5.8.0 resolution: "@ethersproject/wallet@npm:5.8.0" @@ -4619,11 +4630,22 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@ethersproject/units": "npm:^5.7.0" + "@metamask/accounts-controller": "npm:^33.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.2.0" + "@metamask/controller-utils": "npm:^11.12.0" + "@metamask/gas-fee-controller": "npm:^24.0.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^24.1.0" "@metamask/profile-sync-controller": "npm:^24.0.0" + "@metamask/transaction-controller": "npm:^60.1.0" "@metamask/utils": "npm:^11.4.2" "@types/jest": "npm:^27.4.1" + bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" nock: "npm:^13.3.1" From ceb20ac2b8eebf71eff2b849e883695b3b2678b3 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 11:36:22 +0700 Subject: [PATCH 04/26] refactor: simplify test mock data --- .../src/SubscriptionController.test.ts | 258 +++--------------- 1 file changed, 32 insertions(+), 226 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 419e5ddca2..bb4efe0c6f 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -23,7 +23,7 @@ import { type SubscriptionControllerOptions, type SubscriptionControllerState, } from './SubscriptionController'; -import type { Subscription, PricingResponse, ProductPricing } from './types'; +import type { Subscription, PricingResponse, ProductPricing, PricingPaymentMethod } from './types'; import { PaymentType, ProductType, @@ -41,7 +41,7 @@ const MOCK_SUBSCRIPTION: Subscription = { name: ProductType.SHIELD, id: 'prod_shield_basic', currency: 'usd', - amount: 9.99, + amount: 900, }, ], currentPeriodStart: '2024-01-01T00:00:00Z', @@ -59,17 +59,35 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { { interval: RecurringInterval.month, currency: 'usd', - unitAmount: 9.99, - unitDecimals: 18, + unitAmount: 900, + unitDecimals: 2, trialPeriodDays: 0, minBillingCycles: 1, }, ], }; +const MOCK_PRICING_PAYMENT_METHOD: PricingPaymentMethod = { + type: PaymentType.byCrypto, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + symbol: 'USDT', + decimals: 18, + conversionRate: { usd: '1.0' }, + }, + ], + }, + ], +}; + const MOCK_PRICE_INFO_RESPONSE: PricingResponse = { products: [MOCK_PRODUCT_PRICE], - paymentMethods: [MOCK_SUBSCRIPTION.paymentMethod], + paymentMethods: [MOCK_PRICING_PAYMENT_METHOD], }; /** @@ -663,41 +681,7 @@ describe('SubscriptionController', () => { ); // Provide product pricing and crypto payment info - mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], - paymentMethods: [ - { - type: PaymentType.byCrypto, - chains: [ - { - chainId: '0x1', - paymentAddress: '0xspender', - tokens: [ - { - address: '0xtoken', - decimals: 18, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - ], - }); + mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); const result = await controller.createCryptoApproveTransaction({ chainId: '0x1', @@ -788,41 +772,7 @@ describe('SubscriptionController', () => { mockAddTransactionFn.mockResolvedValue(mockTxResult); // Provide product pricing and crypto payment info with unitDecimals small to avoid integer div to 0 - mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 0, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], - paymentMethods: [ - { - type: PaymentType.byCrypto, - chains: [ - { - chainId: '0x1', - paymentAddress: '0xspender', - tokens: [ - { - address: '0xtoken', - decimals: 18, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - ], - }); + mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); const result = await controller.createCryptoApproveTransaction({ chainId: '0x1', @@ -904,21 +854,7 @@ describe('SubscriptionController', () => { it('throws when chains payment info not found', async () => { await withController(async ({ controller, mockService }) => { mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], + ...MOCK_PRICE_INFO_RESPONSE, paymentMethods: [ { type: PaymentType.byCard, @@ -940,21 +876,7 @@ describe('SubscriptionController', () => { it('throws when invalid chain id', async () => { await withController(async ({ controller, mockService }) => { mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], + ...MOCK_PRICE_INFO_RESPONSE, paymentMethods: [ { type: PaymentType.byCrypto, @@ -982,46 +904,12 @@ describe('SubscriptionController', () => { it('throws when invalid token address', async () => { await withController(async ({ controller, mockService }) => { - mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], - paymentMethods: [ - { - type: PaymentType.byCrypto, - chains: [ - { - chainId: '0x1', - paymentAddress: '0xspender', - tokens: [ - { - address: '0xothertoken', - decimals: 18, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - ], - }); + mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); await expect( controller.createCryptoApproveTransaction({ chainId: '0x1', - tokenAddress: '0xtoken', + tokenAddress: '0xtoken-invalid', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -1033,41 +921,7 @@ describe('SubscriptionController', () => { await withController( async ({ controller, mockService, baseMessenger }) => { // Provide full, valid pricing so it reaches provider retrieval - mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], - paymentMethods: [ - { - type: PaymentType.byCrypto, - chains: [ - { - chainId: '0x1', - paymentAddress: '0xspender', - tokens: [ - { - address: '0xtoken', - decimals: 18, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - ], - }); + mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); // Selected network client id exists, but provider missing baseMessenger.registerActionHandler( @@ -1109,21 +963,7 @@ describe('SubscriptionController', () => { async ({ controller, mockService, baseMessenger }) => { // Valid product and chain/token, but token lacks conversion rate for currency mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - }, - ], - }, - ], + ...MOCK_PRICE_INFO_RESPONSE, paymentMethods: [ { type: PaymentType.byCrypto, @@ -1231,41 +1071,7 @@ describe('SubscriptionController', () => { >, ); - mockService.getPricing.mockResolvedValue({ - products: [ - { - name: ProductType.SHIELD, - prices: [ - { - interval: RecurringInterval.month, - currency: 'usd', - unitAmount: 800, - unitDecimals: 2, - trialPeriodDays: 0, - minBillingCycles: 12, - }, - ], - }, - ], - paymentMethods: [ - { - type: PaymentType.byCrypto, - chains: [ - { - chainId: '0x1', - paymentAddress: '0xspender', - tokens: [ - { - address: '0xtoken', - decimals: 18, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - ], - }); + mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); await expect( controller.createCryptoApproveTransaction({ From a5903c46b0848318cb8b19a067ea10fac157288c Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 11:37:51 +0700 Subject: [PATCH 05/26] feat: register createCryptoApproveTransaction handler --- .../src/SubscriptionController.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 8bd36e1a16..001af40571 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -66,6 +66,10 @@ export type SubscriptionControllerGetPricingAction = { type: `${typeof controllerName}:getPricing`; handler: SubscriptionController['getPricing']; }; +export type SubscriptionControllerCreateCryptoApproveTransactionAction = { + type: `${typeof controllerName}:createCryptoApproveTransaction`; + handler: SubscriptionController['createCryptoApproveTransaction']; +}; export type SubscriptionControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -76,7 +80,8 @@ export type SubscriptionControllerActions = | SubscriptionControllerCancelSubscriptionAction | SubscriptionControllerStartShieldSubscriptionWithCardAction | SubscriptionControllerGetPricingAction - | SubscriptionControllerGetStateAction; + | SubscriptionControllerGetStateAction + | SubscriptionControllerCreateCryptoApproveTransactionAction; export type AllowedActions = | AuthenticationController.AuthenticationControllerGetBearerToken @@ -221,6 +226,11 @@ export class SubscriptionController extends BaseController< 'SubscriptionController:getPricing', this.getPricing.bind(this), ); + + this.messagingSystem.registerActionHandler( + 'SubscriptionController:createCryptoApproveTransaction', + this.createCryptoApproveTransaction.bind(this), + ); } /** From b8bd7720c6324e942fb57dc3b6c124dba897d460 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 12:36:58 +0700 Subject: [PATCH 06/26] feat: add start crypto subscription method --- .../src/SubscriptionController.test.ts | 50 ++++++++++++++++++- .../src/SubscriptionController.ts | 7 ++- .../src/SubscriptionService.test.ts | 32 ++++++++++++ .../src/SubscriptionService.ts | 9 ++++ packages/subscription-controller/src/types.ts | 26 +++++++++- 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index bb4efe0c6f..3f3bae6954 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -23,7 +23,14 @@ import { type SubscriptionControllerOptions, type SubscriptionControllerState, } from './SubscriptionController'; -import type { Subscription, PricingResponse, ProductPricing, PricingPaymentMethod } from './types'; +import type { + Subscription, + PricingResponse, + ProductPricing, + PricingPaymentMethod, + StartCryptoSubscriptionRequest, + StartCryptoSubscriptionResponse, +} from './types'; import { PaymentType, ProductType, @@ -170,12 +177,14 @@ function createMockSubscriptionService() { const mockCancelSubscription = jest.fn(); const mockStartSubscriptionWithCard = jest.fn(); const mockGetPricing = jest.fn(); + const mockStartCryptoSubscription = jest.fn(); const mockService = { getSubscriptions: mockGetSubscriptions, cancelSubscription: mockCancelSubscription, startSubscriptionWithCard: mockStartSubscriptionWithCard, getPricing: mockGetPricing, + startCryptoSubscription: mockStartCryptoSubscription, }; return { @@ -184,6 +193,7 @@ function createMockSubscriptionService() { mockCancelSubscription, mockStartSubscriptionWithCard, mockGetPricing, + mockStartCryptoSubscription, }; } @@ -567,6 +577,44 @@ describe('SubscriptionController', () => { }); }); + describe('startCryptoSubscription', () => { + it('should start crypto subscription successfully when user is not subscribed', async () => { + await withController( + { + state: { + subscriptions: [], + }, + }, + async ({ controller, mockService }) => { + const request: StartCryptoSubscriptionRequest = { + products: [ProductType.SHIELD], + isTrialRequested: false, + recurringInterval: RecurringInterval.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }; + + const response: StartCryptoSubscriptionResponse = { + subscriptionId: 'sub_crypto_123', + status: SubscriptionStatus.active, + }; + + mockService.startCryptoSubscription.mockResolvedValue(response); + + const result = await controller.startCryptoSubscription(request); + + expect(result).toStrictEqual(response); + expect(mockService.startCryptoSubscription).toHaveBeenCalledWith( + request, + ); + }, + ); + }); + }); + describe('integration scenarios', () => { it('should handle complete subscription lifecycle with updated logic', async () => { await withController(async ({ controller, mockService }) => { diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 001af40571..487c6b1021 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -33,7 +33,7 @@ import { controllerName, SubscriptionControllerErrorMessage, } from './constants'; -import type { ChainPaymentInfo, ProductPrice, TokenPaymentInfo } from './types'; +import type { ChainPaymentInfo, ProductPrice, StartCryptoSubscriptionRequest, TokenPaymentInfo } from './types'; import { PaymentType, SubscriptionStatus, @@ -275,6 +275,11 @@ export class SubscriptionController extends BaseController< return await this.#subscriptionService.startSubscriptionWithCard(request); } + async startCryptoSubscription(request: StartCryptoSubscriptionRequest) { + this.#assertIsUserNotSubscribed({ products: request.products }); + return await this.#subscriptionService.startCryptoSubscription(request); + } + /** * Create a crypto approve transaction for subscription payment * diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 5e05629b02..7a69008235 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -10,6 +10,7 @@ import { SubscriptionServiceError } from './errors'; import { SubscriptionService } from './SubscriptionService'; import type { StartSubscriptionRequest, + StartCryptoSubscriptionRequest, Subscription, PricingResponse, } from './types'; @@ -278,6 +279,37 @@ describe('SubscriptionService', () => { }); }); + describe('startCryptoSubscription', () => { + it('should start crypto subscription successfully', async () => { + await withMockSubscriptionService(async ({ service, testUrl }) => { + const request: StartCryptoSubscriptionRequest = { + products: [ProductType.SHIELD], + isTrialRequested: false, + recurringInterval: RecurringInterval.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }; + + const response = { + subscriptionId: 'sub_crypto_123', + status: SubscriptionStatus.active, + }; + + nock(testUrl) + .post('/v1/subscriptions/crypto', request) + .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) + .reply(200, response); + + const result = await service.startCryptoSubscription(request); + + expect(result).toStrictEqual(response); + }); + }); + }); + describe('getPricing', () => { const mockPricingResponse: PricingResponse = { products: [], diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index c1c8825cfe..0456f08c1c 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -10,6 +10,8 @@ import type { GetSubscriptionsResponse, ISubscriptionService, PricingResponse, + StartCryptoSubscriptionRequest, + StartCryptoSubscriptionResponse, StartSubscriptionRequest, StartSubscriptionResponse, } from './types'; @@ -59,6 +61,13 @@ export class SubscriptionService implements ISubscriptionService { return await this.#makeRequest(path, 'POST', request); } + async startCryptoSubscription( + request: StartCryptoSubscriptionRequest, + ): Promise { + const path = 'subscriptions/crypto'; + return await this.#makeRequest(path, 'POST', request); + } + async getPricing(): Promise { const path = 'pricing'; return await this.#makeRequest(path); diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 8aab9e029f..b94fd59656 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -1,3 +1,5 @@ +import type { Hex } from '@metamask/utils'; + export enum ProductType { SHIELD = 'shield', } @@ -76,6 +78,25 @@ export type StartSubscriptionResponse = { checkoutSessionUrl: string; }; +export type StartCryptoSubscriptionRequest = { + products: ProductType[]; + isTrialRequested: boolean; + recurringInterval: RecurringInterval; + billingCycles: number; + chainId: string; + payerAddress: string; + /** + * e.g. "USDC" + */ + tokenSymbol: string; + rawTransaction: Hex; +}; + +export type StartCryptoSubscriptionResponse = { + subscriptionId: string; + status: SubscriptionStatus; +}; + export type AuthUtils = { getAccessToken: () => Promise; }; @@ -133,8 +154,11 @@ export type PricingResponse = { export type ISubscriptionService = { getSubscriptions(): Promise; cancelSubscription(request: { subscriptionId: string }): Promise; + getPricing(): Promise; startSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise; - getPricing(): Promise; + startCryptoSubscription( + request: StartCryptoSubscriptionRequest, + ): Promise; }; From 1141428e0c743bfa5d599d478e63161bbed253d9 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 12:38:58 +0700 Subject: [PATCH 07/26] feat: register start crypto subscription handler --- .../src/SubscriptionController.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 487c6b1021..753f920ca4 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -33,7 +33,12 @@ import { controllerName, SubscriptionControllerErrorMessage, } from './constants'; -import type { ChainPaymentInfo, ProductPrice, StartCryptoSubscriptionRequest, TokenPaymentInfo } from './types'; +import type { + ChainPaymentInfo, + ProductPrice, + StartCryptoSubscriptionRequest, + TokenPaymentInfo, +} from './types'; import { PaymentType, SubscriptionStatus, @@ -70,6 +75,10 @@ export type SubscriptionControllerCreateCryptoApproveTransactionAction = { type: `${typeof controllerName}:createCryptoApproveTransaction`; handler: SubscriptionController['createCryptoApproveTransaction']; }; +export type SubscriptionControllerStartCryptoSubscriptionAction = { + type: `${typeof controllerName}:startCryptoSubscription`; + handler: SubscriptionController['startCryptoSubscription']; +}; export type SubscriptionControllerGetStateAction = ControllerGetStateAction< typeof controllerName, @@ -81,7 +90,8 @@ export type SubscriptionControllerActions = | SubscriptionControllerStartShieldSubscriptionWithCardAction | SubscriptionControllerGetPricingAction | SubscriptionControllerGetStateAction - | SubscriptionControllerCreateCryptoApproveTransactionAction; + | SubscriptionControllerCreateCryptoApproveTransactionAction + | SubscriptionControllerStartCryptoSubscriptionAction; export type AllowedActions = | AuthenticationController.AuthenticationControllerGetBearerToken @@ -231,6 +241,11 @@ export class SubscriptionController extends BaseController< 'SubscriptionController:createCryptoApproveTransaction', this.createCryptoApproveTransaction.bind(this), ); + + this.messagingSystem.registerActionHandler( + 'SubscriptionController:startCryptoSubscription', + this.startCryptoSubscription.bind(this), + ); } /** From 8d8f0c8ba9f61fdda566a6ced07ff5441cbb5ce3 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 12:50:23 +0700 Subject: [PATCH 08/26] feat: correct type name and export --- .../src/SubscriptionController.test.ts | 28 +++++++-------- .../src/SubscriptionController.ts | 34 ++++++++----------- .../src/SubscriptionService.test.ts | 2 +- .../src/SubscriptionService.ts | 2 +- packages/subscription-controller/src/index.ts | 11 +++++- packages/subscription-controller/src/types.ts | 24 ++++++++++++- 6 files changed, 64 insertions(+), 37 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 3f3bae6954..cb6fda0980 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -177,14 +177,14 @@ function createMockSubscriptionService() { const mockCancelSubscription = jest.fn(); const mockStartSubscriptionWithCard = jest.fn(); const mockGetPricing = jest.fn(); - const mockStartCryptoSubscription = jest.fn(); + const mockStartSubscriptionWithCrypto = jest.fn(); const mockService = { getSubscriptions: mockGetSubscriptions, cancelSubscription: mockCancelSubscription, startSubscriptionWithCard: mockStartSubscriptionWithCard, getPricing: mockGetPricing, - startCryptoSubscription: mockStartCryptoSubscription, + startSubscriptionWithCrypto: mockStartSubscriptionWithCrypto, }; return { @@ -193,7 +193,7 @@ function createMockSubscriptionService() { mockCancelSubscription, mockStartSubscriptionWithCard, mockGetPricing, - mockStartCryptoSubscription, + mockStartSubscriptionWithCrypto, }; } @@ -602,12 +602,12 @@ describe('SubscriptionController', () => { status: SubscriptionStatus.active, }; - mockService.startCryptoSubscription.mockResolvedValue(response); + mockService.startSubscriptionWithCrypto.mockResolvedValue(response); - const result = await controller.startCryptoSubscription(request); + const result = await controller.startSubscriptionWithCrypto(request); expect(result).toStrictEqual(response); - expect(mockService.startCryptoSubscription).toHaveBeenCalledWith( + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( request, ); }, @@ -685,7 +685,7 @@ describe('SubscriptionController', () => { ContractCtor.mockImplementation(() => instance); }); - it('returns alreadyAllowed when allowance is sufficient', async () => { + it('returns undefined when allowance is sufficient', async () => { await withController( async ({ controller, @@ -735,10 +735,10 @@ describe('SubscriptionController', () => { chainId: '0x1', tokenAddress: '0xtoken', productType: ProductType.SHIELD, - interval: 'month', + interval: RecurringInterval.month, }); - expect(result).toStrictEqual({ alreadyAllowed: true }); + expect(result).toStrictEqual({ transactionResult: undefined }); expect(mockAddTransactionFn).not.toHaveBeenCalled(); expect(mockEstimateGasFeeFn).not.toHaveBeenCalled(); }, @@ -826,7 +826,7 @@ describe('SubscriptionController', () => { chainId: '0x1', tokenAddress: '0xtoken', productType: ProductType.SHIELD, - interval: 'month', + interval: RecurringInterval.month, }); expect(result).toStrictEqual({ transactionResult: mockTxResult }); @@ -893,7 +893,7 @@ describe('SubscriptionController', () => { chainId: '0x1', tokenAddress: '0xtoken', productType: ProductType.SHIELD, - interval: 'month', + interval: RecurringInterval.month, }), ).rejects.toThrow('Price not found'); }); @@ -999,7 +999,7 @@ describe('SubscriptionController', () => { chainId: '0x1', tokenAddress: '0xtoken', productType: ProductType.SHIELD, - interval: 'month', + interval: RecurringInterval.month, }), ).rejects.toThrow('No provider found'); }, @@ -1071,7 +1071,7 @@ describe('SubscriptionController', () => { chainId: '0x1', tokenAddress: '0xtoken', productType: ProductType.SHIELD, - interval: 'month', + interval: RecurringInterval.month, }), ).rejects.toThrow('Conversion rate not found'); }, @@ -1126,7 +1126,7 @@ describe('SubscriptionController', () => { chainId: '0x1', tokenAddress: '0xtoken', productType: ProductType.SHIELD, - interval: 'month', + interval: RecurringInterval.month, }), ).rejects.toThrow('No wallet address found'); }, diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index 753f920ca4..cd7cd7f5a8 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -22,7 +22,6 @@ import type { } from '@metamask/network-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { - type Result, TransactionType, type TransactionController, type TransactionParams, @@ -35,6 +34,8 @@ import { } from './constants'; import type { ChainPaymentInfo, + CreateCryptoApproveTransactionRequest, + CreateCryptoApproveTransactionResponse, ProductPrice, StartCryptoSubscriptionRequest, TokenPaymentInfo, @@ -75,9 +76,9 @@ export type SubscriptionControllerCreateCryptoApproveTransactionAction = { type: `${typeof controllerName}:createCryptoApproveTransaction`; handler: SubscriptionController['createCryptoApproveTransaction']; }; -export type SubscriptionControllerStartCryptoSubscriptionAction = { - type: `${typeof controllerName}:startCryptoSubscription`; - handler: SubscriptionController['startCryptoSubscription']; +export type SubscriptionControllerStartSubscriptionWithCryptoAction = { + type: `${typeof controllerName}:startSubscriptionWithCrypto`; + handler: SubscriptionController['startSubscriptionWithCrypto']; }; export type SubscriptionControllerGetStateAction = ControllerGetStateAction< @@ -91,7 +92,7 @@ export type SubscriptionControllerActions = | SubscriptionControllerGetPricingAction | SubscriptionControllerGetStateAction | SubscriptionControllerCreateCryptoApproveTransactionAction - | SubscriptionControllerStartCryptoSubscriptionAction; + | SubscriptionControllerStartSubscriptionWithCryptoAction; export type AllowedActions = | AuthenticationController.AuthenticationControllerGetBearerToken @@ -243,8 +244,8 @@ export class SubscriptionController extends BaseController< ); this.messagingSystem.registerActionHandler( - 'SubscriptionController:startCryptoSubscription', - this.startCryptoSubscription.bind(this), + 'SubscriptionController:startSubscriptionWithCrypto', + this.startSubscriptionWithCrypto.bind(this), ); } @@ -290,13 +291,14 @@ export class SubscriptionController extends BaseController< return await this.#subscriptionService.startSubscriptionWithCard(request); } - async startCryptoSubscription(request: StartCryptoSubscriptionRequest) { + async startSubscriptionWithCrypto(request: StartCryptoSubscriptionRequest) { this.#assertIsUserNotSubscribed({ products: request.products }); - return await this.#subscriptionService.startCryptoSubscription(request); + return await this.#subscriptionService.startSubscriptionWithCrypto(request); } /** * Create a crypto approve transaction for subscription payment + * Return undefined if allowance amount is already allowed. * * @param request - The request object * @param request.chainId - The chain ID @@ -305,15 +307,9 @@ export class SubscriptionController extends BaseController< * @param request.interval - The interval * @returns The transaction raw or already allowed flag */ - async createCryptoApproveTransaction(request: { - chainId: string; - tokenAddress: string; - productType: ProductType; - interval: 'month' | 'year'; - }): Promise<{ - alreadyAllowed?: boolean; - transactionResult?: Result; - }> { + async createCryptoApproveTransaction( + request: CreateCryptoApproveTransactionRequest, + ): Promise { const pricing = await this.#subscriptionService.getPricing(); const product = pricing.products.find( (p) => p.name === request.productType, @@ -369,7 +365,7 @@ export class SubscriptionController extends BaseController< }); if (allowance.gte(tokenApproveAmount)) { return { - alreadyAllowed: true, + transactionResult: undefined, }; } diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 7a69008235..d70c9f3336 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -303,7 +303,7 @@ describe('SubscriptionService', () => { .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) .reply(200, response); - const result = await service.startCryptoSubscription(request); + const result = await service.startSubscriptionWithCrypto(request); expect(result).toStrictEqual(response); }); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 0456f08c1c..0fc79fef85 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -61,7 +61,7 @@ export class SubscriptionService implements ISubscriptionService { return await this.#makeRequest(path, 'POST', request); } - async startCryptoSubscription( + async startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise { const path = 'subscriptions/crypto'; diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 4f20c55206..50434f3043 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -6,6 +6,8 @@ export type { SubscriptionControllerCancelSubscriptionAction, SubscriptionControllerStartShieldSubscriptionWithCardAction, SubscriptionControllerGetPricingAction, + SubscriptionControllerCreateCryptoApproveTransactionAction, + SubscriptionControllerStartSubscriptionWithCryptoAction, SubscriptionControllerGetStateAction, SubscriptionControllerMessenger, SubscriptionControllerOptions, @@ -19,7 +21,14 @@ export type { Subscription, AuthUtils, ISubscriptionService, - PaymentMethod, + StartCryptoSubscriptionRequest, + StartCryptoSubscriptionResponse, + StartSubscriptionRequest, + StartSubscriptionResponse, + CreateCryptoApproveTransactionRequest, + CreateCryptoApproveTransactionResponse, + RecurringInterval, + SubscriptionStatus, PaymentType, Product, ProductType, diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index b94fd59656..6ea1dd3eb7 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -1,3 +1,4 @@ +import type { Result } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; export enum ProductType { @@ -151,6 +152,27 @@ export type PricingResponse = { paymentMethods: PricingPaymentMethod[]; }; +export type CreateCryptoApproveTransactionRequest = { + /** + * payment chain ID + */ + chainId: string; + /** + * Payment token address + */ + tokenAddress: string; + productType: ProductType; + interval: RecurringInterval; +}; + +export type CreateCryptoApproveTransactionResponse = { + /** + * Transaction result. + * Return undefined if allowance is already allowed. + */ + transactionResult?: Result; +}; + export type ISubscriptionService = { getSubscriptions(): Promise; cancelSubscription(request: { subscriptionId: string }): Promise; @@ -158,7 +180,7 @@ export type ISubscriptionService = { startSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise; - startCryptoSubscription( + startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise; }; From ede07ec2f59444b7884acff8e3486d39bddc846d Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 14:46:33 +0700 Subject: [PATCH 09/26] chore: update changelog --- packages/subscription-controller/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index bb4c4a2934..56c7115fa9 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -14,5 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `cancelSubscription`: Cancel user active subscription. - `startShieldSubscriptionWithCard`: start shield subscription via card (with trial option) ([#6300](https://github.com/MetaMask/core/pull/6300)) - Add `getPricing` method ([#6356](https://github.com/MetaMask/core/pull/6356)) +- Add `startSubscriptionWithCrypto` method ([#6456](https://github.com/MetaMask/core/pull/6456)) +- Add `createCryptoApproveTransaction` method ([#6456](https://github.com/MetaMask/core/pull/6456)) [Unreleased]: https://github.com/MetaMask/core/ From f938bb2204f7ab19d65a2956680b09f9659d99cb Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 15:02:59 +0700 Subject: [PATCH 10/26] fix: tsconfig --- packages/subscription-controller/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 95056e6ef2..e5ddc0f834 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -21,7 +21,7 @@ }, { "path": "../transaction-controller" - }, + } ], "include": ["../../types", "./src", "./tests"] } From e49aed441b404271f56de5bfeb7ee4ee099e1afd Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 15:18:17 +0700 Subject: [PATCH 11/26] fix: correct tsconfig --- packages/subscription-controller/tsconfig.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index e5ddc0f834..43416da8e1 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -10,17 +10,17 @@ { "path": "../base-controller" }, - { + { "path": "../gas-fee-controller" }, { - "path": "../network-controller" + "path": "../network-controller" }, { "path": "../profile-sync-controller" }, - { - "path": "../transaction-controller" + { + "path": "../transaction-controller" } ], "include": ["../../types", "./src", "./tests"] From fad2fea57cb9fd3c0b23f1287d0fc8b6f089a522 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 4 Sep 2025 15:21:38 +0700 Subject: [PATCH 12/26] chore: upgrade transaction-controller dependencies version --- packages/subscription-controller/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 16d7572901..dc272f1da4 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -63,7 +63,7 @@ "@metamask/gas-fee-controller": "^24.0.0", "@metamask/network-controller": "^24.1.0", "@metamask/profile-sync-controller": "^24.0.0", - "@metamask/transaction-controller": "^60.1.0", + "@metamask/transaction-controller": "^60.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/yarn.lock b/yarn.lock index 23d8ab2064..858fc5a225 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4664,7 +4664,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^24.1.0" "@metamask/profile-sync-controller": "npm:^24.0.0" - "@metamask/transaction-controller": "npm:^60.1.0" + "@metamask/transaction-controller": "npm:^60.2.0" "@metamask/utils": "npm:^11.4.2" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" @@ -4728,7 +4728,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^60.1.0, @metamask/transaction-controller@npm:^60.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^60.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: From 2634ca8a77f03d75e7a6c131916e1d742a871552 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 5 Sep 2025 15:03:42 +0700 Subject: [PATCH 13/26] feat: use getCryptoApproveTransactionParams instead of creating transaciton in controller --- packages/subscription-controller/CHANGELOG.md | 2 +- packages/subscription-controller/package.json | 9 - .../src/SubscriptionController.test.ts | 385 ++---------------- .../src/SubscriptionController.ts | 243 ++--------- packages/subscription-controller/src/index.ts | 6 +- packages/subscription-controller/src/types.ts | 16 +- .../subscription-controller/src/utils.test.ts | 123 ------ packages/subscription-controller/src/utils.ts | 54 --- .../tsconfig.build.json | 12 - .../subscription-controller/tsconfig.json | 12 - yarn.lock | 20 - 11 files changed, 72 insertions(+), 810 deletions(-) delete mode 100644 packages/subscription-controller/src/utils.test.ts delete mode 100644 packages/subscription-controller/src/utils.ts diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 56c7115fa9..635db695a1 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -15,6 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `startShieldSubscriptionWithCard`: start shield subscription via card (with trial option) ([#6300](https://github.com/MetaMask/core/pull/6300)) - Add `getPricing` method ([#6356](https://github.com/MetaMask/core/pull/6356)) - Add `startSubscriptionWithCrypto` method ([#6456](https://github.com/MetaMask/core/pull/6456)) -- Add `createCryptoApproveTransaction` method ([#6456](https://github.com/MetaMask/core/pull/6456)) +- Add `getCryptoApproveTransactionParams` method ([#6456](https://github.com/MetaMask/core/pull/6456)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 8da033ea6b..8e38002076 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -47,23 +47,14 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@ethersproject/bignumber": "^5.7.0", - "@ethersproject/contracts": "^5.7.0", - "@ethersproject/providers": "^5.7.0", - "@ethersproject/units": "^5.7.0", "@metamask/base-controller": "^8.3.0", "@metamask/controller-utils": "^11.12.0", - "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/utils": "^11.4.2", "bignumber.js": "^9.1.2" }, "devDependencies": { - "@metamask/accounts-controller": "^33.0.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/gas-fee-controller": "^24.0.0", - "@metamask/network-controller": "^24.1.0", "@metamask/profile-sync-controller": "^24.0.0", - "@metamask/transaction-controller": "^60.2.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index cb6fda0980..3cbcabf560 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -1,13 +1,4 @@ -import { BigNumber } from '@ethersproject/bignumber'; -import { Contract } from '@ethersproject/contracts'; -import type { AccountsControllerGetSelectedMultichainAccountAction } from '@metamask/accounts-controller'; import { Messenger } from '@metamask/base-controller'; -import type { GetGasFeeState } from '@metamask/gas-fee-controller'; -import type { - NetworkControllerFindNetworkClientIdByChainIdAction, - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerGetStateAction, -} from '@metamask/network-controller'; import { controllerName, @@ -38,8 +29,6 @@ import { SubscriptionStatus, } from './types'; -jest.mock('@ethersproject/contracts'); - // Mock data const MOCK_SUBSCRIPTION: Subscription = { id: 'sub_123456789', @@ -115,14 +104,7 @@ function createCustomSubscriptionMessenger(props?: { AllowedEvents['type'] >({ name: controllerName, - allowedActions: [ - 'AuthenticationController:getBearerToken', - 'NetworkController:getState', - 'NetworkController:getNetworkClientById', - 'NetworkController:findNetworkClientIdByChainId', - 'AccountsController:getSelectedMultichainAccount', - 'GasFeeController:getState', - ], + allowedActions: ['AuthenticationController:getBearerToken'], allowedEvents: props?.overrideEvents ?? [ 'AuthenticationController:stateChange', ], @@ -206,8 +188,6 @@ type WithControllerCallback = (params: { messenger: SubscriptionControllerMessenger; baseMessenger: Messenger; mockService: ReturnType['mockService']; - mockAddTransactionFn: jest.Mock; - mockEstimateGasFeeFn: jest.Mock; }) => Promise | ReturnValue; type WithControllerOptions = Partial; @@ -228,14 +208,10 @@ async function withController( const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; const { messenger, baseMessenger } = createMockSubscriptionMessenger(); const { mockService } = createMockSubscriptionService(); - const mockAddTransactionFn = jest.fn(); - const mockEstimateGasFeeFn = jest.fn(); const controller = new SubscriptionController({ messenger, subscriptionService: mockService, - addTransactionFn: mockAddTransactionFn, - estimateGasFeeFn: mockEstimateGasFeeFn, ...rest, }); @@ -245,17 +221,10 @@ async function withController( messenger, baseMessenger, mockService, - mockAddTransactionFn, - mockEstimateGasFeeFn, }); } describe('SubscriptionController', () => { - beforeEach(() => { - // Clear all instances and calls to constructor and all methods: - (Contract as unknown as jest.Mock).mockClear(); - }); - describe('constructor', () => { it('should be able to instantiate with default options', async () => { await withController(async ({ controller }) => { @@ -271,15 +240,11 @@ describe('SubscriptionController', () => { const initialState: Partial = { subscriptions: [MOCK_SUBSCRIPTION], }; - const mockAddTransactionFn = jest.fn(); - const mockEstimateGasFeeFn = jest.fn(); const controller = new SubscriptionController({ messenger, state: initialState, subscriptionService: mockService, - addTransactionFn: mockAddTransactionFn, - estimateGasFeeFn: mockEstimateGasFeeFn, }); expect(controller).toBeDefined(); @@ -289,14 +254,10 @@ describe('SubscriptionController', () => { it('should be able to instantiate with custom subscription service', () => { const { messenger } = createMockSubscriptionMessenger(); const { mockService } = createMockSubscriptionService(); - const mockAddTransactionFn = jest.fn(); - const mockEstimateGasFeeFn = jest.fn(); const controller = new SubscriptionController({ messenger, subscriptionService: mockService, - addTransactionFn: mockAddTransactionFn, - estimateGasFeeFn: mockEstimateGasFeeFn, }); expect(controller).toBeDefined(); @@ -673,180 +634,26 @@ describe('SubscriptionController', () => { }); }); - describe('createCryptoApproveTransaction', () => { - const ContractCtor = Contract as unknown as jest.Mock; - beforeEach(() => { - // ethers.js Contract method is added at runtime depend on abi so we need to mock it dynamically here - const instance = { - allowance: jest - .fn() - .mockResolvedValue(BigNumber.from('1000000000000000000000')), - } as unknown as Contract; - ContractCtor.mockImplementation(() => instance); - }); - - it('returns undefined when allowance is sufficient', async () => { - await withController( - async ({ - controller, - mockService, - mockAddTransactionFn, - mockEstimateGasFeeFn, - baseMessenger, - }) => { - // Register mocks for required cross-controller actions on the provided base messenger - baseMessenger.registerActionHandler( - 'NetworkController:getState', - (..._args) => - ({ - selectedNetworkClientId: 'test-client-id', - }) as ReturnType, - ); - baseMessenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - (..._args) => - ({ - provider: { request: jest.fn() }, - }) as unknown as ReturnType< - NetworkControllerGetNetworkClientByIdAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'AccountsController:getSelectedMultichainAccount', - (..._args) => - ({ - address: '0xabc', - }) as ReturnType< - AccountsControllerGetSelectedMultichainAccountAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'NetworkController:findNetworkClientIdByChainId', - (..._args) => - 'test-client-id' as ReturnType< - NetworkControllerFindNetworkClientIdByChainIdAction['handler'] - >, - ); - - // Provide product pricing and crypto payment info - mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); - - const result = await controller.createCryptoApproveTransaction({ - chainId: '0x1', - tokenAddress: '0xtoken', - productType: ProductType.SHIELD, - interval: RecurringInterval.month, - }); - - expect(result).toStrictEqual({ transactionResult: undefined }); - expect(mockAddTransactionFn).not.toHaveBeenCalled(); - expect(mockEstimateGasFeeFn).not.toHaveBeenCalled(); - }, - ); - }); - - it('returns transactionResult when allowance is insufficient and adds approve tx', async () => { - // Override Contract mock to simulate low allowance and encode approve data - const encodeFunctionDataMock = jest.fn().mockReturnValue('0xabcdef'); - const instance = { - allowance: jest.fn().mockResolvedValue(BigNumber.from('0')), - interface: { - encodeFunctionData: encodeFunctionDataMock, - }, - } as unknown as Contract; - ContractCtor.mockImplementation(() => instance); - - await withController( - async ({ - controller, - mockService, - mockAddTransactionFn, - mockEstimateGasFeeFn, - baseMessenger, - }) => { - // Register required cross-controller handlers - baseMessenger.registerActionHandler( - 'NetworkController:getState', - (..._args) => - ({ - selectedNetworkClientId: 'test-client-id', - }) as ReturnType, - ); - baseMessenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - (..._args) => - ({ - provider: { request: jest.fn() }, - }) as unknown as ReturnType< - NetworkControllerGetNetworkClientByIdAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'AccountsController:getSelectedMultichainAccount', - (..._args) => - ({ - address: '0xabc', - }) as ReturnType< - AccountsControllerGetSelectedMultichainAccountAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'NetworkController:findNetworkClientIdByChainId', - (..._args) => - 'test-client-id' as ReturnType< - NetworkControllerFindNetworkClientIdByChainIdAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'GasFeeController:getState', - (..._args) => - ({ - gasFeeEstimates: { estimatedBaseFee: '1' }, - }) as ReturnType, - ); - - // Gas estimates returned by TransactionController.estimateGasFee - mockEstimateGasFeeFn.mockResolvedValue({ - estimates: { - high: { - maxFeePerGas: '0x1', - maxPriorityFeePerGas: '0x2', - }, - }, - }); - - // Mock transaction addition result - const mockTxResult = { transactionMeta: { id: 'tx-123' } }; - mockAddTransactionFn.mockResolvedValue(mockTxResult); - - // Provide product pricing and crypto payment info with unitDecimals small to avoid integer div to 0 - mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); + describe('getCryptoApproveTransactionParams', () => { + it('returns transaction params for crypto approve transaction', async () => { + await withController(async ({ controller, mockService }) => { + // Provide product pricing and crypto payment info with unitDecimals small to avoid integer div to 0 + mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); - const result = await controller.createCryptoApproveTransaction({ - chainId: '0x1', - tokenAddress: '0xtoken', - productType: ProductType.SHIELD, - interval: RecurringInterval.month, - }); + const result = await controller.getCryptoApproveTransactionParams({ + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: RecurringInterval.month, + }); - expect(result).toStrictEqual({ transactionResult: mockTxResult }); - expect(mockAddTransactionFn).toHaveBeenCalledTimes(1); - expect(mockEstimateGasFeeFn).toHaveBeenCalledTimes(1); - // Ensure approve calldata was built - expect(encodeFunctionDataMock).toHaveBeenCalledWith('approve', [ - '0xspender', - expect.anything(), - ]); - // Ensure the encoded data is used in the transaction params - const [txParamsArg] = mockAddTransactionFn.mock.calls[0]; - expect(txParamsArg).toMatchObject({ - to: '0xtoken', - from: '0xabc', - value: '0', - data: '0xabcdef', - }); - }, - ); + expect(result).toStrictEqual({ + approveAmount: '9000000000000000000', + spenderAddress: '0xspender', + paymentTokenAddress: '0xtoken', + chainId: '0x1', + }); + }); }); it('throws when product price not found', async () => { @@ -857,9 +664,9 @@ describe('SubscriptionController', () => { }); await expect( - controller.createCryptoApproveTransaction({ + controller.getCryptoApproveTransactionParams({ chainId: '0x1', - tokenAddress: '0xtoken', + paymentTokenAddress: '0xtoken', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -889,9 +696,9 @@ describe('SubscriptionController', () => { }); await expect( - controller.createCryptoApproveTransaction({ + controller.getCryptoApproveTransactionParams({ chainId: '0x1', - tokenAddress: '0xtoken', + paymentTokenAddress: '0xtoken', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -911,9 +718,9 @@ describe('SubscriptionController', () => { }); await expect( - controller.createCryptoApproveTransaction({ + controller.getCryptoApproveTransactionParams({ chainId: '0x1', - tokenAddress: '0xtoken', + paymentTokenAddress: '0xtoken', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -940,9 +747,9 @@ describe('SubscriptionController', () => { }); await expect( - controller.createCryptoApproveTransaction({ + controller.getCryptoApproveTransactionParams({ chainId: '0x1', - tokenAddress: '0xtoken', + paymentTokenAddress: '0xtoken', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -955,9 +762,9 @@ describe('SubscriptionController', () => { mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); await expect( - controller.createCryptoApproveTransaction({ + controller.getCryptoApproveTransactionParams({ chainId: '0x1', - tokenAddress: '0xtoken-invalid', + paymentTokenAddress: '0xtoken-invalid', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -965,47 +772,6 @@ describe('SubscriptionController', () => { }); }); - it('throws when no provider found', async () => { - await withController( - async ({ controller, mockService, baseMessenger }) => { - // Provide full, valid pricing so it reaches provider retrieval - mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); - - // Selected network client id exists, but provider missing - baseMessenger.registerActionHandler( - 'NetworkController:getState', - (..._args) => - ({ - selectedNetworkClientId: 'test-client-id', - }) as ReturnType, - ); - baseMessenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - (..._args) => - ({}) as unknown as ReturnType< - NetworkControllerGetNetworkClientByIdAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'NetworkController:findNetworkClientIdByChainId', - (..._args) => - 'test-client-id' as ReturnType< - NetworkControllerFindNetworkClientIdByChainIdAction['handler'] - >, - ); - - await expect( - controller.createCryptoApproveTransaction({ - chainId: '0x1', - tokenAddress: '0xtoken', - productType: ProductType.SHIELD, - interval: RecurringInterval.month, - }), - ).rejects.toThrow('No provider found'); - }, - ); - }); - it('throws when conversion rate not found', async () => { await withController( async ({ controller, mockService, baseMessenger }) => { @@ -1032,44 +798,10 @@ describe('SubscriptionController', () => { ], }); - // Set up required cross-controller handlers so we reach conversion rate check - baseMessenger.registerActionHandler( - 'NetworkController:getState', - (..._args) => - ({ - selectedNetworkClientId: 'test-client-id', - }) as ReturnType, - ); - baseMessenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - (..._args) => - ({ - provider: { request: jest.fn() }, - }) as unknown as ReturnType< - NetworkControllerGetNetworkClientByIdAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'AccountsController:getSelectedMultichainAccount', - (..._args) => - ({ - address: '0xabc', - }) as ReturnType< - AccountsControllerGetSelectedMultichainAccountAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'NetworkController:findNetworkClientIdByChainId', - (..._args) => - 'test-client-id' as ReturnType< - NetworkControllerFindNetworkClientIdByChainIdAction['handler'] - >, - ); - await expect( - controller.createCryptoApproveTransaction({ + controller.getCryptoApproveTransactionParams({ chainId: '0x1', - tokenAddress: '0xtoken', + paymentTokenAddress: '0xtoken', productType: ProductType.SHIELD, interval: RecurringInterval.month, }), @@ -1077,60 +809,5 @@ describe('SubscriptionController', () => { }, ); }); - - it('throws when no wallet address found', async () => { - const ContractCtorLocal = Contract as unknown as jest.Mock; - const instance = { - allowance: jest.fn().mockResolvedValue(BigNumber.from('0')), - interface: { encodeFunctionData: jest.fn() }, - } as unknown as Contract; - ContractCtorLocal.mockImplementation(() => instance); - - await withController( - async ({ controller, mockService, baseMessenger }) => { - baseMessenger.registerActionHandler( - 'NetworkController:getState', - (..._args) => - ({ - selectedNetworkClientId: 'test-client-id', - }) as ReturnType, - ); - baseMessenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - (..._args) => - ({ - provider: { request: jest.fn() }, - }) as unknown as ReturnType< - NetworkControllerGetNetworkClientByIdAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'AccountsController:getSelectedMultichainAccount', - (..._args) => - undefined as ReturnType< - AccountsControllerGetSelectedMultichainAccountAction['handler'] - >, - ); - baseMessenger.registerActionHandler( - 'NetworkController:findNetworkClientIdByChainId', - (..._args) => - 'test-client-id' as ReturnType< - NetworkControllerFindNetworkClientIdByChainIdAction['handler'] - >, - ); - - mockService.getPricing.mockResolvedValue(MOCK_PRICE_INFO_RESPONSE); - - await expect( - controller.createCryptoApproveTransaction({ - chainId: '0x1', - tokenAddress: '0xtoken', - productType: ProductType.SHIELD, - interval: RecurringInterval.month, - }), - ).rejects.toThrow('No wallet address found'); - }, - ); - }); }); }); diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index cd7cd7f5a8..fada8e54e9 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -1,8 +1,3 @@ -import { BigNumber as EthersBigNumber } from '@ethersproject/bignumber'; -import { BigNumber } from '@ethersproject/bignumber'; -import { Contract } from '@ethersproject/contracts'; -import { Web3Provider } from '@ethersproject/providers'; -import type { AccountsControllerGetSelectedMultichainAccountAction } from '@metamask/accounts-controller'; import { BaseController, type StateMetadata, @@ -10,32 +5,15 @@ import { type ControllerGetStateAction, type RestrictedMessenger, } from '@metamask/base-controller'; -import { toHex } from '@metamask/controller-utils'; -import type { GetGasFeeState } from '@metamask/gas-fee-controller'; -import { abiERC20 } from '@metamask/metamask-eth-abis'; -import type { - AutoManagedNetworkClient, - CustomNetworkClientConfiguration, - NetworkControllerFindNetworkClientIdByChainIdAction, - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerGetStateAction, -} from '@metamask/network-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; -import { - TransactionType, - type TransactionController, - type TransactionParams, -} from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; import { controllerName, SubscriptionControllerErrorMessage, } from './constants'; import type { - ChainPaymentInfo, - CreateCryptoApproveTransactionRequest, - CreateCryptoApproveTransactionResponse, + GetCryptoApproveTransactionRequest, + GetCryptoApproveTransactionResponse, ProductPrice, StartCryptoSubscriptionRequest, TokenPaymentInfo, @@ -49,7 +27,6 @@ import { type StartSubscriptionRequest, type Subscription, } from './types'; -import { generateActionId, getTxGasEstimates } from './utils'; export type SubscriptionControllerState = { subscriptions: Subscription[]; @@ -72,9 +49,9 @@ export type SubscriptionControllerGetPricingAction = { type: `${typeof controllerName}:getPricing`; handler: SubscriptionController['getPricing']; }; -export type SubscriptionControllerCreateCryptoApproveTransactionAction = { - type: `${typeof controllerName}:createCryptoApproveTransaction`; - handler: SubscriptionController['createCryptoApproveTransaction']; +export type SubscriptionControllerGetCryptoApproveTransactionParamsAction = { + type: `${typeof controllerName}:getCryptoApproveTransactionParams`; + handler: SubscriptionController['getCryptoApproveTransactionParams']; }; export type SubscriptionControllerStartSubscriptionWithCryptoAction = { type: `${typeof controllerName}:startSubscriptionWithCrypto`; @@ -91,16 +68,11 @@ export type SubscriptionControllerActions = | SubscriptionControllerStartShieldSubscriptionWithCardAction | SubscriptionControllerGetPricingAction | SubscriptionControllerGetStateAction - | SubscriptionControllerCreateCryptoApproveTransactionAction + | SubscriptionControllerGetCryptoApproveTransactionParamsAction | SubscriptionControllerStartSubscriptionWithCryptoAction; export type AllowedActions = - | AuthenticationController.AuthenticationControllerGetBearerToken - | NetworkControllerFindNetworkClientIdByChainIdAction - | NetworkControllerGetStateAction - | NetworkControllerGetNetworkClientByIdAction - | AccountsControllerGetSelectedMultichainAccountAction - | GetGasFeeState; + AuthenticationController.AuthenticationControllerGetBearerToken; // Events export type SubscriptionControllerStateChangeEvent = ControllerStateChangeEvent< @@ -137,10 +109,6 @@ export type SubscriptionControllerOptions = { * Subscription service to use for the subscription controller. */ subscriptionService: ISubscriptionService; - - addTransactionFn: typeof TransactionController.prototype.addTransaction; - - estimateGasFeeFn: typeof TransactionController.prototype.estimateGasFee; }; /** @@ -176,10 +144,6 @@ export class SubscriptionController extends BaseController< > { readonly #subscriptionService: ISubscriptionService; - readonly #addTransactionFn: typeof TransactionController.prototype.addTransaction; - - readonly #estimateGasFeeFn: typeof TransactionController.prototype.estimateGasFee; - /** * Creates a new SubscriptionController instance. * @@ -187,15 +151,11 @@ export class SubscriptionController extends BaseController< * @param options.messenger - A restricted messenger. * @param options.state - Initial state to set on this controller. * @param options.subscriptionService - The subscription service for communicating with subscription server. - * @param options.addTransactionFn - The function to add a transaction. - * @param options.estimateGasFeeFn - The function to estimate gas fee. */ constructor({ messenger, state, subscriptionService, - addTransactionFn, - estimateGasFeeFn, }: SubscriptionControllerOptions) { super({ name: controllerName, @@ -208,8 +168,6 @@ export class SubscriptionController extends BaseController< }); this.#subscriptionService = subscriptionService; - this.#addTransactionFn = addTransactionFn; - this.#estimateGasFeeFn = estimateGasFeeFn; this.#registerMessageHandlers(); } @@ -239,8 +197,8 @@ export class SubscriptionController extends BaseController< ); this.messagingSystem.registerActionHandler( - 'SubscriptionController:createCryptoApproveTransaction', - this.createCryptoApproveTransaction.bind(this), + 'SubscriptionController:getCryptoApproveTransactionParams', + this.getCryptoApproveTransactionParams.bind(this), ); this.messagingSystem.registerActionHandler( @@ -297,20 +255,19 @@ export class SubscriptionController extends BaseController< } /** - * Create a crypto approve transaction for subscription payment - * Return undefined if allowance amount is already allowed. + * Get transaction params to create crypto approve transaction for subscription payment * * @param request - The request object * @param request.chainId - The chain ID * @param request.tokenAddress - The address of the token * @param request.productType - The product type * @param request.interval - The interval - * @returns The transaction raw or already allowed flag + * @returns The crypto approve transaction params */ - async createCryptoApproveTransaction( - request: CreateCryptoApproveTransactionRequest, - ): Promise { - const pricing = await this.#subscriptionService.getPricing(); + async getCryptoApproveTransactionParams( + request: GetCryptoApproveTransactionRequest, + ): Promise { + const pricing = await this.getPricing(); const product = pricing.products.find( (p) => p.name === request.productType, ); @@ -336,152 +293,23 @@ export class SubscriptionController extends BaseController< throw new Error('Invalid chain id'); } const tokenPaymentInfo = chainPaymentInfo.tokens.find( - (t) => t.address === request.tokenAddress, + (t) => t.address === request.paymentTokenAddress, ); if (!tokenPaymentInfo) { throw new Error('Invalid token address'); } - const networkClientId = this.messagingSystem.call( - 'NetworkController:findNetworkClientIdByChainId', - request.chainId as Hex, - ); - - const provider = this.#getSelectedNetworkClient()?.provider; - if (!provider) { - throw new Error('No provider found'); - } - const tokenApproveAmount = this.#getTokenApproveAmount( price, tokenPaymentInfo, ); - // no need to create transaction if already allowed enough amount - const allowance = await this.#getCryptoAllowance({ - tokenAddress: request.tokenAddress, - chainPaymentInfo, - chainId: request.chainId, - provider, - }); - if (allowance.gte(tokenApproveAmount)) { - return { - transactionResult: undefined, - }; - } - - const spender = chainPaymentInfo.paymentAddress; - - const ethersProvider = new Web3Provider(provider); - const { address: walletAddress } = - this.#getMultichainSelectedAccount() ?? {}; - if (!walletAddress) { - throw new Error('No wallet address found'); - } - - const token = new Contract(request.tokenAddress, abiERC20, ethersProvider); - // Build call data only - const txData = token.interface.encodeFunctionData('approve', [ - spender, - tokenApproveAmount, - ]); - const transactionParams = { - to: request.tokenAddress, - data: txData, - from: walletAddress, - value: '0', - }; - - const actionId = generateActionId().toString(); - const requestOptions = { - actionId, - networkClientId, - requireApproval: true, - type: TransactionType.tokenMethodApprove, - origin: 'metamask', - }; - - const transactionParamsWithMaxGas: TransactionParams = { - ...transactionParams, - ...(await this.#calculateGasFees( - transactionParams, - networkClientId, - request.chainId as Hex, - )), - }; - - const result = await this.#addTransactionFn( - transactionParamsWithMaxGas, - requestOptions, - ); - - return { - transactionResult: result, - }; - } - - readonly #calculateGasFees = async ( - transactionParams: TransactionParams, - networkClientId: string, - chainId: Hex, - ) => { - const { gasFeeEstimates } = this.messagingSystem.call( - 'GasFeeController:getState', - ); - const { estimates: txGasFeeEstimates } = await this.#estimateGasFeeFn({ - transactionParams, - chainId, - networkClientId, - }); - const { maxFeePerGas, maxPriorityFeePerGas } = getTxGasEstimates({ - networkGasFeeEstimates: gasFeeEstimates, - txGasFeeEstimates, - }); - const maxGasLimit = toHex(transactionParams.gas ?? 0); return { - maxFeePerGas, - maxPriorityFeePerGas, - gas: maxGasLimit, + approveAmount: tokenApproveAmount.toString(), + spenderAddress: chainPaymentInfo.paymentAddress, + paymentTokenAddress: request.paymentTokenAddress, + chainId: request.chainId, }; - }; - - /** - * Get the allowance of the crypto token for payment address - * - * @param request - The request object - * @param request.tokenAddress - The address of the token - * @param request.chainPaymentInfo - The chain payment info - * @param request.chainId - The chain ID - * @param request.provider - The network client provider - * @returns The allowance of the crypto token - */ - async #getCryptoAllowance(request: { - tokenAddress: string; - chainPaymentInfo: ChainPaymentInfo; - chainId: string; - provider: AutoManagedNetworkClient['provider']; - }) { - const { provider } = request; - - const ethersProvider = new Web3Provider(provider); - const { address: walletAddress } = - this.#getMultichainSelectedAccount() ?? {}; - const contract = new Contract( - request.tokenAddress, - abiERC20, - ethersProvider, - ); - const allowance: BigNumber = await contract.allowance( - walletAddress, - request.chainPaymentInfo.paymentAddress, - ); - return allowance; - } - - #getMultichainSelectedAccount() { - return this.messagingSystem.call( - 'AccountsController:getSelectedMultichainAccount', - ); } /** @@ -492,9 +320,9 @@ export class SubscriptionController extends BaseController< * @returns The price amount */ #getSubscriptionPriceAmount(price: ProductPrice) { - const amount = EthersBigNumber.from(price.unitAmount) - .div(EthersBigNumber.from(10).pow(price.unitDecimals)) - .mul(price.minBillingCycles); + const amount = + (BigInt(price.unitAmount) / BigInt(10) ** BigInt(price.unitDecimals)) * + BigInt(price.minBillingCycles); return amount; } @@ -519,30 +347,15 @@ export class SubscriptionController extends BaseController< // conversion rate is a float string e.g: "1.0" // ether.js bignumber does not support float string, only handle integer string // we need to convert it to integer string or use bignumber.js - const conversionRateBigNumber = BigNumber.from(Number(conversionRate)); + const conversionRateBigInt = BigInt(Number(conversionRate)); - const amount = this.#getSubscriptionPriceAmount(price) - .mul(EthersBigNumber.from(10).pow(tokenPaymentInfo.decimals)) - .div(conversionRateBigNumber); + const amount = + (this.#getSubscriptionPriceAmount(price) * + BigInt(10) ** BigInt(tokenPaymentInfo.decimals)) / + conversionRateBigInt; return amount; } - #getSelectedNetworkClientId() { - const { selectedNetworkClientId } = this.messagingSystem.call( - 'NetworkController:getState', - ); - return selectedNetworkClientId; - } - - #getSelectedNetworkClient() { - const selectedNetworkClientId = this.#getSelectedNetworkClientId(); - const networkClient = this.messagingSystem.call( - 'NetworkController:getNetworkClientById', - selectedNetworkClientId, - ); - return networkClient; - } - #assertIsUserNotSubscribed({ products }: { products: ProductType[] }) { if ( this.state.subscriptions.find((subscription) => diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 50434f3043..4bdb53bb17 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -6,7 +6,7 @@ export type { SubscriptionControllerCancelSubscriptionAction, SubscriptionControllerStartShieldSubscriptionWithCardAction, SubscriptionControllerGetPricingAction, - SubscriptionControllerCreateCryptoApproveTransactionAction, + SubscriptionControllerGetCryptoApproveTransactionParamsAction, SubscriptionControllerStartSubscriptionWithCryptoAction, SubscriptionControllerGetStateAction, SubscriptionControllerMessenger, @@ -25,8 +25,8 @@ export type { StartCryptoSubscriptionResponse, StartSubscriptionRequest, StartSubscriptionResponse, - CreateCryptoApproveTransactionRequest, - CreateCryptoApproveTransactionResponse, + GetCryptoApproveTransactionRequest, + GetCryptoApproveTransactionResponse, RecurringInterval, SubscriptionStatus, PaymentType, diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 6ea1dd3eb7..48d95363aa 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -1,4 +1,3 @@ -import type { Result } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; export enum ProductType { @@ -152,7 +151,7 @@ export type PricingResponse = { paymentMethods: PricingPaymentMethod[]; }; -export type CreateCryptoApproveTransactionRequest = { +export type GetCryptoApproveTransactionRequest = { /** * payment chain ID */ @@ -160,17 +159,20 @@ export type CreateCryptoApproveTransactionRequest = { /** * Payment token address */ - tokenAddress: string; + paymentTokenAddress: string; productType: ProductType; interval: RecurringInterval; }; -export type CreateCryptoApproveTransactionResponse = { +export type GetCryptoApproveTransactionResponse = { /** - * Transaction result. - * Return undefined if allowance is already allowed. + * The amount to approve + * e.g: "100000000" */ - transactionResult?: Result; + approveAmount: string; + spenderAddress: string; + paymentTokenAddress: string; + chainId: string; }; export type ISubscriptionService = { diff --git a/packages/subscription-controller/src/utils.test.ts b/packages/subscription-controller/src/utils.test.ts deleted file mode 100644 index ceee2de1b3..0000000000 --- a/packages/subscription-controller/src/utils.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { GasFeeEstimates } from '@metamask/gas-fee-controller'; -import type { - FeeMarketGasFeeEstimates, - TransactionController, -} from '@metamask/transaction-controller'; - -import { - generateActionId, - getTransaction1559GasFeeEstimates, - getTxGasEstimates, -} from './utils'; - -describe('utils', () => { - describe('generateActionId', () => { - it('returns a deterministic string when time and random are mocked', () => { - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); - const randomSpy = jest - .spyOn(global.Math, 'random') - .mockReturnValueOnce(0.1) - .mockReturnValueOnce(0.2); - - const id1 = generateActionId(); - const id2 = generateActionId(); - - expect(typeof id1).toBe('string'); - expect(typeof id2).toBe('string'); - expect(id1).toBe('1700000000000.1'); - expect(id2).toBe('1700000000000.2'); - - nowSpy.mockRestore(); - randomSpy.mockRestore(); - }); - - it('generates different values for subsequent calls (sanity)', () => { - const a = generateActionId(); - const b = generateActionId(); - expect(a).not.toBe(b); - }); - }); - - describe('getTransaction1559GasFeeEstimates', () => { - it('computes baseAndPriorityFeePerGas when maxPriorityFeePerGas is provided', () => { - const input = { - high: { - maxFeePerGas: '0x5208', - maxPriorityFeePerGas: 'a', - }, - } as unknown as FeeMarketGasFeeEstimates; - - const estimates = getTransaction1559GasFeeEstimates(input, '2'); - - expect(estimates.maxFeePerGas).toBe('0x5208'); - expect(estimates.maxPriorityFeePerGas).toBe('a'); - // 2 gwei -> 2e9 wei, plus 10 (priority) = 2000000010 - expect(estimates.baseAndPriorityFeePerGas?.toString()).toBe('2000000010'); - }); - - it('returns undefined baseAndPriorityFeePerGas when maxPriorityFeePerGas is missing', () => { - const input = { - high: undefined, - } as unknown as FeeMarketGasFeeEstimates; - - const estimates = getTransaction1559GasFeeEstimates(input, '1'); - - expect(estimates.maxPriorityFeePerGas).toBeUndefined(); - expect(estimates.baseAndPriorityFeePerGas).toBeUndefined(); - }); - }); - - describe('getTxGasEstimates', () => { - it('derives estimates using provided tx and network fee data', () => { - type EstimateReturn = Awaited< - ReturnType - >['estimates']; - - const txGasFeeEstimates = { - high: { - maxFeePerGas: '0x2', - maxPriorityFeePerGas: 'a', // 10 (hex) - }, - } as unknown as EstimateReturn; - - const networkGasFeeEstimates = { - estimatedBaseFee: '3', // 3 gwei -> 3e9 wei - } as unknown as GasFeeEstimates; - - const result = getTxGasEstimates({ - txGasFeeEstimates, - networkGasFeeEstimates, - }); - - expect(result.maxFeePerGas).toBe('0x2'); - expect(result.maxPriorityFeePerGas).toBe('a'); - // 3e9 + 10 - expect(result.baseAndPriorityFeePerGas?.toString()).toBe('3000000010'); - }); - - it('defaults estimatedBaseFee to 0 when missing', () => { - type EstimateReturn = Awaited< - ReturnType - >['estimates']; - - const txGasFeeEstimates = { - high: { - maxFeePerGas: '0x2', - maxPriorityFeePerGas: 'a', - }, - } as unknown as EstimateReturn; - - const networkGasFeeEstimates = {} as unknown as GasFeeEstimates; - - const result = getTxGasEstimates({ - txGasFeeEstimates, - networkGasFeeEstimates, - }); - - // base=0 gwei -> 0 wei; 0 + 10 = 10 - expect(result.baseAndPriorityFeePerGas?.toString()).toBe('10'); - expect(result.maxFeePerGas).toBe('0x2'); - expect(result.maxPriorityFeePerGas).toBe('a'); - }); - }); -}); diff --git a/packages/subscription-controller/src/utils.ts b/packages/subscription-controller/src/utils.ts deleted file mode 100644 index 897c94f66f..0000000000 --- a/packages/subscription-controller/src/utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { - GasFeeEstimates, - GasFeeState, -} from '@metamask/gas-fee-controller'; -import type { - FeeMarketGasFeeEstimates, - TransactionController, -} from '@metamask/transaction-controller'; -import { BigNumber } from 'bignumber.js'; - -export const generateActionId = () => (Date.now() + Math.random()).toString(); - -export const getTransaction1559GasFeeEstimates = ( - txGasFeeEstimates: FeeMarketGasFeeEstimates, - estimatedBaseFee: string, -) => { - const { maxFeePerGas, maxPriorityFeePerGas } = txGasFeeEstimates?.high ?? {}; - - const baseAndPriorityFeePerGas = maxPriorityFeePerGas - ? new BigNumber(estimatedBaseFee, 10) - .times(10 ** 9) - .plus(maxPriorityFeePerGas, 16) - : undefined; - - return { - baseAndPriorityFeePerGas, - maxFeePerGas, - maxPriorityFeePerGas, - }; -}; - -/** - * Get the gas fee estimates for a transaction - * - * @param params - The parameters for the gas fee estimates - * @param params.txGasFeeEstimates - The gas fee estimates for the transaction (TransactionController) - * @param params.networkGasFeeEstimates - The gas fee estimates for the network (GasFeeController) - * @returns The gas fee estimates for the transaction - */ -export const getTxGasEstimates = ({ - txGasFeeEstimates, - networkGasFeeEstimates, -}: { - txGasFeeEstimates: Awaited< - ReturnType - >['estimates']; - networkGasFeeEstimates: GasFeeState['gasFeeEstimates']; -}) => { - const { estimatedBaseFee = '0' } = networkGasFeeEstimates as GasFeeEstimates; - return getTransaction1559GasFeeEstimates( - txGasFeeEstimates as FeeMarketGasFeeEstimates, - estimatedBaseFee, - ); -}; diff --git a/packages/subscription-controller/tsconfig.build.json b/packages/subscription-controller/tsconfig.build.json index 73a19c0e7a..470351ab50 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -6,21 +6,9 @@ "rootDir": "./src" }, "references": [ - { - "path": "../accounts-controller/tsconfig.build.json" - }, { "path": "../base-controller/tsconfig.build.json" }, - { - "path": "../gas-fee-controller/tsconfig.build.json" - }, - { - "path": "../network-controller/tsconfig.build.json" - }, - { - "path": "../transaction-controller/tsconfig.build.json" - }, { "path": "../profile-sync-controller/tsconfig.build.json" } diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 43416da8e1..4828147b53 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -4,23 +4,11 @@ "baseUrl": "./" }, "references": [ - { - "path": "../accounts-controller" - }, { "path": "../base-controller" }, - { - "path": "../gas-fee-controller" - }, - { - "path": "../network-controller" - }, { "path": "../profile-sync-controller" - }, - { - "path": "../transaction-controller" } ], "include": ["../../types", "./src", "./tests"] diff --git a/yarn.lock b/yarn.lock index 47ed12c79e..d48a81a574 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1230,17 +1230,6 @@ __metadata: languageName: node linkType: hard -"@ethersproject/units@npm:^5.7.0": - version: 5.8.0 - resolution: "@ethersproject/units@npm:5.8.0" - dependencies: - "@ethersproject/bignumber": "npm:^5.8.0" - "@ethersproject/constants": "npm:^5.8.0" - "@ethersproject/logger": "npm:^5.8.0" - checksum: 10/cc7180c85f695449c20572602971145346fc5c169ee32f23d79ac31cc8c9c66a2049e3ac852b940ddccbe39ab1db3b81e3e093b604d9ab7ab27639ecb933b270 - languageName: node - linkType: hard - "@ethersproject/wallet@npm:^5.7.0": version: 5.8.0 resolution: "@ethersproject/wallet@npm:5.8.0" @@ -4660,19 +4649,10 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@ethersproject/units": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^33.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^8.3.0" "@metamask/controller-utils": "npm:^11.12.0" - "@metamask/gas-fee-controller": "npm:^24.0.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^24.1.0" "@metamask/profile-sync-controller": "npm:^24.0.0" - "@metamask/transaction-controller": "npm:^60.2.0" "@metamask/utils": "npm:^11.4.2" "@types/jest": "npm:^27.4.1" bignumber.js: "npm:^9.1.2" From c148d80611b623b2d3898db4b31d6729b5bdcfd7 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 5 Sep 2025 15:08:42 +0700 Subject: [PATCH 14/26] feat: correct package usage --- packages/subscription-controller/package.json | 5 ++--- .../src/SubscriptionController.ts | 15 ++++++++++----- yarn.lock | 1 - 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 8e38002076..5fcd497ba4 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -48,12 +48,11 @@ }, "dependencies": { "@metamask/base-controller": "^8.3.0", - "@metamask/controller-utils": "^11.12.0", - "@metamask/utils": "^11.4.2", - "bignumber.js": "^9.1.2" + "@metamask/utils": "^11.4.2" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/controller-utils": "^11.12.0", "@metamask/profile-sync-controller": "^24.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index fada8e54e9..e180f66ee8 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -345,14 +345,19 @@ export class SubscriptionController extends BaseController< throw new Error('Conversion rate not found'); } // conversion rate is a float string e.g: "1.0" - // ether.js bignumber does not support float string, only handle integer string - // we need to convert it to integer string or use bignumber.js - const conversionRateBigInt = BigInt(Number(conversionRate)); + // We need to handle float conversion rates with integer math for BigInt. + // We'll scale the conversion rate to an integer by multiplying by 10^18 (or another large factor). + // This allows us to avoid floating point math and keep precision. + const CONVERSION_RATE_SCALE = 10n ** 18n; + const conversionRateScaled = BigInt( + Math.round(Number(conversionRate) * Number(CONVERSION_RATE_SCALE)), + ); const amount = (this.#getSubscriptionPriceAmount(price) * - BigInt(10) ** BigInt(tokenPaymentInfo.decimals)) / - conversionRateBigInt; + BigInt(10) ** BigInt(tokenPaymentInfo.decimals) * + CONVERSION_RATE_SCALE) / + conversionRateScaled; return amount; } diff --git a/yarn.lock b/yarn.lock index d48a81a574..479aa8ce5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4655,7 +4655,6 @@ __metadata: "@metamask/profile-sync-controller": "npm:^24.0.0" "@metamask/utils": "npm:^11.4.2" "@types/jest": "npm:^27.4.1" - bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" nock: "npm:^13.3.1" From 3e13fe7a66ff7c195e6a80bb3e245054c5da6aa1 Mon Sep 17 00:00:00 2001 From: Tuna Date: Fri, 5 Sep 2025 16:45:47 +0700 Subject: [PATCH 15/26] fix: linting --- .../src/SubscriptionController.test.ts | 66 +++++++++---------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 3cbcabf560..66eaba9a56 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -773,41 +773,39 @@ describe('SubscriptionController', () => { }); it('throws when conversion rate not found', async () => { - await withController( - async ({ controller, mockService, baseMessenger }) => { - // Valid product and chain/token, but token lacks conversion rate for currency - mockService.getPricing.mockResolvedValue({ - ...MOCK_PRICE_INFO_RESPONSE, - paymentMethods: [ - { - type: PaymentType.byCrypto, - chains: [ - { - chainId: '0x1', - paymentAddress: '0xspender', - tokens: [ - { - address: '0xtoken', - decimals: 18, - conversionRate: {}, - }, - ], - }, - ], - }, - ], - }); + await withController(async ({ controller, mockService }) => { + // Valid product and chain/token, but token lacks conversion rate for currency + mockService.getPricing.mockResolvedValue({ + ...MOCK_PRICE_INFO_RESPONSE, + paymentMethods: [ + { + type: PaymentType.byCrypto, + chains: [ + { + chainId: '0x1', + paymentAddress: '0xspender', + tokens: [ + { + address: '0xtoken', + decimals: 18, + conversionRate: {}, + }, + ], + }, + ], + }, + ], + }); - await expect( - controller.getCryptoApproveTransactionParams({ - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: ProductType.SHIELD, - interval: RecurringInterval.month, - }), - ).rejects.toThrow('Conversion rate not found'); - }, - ); + await expect( + controller.getCryptoApproveTransactionParams({ + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: ProductType.SHIELD, + interval: RecurringInterval.month, + }), + ).rejects.toThrow('Conversion rate not found'); + }); }); }); }); From 18ac5a462fd498bf8fb73410cac496046c40f391 Mon Sep 17 00:00:00 2001 From: Tuna Date: Sat, 6 Sep 2025 14:04:29 +0700 Subject: [PATCH 16/26] fix: update conversion rate scale --- .../subscription-controller/src/SubscriptionController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index e180f66ee8..cb46c84824 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -346,9 +346,11 @@ export class SubscriptionController extends BaseController< } // conversion rate is a float string e.g: "1.0" // We need to handle float conversion rates with integer math for BigInt. - // We'll scale the conversion rate to an integer by multiplying by 10^18 (or another large factor). + // We'll scale the conversion rate to an integer by multiplying by 10^4. + // conversionRate is in usd decimal. In most currencies, we only care about 2 decimals (cents) + // So, scale must be max of 10 ** 4 (most exchanges trade with max 4 decimals of usd) // This allows us to avoid floating point math and keep precision. - const CONVERSION_RATE_SCALE = 10n ** 18n; + const CONVERSION_RATE_SCALE = 10n ** 4n; const conversionRateScaled = BigInt( Math.round(Number(conversionRate) * Number(CONVERSION_RATE_SCALE)), ); From 247e2ac6fc230267bb920f89fbfd17ad369330f8 Mon Sep 17 00:00:00 2001 From: Tuna Date: Sat, 6 Sep 2025 15:13:48 +0700 Subject: [PATCH 17/26] feat: scale subscription price amount --- .../src/SubscriptionController.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index cb46c84824..cd3fd36be4 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -320,9 +320,9 @@ export class SubscriptionController extends BaseController< * @returns The price amount */ #getSubscriptionPriceAmount(price: ProductPrice) { + // no need to use BigInt since max unitDecimals are always 2 for price const amount = - (BigInt(price.unitAmount) / BigInt(10) ** BigInt(price.unitDecimals)) * - BigInt(price.minBillingCycles); + (price.unitAmount / 10 ** price.unitDecimals) * price.minBillingCycles; return amount; } @@ -352,11 +352,15 @@ export class SubscriptionController extends BaseController< // This allows us to avoid floating point math and keep precision. const CONVERSION_RATE_SCALE = 10n ** 4n; const conversionRateScaled = BigInt( - Math.round(Number(conversionRate) * Number(CONVERSION_RATE_SCALE)), + Number(conversionRate) * Number(CONVERSION_RATE_SCALE), + ); + const priceAmount = this.#getSubscriptionPriceAmount(price); + const priceAmountScaled = BigInt( + priceAmount * Number(CONVERSION_RATE_SCALE), ); const amount = - (this.#getSubscriptionPriceAmount(price) * + ((priceAmountScaled / CONVERSION_RATE_SCALE) * BigInt(10) ** BigInt(tokenPaymentInfo.decimals) * CONVERSION_RATE_SCALE) / conversionRateScaled; From def45851c52a9a558400fb4ed2a78fef961a0738 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 8 Sep 2025 16:19:58 +0700 Subject: [PATCH 18/26] feat: update type --- .../src/SubscriptionService.test.ts | 2 +- packages/subscription-controller/src/index.ts | 1 + packages/subscription-controller/src/types.ts | 32 +++++++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index d70c9f3336..86906861be 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -28,7 +28,7 @@ const MOCK_SUBSCRIPTION: Subscription = { { name: ProductType.SHIELD, id: 'prod_shield_basic', - currency: 'USD', + currency: 'usd', amount: 9.99, }, ], diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 4bdb53bb17..a03990f47b 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -36,6 +36,7 @@ export type { ProductPricing, TokenPaymentInfo, ChainPaymentInfo, + Currency, PricingPaymentMethod, PricingResponse, } from './types'; diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 48d95363aa..5d044ebeff 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -4,10 +4,13 @@ export enum ProductType { SHIELD = 'shield', } +/** only usd for now */ +export type Currency = 'usd'; + export type Product = { name: ProductType; id: string; - currency: string; + currency: Currency; amount: number; }; @@ -56,8 +59,8 @@ export type Subscription = { export type SubscriptionPaymentMethod = { type: PaymentType; crypto?: { - payerAddress: string; - chainId: string; + payerAddress: Hex; + chainId: Hex; tokenSymbol: string; }; }; @@ -83,8 +86,8 @@ export type StartCryptoSubscriptionRequest = { isTrialRequested: boolean; recurringInterval: RecurringInterval; billingCycles: number; - chainId: string; - payerAddress: string; + chainId: Hex; + payerAddress: Hex; /** * e.g. "USDC" */ @@ -111,7 +114,8 @@ export type ProductPrice = { interval: RecurringInterval; unitAmount: number; // amount in the smallest unit of the currency, e.g., cents unitDecimals: number; // number of decimals for the smallest unit of the currency - currency: string; // "usd" + /** only usd for now */ + currency: Currency; trialPeriodDays: number; minBillingCycles: number; }; @@ -123,7 +127,7 @@ export type ProductPricing = { export type TokenPaymentInfo = { symbol: string; - address: string; + address: Hex; decimals: number; /** * example: { @@ -136,8 +140,8 @@ export type TokenPaymentInfo = { }; export type ChainPaymentInfo = { - chainId: string; - paymentAddress: string; + chainId: Hex; + paymentAddress: Hex; tokens: TokenPaymentInfo[]; }; @@ -155,11 +159,11 @@ export type GetCryptoApproveTransactionRequest = { /** * payment chain ID */ - chainId: string; + chainId: Hex; /** * Payment token address */ - paymentTokenAddress: string; + paymentTokenAddress: Hex; productType: ProductType; interval: RecurringInterval; }; @@ -170,9 +174,9 @@ export type GetCryptoApproveTransactionResponse = { * e.g: "100000000" */ approveAmount: string; - spenderAddress: string; - paymentTokenAddress: string; - chainId: string; + spenderAddress: Hex; + paymentTokenAddress: Hex; + chainId: Hex; }; export type ISubscriptionService = { From 81847125e676721d83817117555a6994333e9a2b Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 8 Sep 2025 16:33:15 +0700 Subject: [PATCH 19/26] refactor: rename response type for consistency --- .../src/SubscriptionController.test.ts | 2 +- .../subscription-controller/src/SubscriptionController.ts | 2 +- packages/subscription-controller/src/types.ts | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 66eaba9a56..56288c70dd 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -649,7 +649,7 @@ describe('SubscriptionController', () => { expect(result).toStrictEqual({ approveAmount: '9000000000000000000', - spenderAddress: '0xspender', + paymentAddress: '0xspender', paymentTokenAddress: '0xtoken', chainId: '0x1', }); diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index cd3fd36be4..fe956342be 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -306,7 +306,7 @@ export class SubscriptionController extends BaseController< return { approveAmount: tokenApproveAmount.toString(), - spenderAddress: chainPaymentInfo.paymentAddress, + paymentAddress: chainPaymentInfo.paymentAddress, paymentTokenAddress: request.paymentTokenAddress, chainId: request.chainId, }; diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 5d044ebeff..a752f4b7bf 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -174,7 +174,13 @@ export type GetCryptoApproveTransactionResponse = { * e.g: "100000000" */ approveAmount: string; - spenderAddress: Hex; + /** + * The contract address (spender) + */ + paymentAddress: Hex; + /** + * The payment token address + */ paymentTokenAddress: Hex; chainId: Hex; }; From dee0f455a11dcccdada23f55faec11abcbaad871 Mon Sep 17 00:00:00 2001 From: Nguyen Anh Tu Date: Mon, 8 Sep 2025 18:01:40 +0700 Subject: [PATCH 20/26] Update packages/subscription-controller/CHANGELOG.md Co-authored-by: matthiasgeihs <62935430+matthiasgeihs@users.noreply.github.com> --- packages/subscription-controller/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 635db695a1..bc02f24604 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `cancelSubscription`: Cancel user active subscription. - `startShieldSubscriptionWithCard`: start shield subscription via card (with trial option) ([#6300](https://github.com/MetaMask/core/pull/6300)) - Add `getPricing` method ([#6356](https://github.com/MetaMask/core/pull/6356)) -- Add `startSubscriptionWithCrypto` method ([#6456](https://github.com/MetaMask/core/pull/6456)) -- Add `getCryptoApproveTransactionParams` method ([#6456](https://github.com/MetaMask/core/pull/6456)) +- Add methods `startSubscriptionWithCrypto` and `getCryptoApproveTransactionParams` method ([#6456](https://github.com/MetaMask/core/pull/6456)) [Unreleased]: https://github.com/MetaMask/core/ From b6f696b93487b31f842a1ce1c7ff638d02fdfe32 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 9 Sep 2025 09:26:13 +0700 Subject: [PATCH 21/26] feat: use handleFetch internally --- packages/subscription-controller/package.json | 1 - .../src/SubscriptionService.test.ts | 142 +++++++++--------- .../src/SubscriptionService.ts | 9 +- packages/subscription-controller/src/types.ts | 8 +- yarn.lock | 1 - 5 files changed, 71 insertions(+), 90 deletions(-) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 5fcd497ba4..d4b46c7d62 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -57,7 +57,6 @@ "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", "jest": "^27.5.1", - "nock": "^13.3.1", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index 86906861be..d5197f8124 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -1,5 +1,4 @@ import { handleFetch } from '@metamask/controller-utils'; -import nock, { cleanAll, isDone } from 'nock'; import { Env, @@ -21,6 +20,11 @@ import { SubscriptionStatus, } from './types'; +// Mock the handleFetch function +jest.mock('@metamask/controller-utils', () => ({ + handleFetch: jest.fn(), +})); + // Mock data const MOCK_SUBSCRIPTION: Subscription = { id: 'sub_123456789', @@ -43,11 +47,6 @@ const MOCK_SUBSCRIPTION: Subscription = { const MOCK_ACCESS_TOKEN = 'mock-access-token-12345'; -const MOCK_ERROR_RESPONSE = { - message: 'Subscription not found', - error: 'NOT_FOUND', -}; - const MOCK_START_SUBSCRIPTION_REQUEST: StartSubscriptionRequest = { products: [ProductType.SHIELD], isTrialRequested: true, @@ -63,19 +62,14 @@ const MOCK_START_SUBSCRIPTION_RESPONSE = { * * @param params - The parameters object * @param [params.env] - The environment to use for the config - * @param [params.fetchFn] - The fetch function to use for the config * @returns The mock configuration object */ -function createMockConfig({ - env = Env.DEV, - fetchFn = handleFetch, -}: { env?: Env; fetchFn?: typeof fetch } = {}) { +function createMockConfig({ env = Env.DEV }: { env?: Env } = {}) { return { env, auth: { getAccessToken: jest.fn().mockResolvedValue(MOCK_ACCESS_TOKEN), }, - fetchFn, }; } @@ -109,8 +103,8 @@ function withMockSubscriptionService( } describe('SubscriptionService', () => { - afterEach(() => { - cleanAll(); + beforeEach(() => { + jest.clearAllMocks(); }); describe('constructor', () => { @@ -134,33 +128,29 @@ describe('SubscriptionService', () => { describe('getSubscriptions', () => { it('should fetch subscriptions successfully', async () => { - await withMockSubscriptionService( - async ({ service, testUrl, config }) => { - nock(testUrl) - .get('/v1/subscriptions') - .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) - .reply(200, { - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); - - const result = await service.getSubscriptions(); - - expect(result).toStrictEqual({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); - expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); - expect(isDone()).toBe(true); - }, - ); + await withMockSubscriptionService(async ({ service, config }) => { + (handleFetch as jest.Mock).mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], + }); + + const result = await service.getSubscriptions(); + + expect(result).toStrictEqual({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], + }); + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); }); it('should throw SubscriptionServiceError for error responses', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl).get('/v1/subscriptions').reply(404, MOCK_ERROR_RESPONSE); + await withMockSubscriptionService(async ({ service }) => { + (handleFetch as jest.Mock).mockRejectedValue( + new Error('Network error'), + ); await expect(service.getSubscriptions()).rejects.toThrow( SubscriptionServiceError, @@ -169,8 +159,10 @@ describe('SubscriptionService', () => { }); it('should throw SubscriptionServiceError for network errors', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl).get('/v1/subscriptions').replyWithError('Network error'); + await withMockSubscriptionService(async ({ service }) => { + (handleFetch as jest.Mock).mockRejectedValue( + new Error('Network error'), + ); await expect(service.getSubscriptions()).rejects.toThrow( SubscriptionServiceError, @@ -190,52 +182,57 @@ describe('SubscriptionService', () => { }); it('should handle null exceptions in catch block', async () => { - const fetchMock = jest.fn().mockRejectedValueOnce(null); - const config = createMockConfig({ fetchFn: fetchMock }); + const config = createMockConfig({}); const service = new SubscriptionService(config); + (handleFetch as jest.Mock).mockRejectedValue(null); await expect( service.cancelSubscription({ subscriptionId: 'sub_123456789' }), ).rejects.toThrow(SubscriptionServiceError); }); + + it('should handle non-Error exceptions in catch block', async () => { + await withMockSubscriptionService(async ({ service }) => { + // Mock handleFetch to throw null (not an Error instance) + (handleFetch as jest.Mock).mockRejectedValue(null); + + await expect(service.getSubscriptions()).rejects.toThrow( + SubscriptionServiceError, + ); + }); + }); }); describe('cancelSubscription', () => { it('should cancel subscription successfully', async () => { - await withMockSubscriptionService( - async ({ service, testUrl, config }) => { - nock(testUrl) - .delete('/v1/subscriptions/sub_123456789') - .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) - .reply(200, {}); - - await service.cancelSubscription({ subscriptionId: 'sub_123456789' }); - - expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); - expect(isDone()).toBe(true); - }, - ); + await withMockSubscriptionService(async ({ service, config }) => { + (handleFetch as jest.Mock).mockResolvedValue({}); + + await service.cancelSubscription({ subscriptionId: 'sub_123456789' }); + + expect(config.auth.getAccessToken).toHaveBeenCalledTimes(1); + }); }); it('should throw SubscriptionServiceError for network errors', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl) - .delete('/v1/subscriptions/sub_123456789') - .replyWithError('Network error'); + await withMockSubscriptionService(async ({ service }) => { + (handleFetch as jest.Mock).mockRejectedValue( + new Error('Network error'), + ); await expect( service.cancelSubscription({ subscriptionId: 'sub_123456789' }), - ).rejects.toThrow(/Network error/u); + ).rejects.toThrow(SubscriptionServiceError); }); }); }); describe('startSubscription', () => { it('should start subscription successfully', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { - nock(testUrl) - .post('/v1/subscriptions/card', MOCK_START_SUBSCRIPTION_REQUEST) - .reply(200, MOCK_START_SUBSCRIPTION_RESPONSE); + await withMockSubscriptionService(async ({ service }) => { + (handleFetch as jest.Mock).mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); const result = await service.startSubscriptionWithCard( MOCK_START_SUBSCRIPTION_REQUEST, @@ -248,16 +245,15 @@ describe('SubscriptionService', () => { it('should start subscription without trial', async () => { const config = createMockConfig(); const service = new SubscriptionService(config); - const testUrl = getTestUrl(Env.DEV); const request: StartSubscriptionRequest = { products: [ProductType.SHIELD], isTrialRequested: false, recurringInterval: RecurringInterval.month, }; - nock(testUrl) - .post('/v1/subscriptions/card', request) - .reply(200, MOCK_START_SUBSCRIPTION_RESPONSE); + (handleFetch as jest.Mock).mockResolvedValue( + MOCK_START_SUBSCRIPTION_RESPONSE, + ); const result = await service.startSubscriptionWithCard(request); @@ -281,7 +277,7 @@ describe('SubscriptionService', () => { describe('startCryptoSubscription', () => { it('should start crypto subscription successfully', async () => { - await withMockSubscriptionService(async ({ service, testUrl }) => { + await withMockSubscriptionService(async ({ service }) => { const request: StartCryptoSubscriptionRequest = { products: [ProductType.SHIELD], isTrialRequested: false, @@ -298,10 +294,7 @@ describe('SubscriptionService', () => { status: SubscriptionStatus.active, }; - nock(testUrl) - .post('/v1/subscriptions/crypto', request) - .matchHeader('Authorization', `Bearer ${MOCK_ACCESS_TOKEN}`) - .reply(200, response); + (handleFetch as jest.Mock).mockResolvedValue(response); const result = await service.startSubscriptionWithCrypto(request); @@ -319,9 +312,8 @@ describe('SubscriptionService', () => { it('should fetch pricing successfully', async () => { const config = createMockConfig(); const service = new SubscriptionService(config); - const testUrl = getTestUrl(Env.DEV); - nock(testUrl).get('/v1/pricing').reply(200, mockPricingResponse); + (handleFetch as jest.Mock).mockResolvedValue(mockPricingResponse); const result = await service.getPricing(); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 0fc79fef85..0d399ba164 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -1,3 +1,5 @@ +import { handleFetch } from '@metamask/controller-utils'; + import { getEnvUrls, SubscriptionControllerErrorMessage, @@ -6,7 +8,6 @@ import { import { SubscriptionServiceError } from './errors'; import type { AuthUtils, - FetchFunction, GetSubscriptionsResponse, ISubscriptionService, PricingResponse, @@ -19,7 +20,6 @@ import type { export type SubscriptionServiceConfig = { env: Env; auth: AuthUtils; - fetchFn: FetchFunction; }; export const SUBSCRIPTION_URL = (env: Env, path: string) => @@ -28,14 +28,11 @@ export const SUBSCRIPTION_URL = (env: Env, path: string) => export class SubscriptionService implements ISubscriptionService { readonly #env: Env; - readonly #fetch: FetchFunction; - public authUtils: AuthUtils; constructor(config: SubscriptionServiceConfig) { this.#env = config.env; this.authUtils = config.auth; - this.#fetch = config.fetchFn; } async getSubscriptions(): Promise { @@ -82,7 +79,7 @@ export class SubscriptionService implements ISubscriptionService { const headers = await this.#getAuthorizationHeader(); const url = new URL(SUBSCRIPTION_URL(this.#env, path)); - const response = await this.#fetch(url.toString(), { + const response = await handleFetch(url.toString(), { method, headers: { 'Content-Type': 'application/json', diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index a752f4b7bf..81f39e5d04 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -104,12 +104,6 @@ export type AuthUtils = { getAccessToken: () => Promise; }; -export type FetchFunction = ( - input: RequestInfo | URL, - init?: RequestInit, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) => Promise; - export type ProductPrice = { interval: RecurringInterval; unitAmount: number; // amount in the smallest unit of the currency, e.g., cents @@ -188,10 +182,10 @@ export type GetCryptoApproveTransactionResponse = { export type ISubscriptionService = { getSubscriptions(): Promise; cancelSubscription(request: { subscriptionId: string }): Promise; - getPricing(): Promise; startSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise; + getPricing(): Promise; startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise; diff --git a/yarn.lock b/yarn.lock index 479aa8ce5d..5c672d93d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4657,7 +4657,6 @@ __metadata: "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" - nock: "npm:^13.3.1" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typedoc-plugin-missing-exports: "npm:^2.0.0" From 048f5c5a906ca0a0e88d84fc3dc9ce7b805b7983 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 9 Sep 2025 09:30:12 +0700 Subject: [PATCH 22/26] fix: remove redundant test --- .../src/constants.test.ts | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/subscription-controller/src/constants.test.ts b/packages/subscription-controller/src/constants.test.ts index a53ba708ab..a2277bc61a 100644 --- a/packages/subscription-controller/src/constants.test.ts +++ b/packages/subscription-controller/src/constants.test.ts @@ -1,28 +1,8 @@ -import { Env, getEnvUrls, controllerName } from './constants'; +import type { Env } from './constants'; +import { getEnvUrls, controllerName } from './constants'; describe('constants', () => { describe('getEnvUrls', () => { - it('should return correct URLs for dev environment', () => { - const result = getEnvUrls(Env.DEV); - expect(result).toStrictEqual({ - subscriptionApiUrl: 'https://subscription.dev-api.cx.metamask.io', - }); - }); - - it('should return correct URLs for uat environment', () => { - const result = getEnvUrls(Env.UAT); - expect(result).toStrictEqual({ - subscriptionApiUrl: 'https://subscription.uat-api.cx.metamask.io', - }); - }); - - it('should return correct URLs for prd environment', () => { - const result = getEnvUrls(Env.PRD); - expect(result).toStrictEqual({ - subscriptionApiUrl: 'https://subscription.api.cx.metamask.io', - }); - }); - it('should throw error for invalid environment', () => { // Type assertion to test invalid environment const invalidEnv = 'invalid' as Env; From d4fa47eaa6418116ebd3e69e31ad18047fb9cf5b Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 9 Sep 2025 15:29:39 +0700 Subject: [PATCH 23/26] refactor: revert moving --- .../subscription-controller/src/SubscriptionService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 0d399ba164..225de019ee 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -65,11 +65,6 @@ export class SubscriptionService implements ISubscriptionService { return await this.#makeRequest(path, 'POST', request); } - async getPricing(): Promise { - const path = 'pricing'; - return await this.#makeRequest(path); - } - async #makeRequest( path: string, method: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH' = 'GET', @@ -103,4 +98,9 @@ export class SubscriptionService implements ISubscriptionService { const accessToken = await this.authUtils.getAccessToken(); return { Authorization: `Bearer ${accessToken}` }; } + + async getPricing(): Promise { + const path = 'pricing'; + return await this.#makeRequest(path); + } } From 3f0df4badaf8f9ba088c10cdb5225fdf351f5ea5 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 9 Sep 2025 17:05:55 +0700 Subject: [PATCH 24/26] refactor: make scale more clear --- .../src/SubscriptionController.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index fe956342be..4fda902caf 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -350,21 +350,19 @@ export class SubscriptionController extends BaseController< // conversionRate is in usd decimal. In most currencies, we only care about 2 decimals (cents) // So, scale must be max of 10 ** 4 (most exchanges trade with max 4 decimals of usd) // This allows us to avoid floating point math and keep precision. - const CONVERSION_RATE_SCALE = 10n ** 4n; - const conversionRateScaled = BigInt( - Number(conversionRate) * Number(CONVERSION_RATE_SCALE), - ); + const SCALE = 10n ** 4n; + const conversionRateScaled = + BigInt(Math.round(Number(conversionRate) * Number(SCALE))) / SCALE; + // price of the product const priceAmount = this.#getSubscriptionPriceAmount(price); - const priceAmountScaled = BigInt( - priceAmount * Number(CONVERSION_RATE_SCALE), - ); + const priceAmountScaled = + BigInt(Math.round(priceAmount * Number(SCALE))) / SCALE; - const amount = - ((priceAmountScaled / CONVERSION_RATE_SCALE) * - BigInt(10) ** BigInt(tokenPaymentInfo.decimals) * - CONVERSION_RATE_SCALE) / - conversionRateScaled; - return amount; + const tokenDecimal = BigInt(10) ** BigInt(tokenPaymentInfo.decimals); + + const tokenAmount = + (priceAmountScaled * tokenDecimal) / conversionRateScaled; + return tokenAmount; } #assertIsUserNotSubscribed({ products }: { products: ProductType[] }) { From 3ae5b3ff066814eec7c03ce6c07914c8d88b1ad2 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 9 Sep 2025 17:26:17 +0700 Subject: [PATCH 25/26] chore: move package to dep --- packages/subscription-controller/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index d4b46c7d62..e147744aa5 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -48,11 +48,11 @@ }, "dependencies": { "@metamask/base-controller": "^8.3.0", + "@metamask/controller-utils": "^11.12.0", "@metamask/utils": "^11.4.2" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/controller-utils": "^11.12.0", "@metamask/profile-sync-controller": "^24.0.0", "@types/jest": "^27.4.1", "deepmerge": "^4.2.2", From 6853156acdb7949380e96b833081366f632bc973 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 9 Sep 2025 17:41:43 +0700 Subject: [PATCH 26/26] fix: lint --- .../subscription-controller/src/SubscriptionController.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 887384889a..feca9b5c8e 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -205,7 +205,8 @@ async function withController( ...args: WithControllerArgs ) { const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; - const { messenger, mockPerformSignOut, baseMessenger } = createMockSubscriptionMessenger(); + const { messenger, mockPerformSignOut, baseMessenger } = + createMockSubscriptionMessenger(); const { mockService } = createMockSubscriptionService(); const controller = new SubscriptionController({