From 1c0a92a09571e3f6343031c17e68399000d98b30 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 19:46:30 +0700 Subject: [PATCH 01/23] feat: subscription delegation service orchestration --- packages/chomp-api-service/CHANGELOG.md | 6 + .../src/chomp-api-service.test.ts | 41 ++ .../src/chomp-api-service.ts | 12 +- packages/chomp-api-service/src/index.ts | 1 + packages/chomp-api-service/src/types.ts | 16 +- packages/subscription-controller/CHANGELOG.md | 6 + packages/subscription-controller/package.json | 5 + .../subscription-controller/src/constants.ts | 13 + packages/subscription-controller/src/index.ts | 19 + ...onDelegationService-method-action-types.ts | 27 ++ .../SubscriptionDelegationService.test.ts | 452 ++++++++++++++++++ .../SubscriptionDelegationService.ts | 340 +++++++++++++ .../subscription-delegation/amount.test.ts | 94 ++++ .../src/subscription-delegation/amount.ts | 90 ++++ .../subscription-delegation/caveats.test.ts | 96 ++++ .../src/subscription-delegation/caveats.ts | 87 ++++ .../fingerprint.test.ts | 180 +++++++ .../subscription-delegation/fingerprint.ts | 106 ++++ .../src/subscription-delegation/types.ts | 61 +++ .../tsconfig.build.json | 26 +- .../subscription-controller/tsconfig.json | 27 +- yarn.lock | 5 + 22 files changed, 1689 insertions(+), 21 deletions(-) create mode 100644 packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/amount.test.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/amount.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/caveats.test.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/caveats.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/fingerprint.ts create mode 100644 packages/subscription-controller/src/subscription-delegation/types.ts diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index faaa749075b..40e25bc8dc1 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Accept `'subscription-payment'` as a CHOMP intent / delegation metadata type alongside `'cash-deposit'` and `'cash-withdrawal'`. + - Adds exported `ChompIntentType` alias covering all three values. + - Response structs for create-intents, get-intents-by-address, and service-details now accept the new type. + ## [4.0.2] ### Changed diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts index c80b6ba7b5c..bbe8b4a3207 100644 --- a/packages/chomp-api-service/src/chomp-api-service.test.ts +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -520,6 +520,47 @@ describe('ChompApiService', () => { 'At path: 0.delegationHash -- Expected a string', ); }); + + it('accepts subscription-payment intent metadata type', async () => { + const subscriptionIntentParams = [ + { + account: '0xabc' as const, + delegationHash: '0xdef' as const, + chainId: '0x1' as const, + metadata: { + allowance: '0xff' as const, + tokenSymbol: 'pvmUSD', + tokenAddress: '0x123' as const, + type: 'subscription-payment' as const, + }, + }, + ]; + const subscriptionIntentResponse = [ + { + delegationHash: '0xdef', + metadata: { + allowance: '0xff', + tokenSymbol: 'pvmUSD', + tokenAddress: '0x123', + type: 'subscription-payment', + }, + createdAt: '2026-01-01T00:00:00Z', + }, + ]; + + nock(BASE_URL) + .post('/v1/intent', subscriptionIntentParams) + .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) + .reply(201, subscriptionIntentResponse); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'ChompApiService:createIntents', + subscriptionIntentParams, + ); + + expect(result).toStrictEqual(subscriptionIntentResponse); + }); }); describe('getIntentsByAddress', () => { diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index d3348fd7a6a..2cfc198e5db 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -193,6 +193,12 @@ const VerifyDelegationResponseStruct = type({ errors: optional(array(string())), }); +const ChompIntentTypeStruct = enums([ + 'cash-deposit', + 'cash-withdrawal', + 'subscription-payment', +]); + const SendIntentResponseArrayStruct = array( type({ delegationHash: StrictHexStruct, @@ -200,7 +206,7 @@ const SendIntentResponseArrayStruct = array( allowance: StrictHexStruct, tokenSymbol: string(), tokenAddress: StrictHexStruct, - type: enums(['cash-deposit', 'cash-withdrawal']), + type: ChompIntentTypeStruct, }), createdAt: string(), }), @@ -216,7 +222,7 @@ const IntentEntryArrayStruct = array( allowance: StrictHexStruct, tokenAddress: StrictHexStruct, tokenSymbol: string(), - type: enums(['cash-deposit', 'cash-withdrawal']), + type: ChompIntentTypeStruct, }), }), ); @@ -233,7 +239,7 @@ const ServiceDetailsProtocolStruct = type({ }), ), adapterAddress: StrictHexStruct, - intentTypes: array(enums(['cash-deposit', 'cash-withdrawal'])), + intentTypes: array(ChompIntentTypeStruct), }); const ServiceDetailsResponseStruct = type({ diff --git a/packages/chomp-api-service/src/index.ts b/packages/chomp-api-service/src/index.ts index ab5b8faa26f..407511bb6a3 100644 --- a/packages/chomp-api-service/src/index.ts +++ b/packages/chomp-api-service/src/index.ts @@ -23,6 +23,7 @@ export type { AssociateAddressParams, AssociateAddressResponse, AuthorizationData, + ChompIntentType, CreateUpgradeParams, CreateUpgradeResponse, CreateWithdrawalParams, diff --git a/packages/chomp-api-service/src/types.ts b/packages/chomp-api-service/src/types.ts index e4dba142593..89c2f60b259 100644 --- a/packages/chomp-api-service/src/types.ts +++ b/packages/chomp-api-service/src/types.ts @@ -40,11 +40,19 @@ export type VerifyDelegationParams = { chainId: Hex; }; +/** + * CHOMP intent / delegation metadata type discriminator. + */ +export type ChompIntentType = + | 'cash-deposit' + | 'cash-withdrawal' + | 'subscription-payment'; + export type IntentMetadataParams = { allowance: Hex; tokenSymbol: string; tokenAddress: Hex; - type: 'cash-deposit' | 'cash-withdrawal'; + type: ChompIntentType; }; export type SendIntentParams = { @@ -137,7 +145,7 @@ export type IntentMetadataResponse = { allowance: Hex; tokenSymbol: string; tokenAddress: Hex; - type: 'cash-deposit' | 'cash-withdrawal'; + type: ChompIntentType; }; export type SendIntentResponse = { @@ -158,7 +166,7 @@ export type IntentEntry = { allowance: Hex; tokenAddress: Hex; tokenSymbol: string; - type: 'cash-deposit' | 'cash-withdrawal'; + type: ChompIntentType; }; }; @@ -176,7 +184,7 @@ export type ServiceDetailsSupportedToken = { export type ServiceDetailsProtocol = { supportedTokens: ServiceDetailsSupportedToken[]; adapterAddress: Hex; - intentTypes: ('cash-deposit' | 'cash-withdrawal')[]; + intentTypes: ChompIntentType[]; }; export type ServiceDetailsChain = { diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index b1de16c19b5..d4ad921c615 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `SubscriptionDelegationService` for Money Account Plus subscription-payment delegation setup. + - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. + - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. + - Construct with immutable, chain-scoped `SubscriptionDelegationConfig` (CHOMP delegate address); Delegation Framework enforcers are resolved from `@metamask/delegation-deployments`. + - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. + - Add `getBenefits` to fetch and persist Money Account Plus subscription benefits. ([#10103](https://github.com/MetaMask/core/pull/10103)) - New persisted `SubscriptionControllerState.benefits` field and `SubscriptionController:getBenefits` messenger action. - Requires an active `MONEY_ACCOUNT_PLUS` subscription; otherwise throws `UserNotSubscribed` without calling the service. diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 4a0ff223d87..4ddb4813eeb 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -55,9 +55,14 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/authenticated-user-storage": "^3.0.2", "@metamask/base-controller": "^9.1.0", "@metamask/base-data-service": "^1.0.0", + "@metamask/chomp-api-service": "^4.0.2", "@metamask/controller-utils": "^12.3.0", + "@metamask/delegation-controller": "^3.0.2", + "@metamask/delegation-core": "^2.2.1", + "@metamask/delegation-deployments": "^1.4.0", "@metamask/messenger": "^2.0.0", "@metamask/polling-controller": "^16.0.9", "@metamask/profile-sync-controller": "^29.0.0", diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index d01c4d6b6ca..36613598b34 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -69,6 +69,19 @@ export enum SubscriptionServiceErrorMessage { FailedToGetBillingPortalUrl = 'Failed to get billing portal url', } +export enum SubscriptionDelegationServiceErrorMessage { + InvalidAmount = 'Subscription delegation amount must be a non-negative integer', + InvalidDecimals = 'Subscription delegation decimals must be a non-negative integer', + LossyAmountScale = 'Subscription delegation amount cannot be scaled to token decimals without remainder', + UnsupportedRecurringInterval = 'Unsupported subscription recurring interval', + UnsupportedProduct = 'Subscription delegation is only supported for Money Account Plus', + ChainIdMismatch = 'Subscription delegation request chainId does not match the configured chainId', + DelegationContractsNotFound = 'Subscription delegation contracts were not found for the configured chain', + ChompRejectedDelegation = 'CHOMP rejected the subscription delegation', + ChompMissingDelegationHash = 'CHOMP verify response did not include a delegation hash', + ChompDelegationHashMismatch = 'CHOMP verify response delegation hash does not match the locally computed hash', +} + export const DEFAULT_POLLING_INTERVAL = 5 * 60 * 1_000; // 5 minutes export const ACTIVE_SUBSCRIPTION_STATUSES = [ diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 6540cf136b6..2a3cbca142b 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -116,6 +116,7 @@ export { Env, SubscriptionControllerErrorMessage, SubscriptionServiceErrorMessage, + SubscriptionDelegationServiceErrorMessage, } from './constants.js'; export type { SubscriptionServiceOptions, @@ -148,3 +149,21 @@ export type { SubscriptionServiceGetPricingAction, SubscriptionServiceGetBillingPortalUrlAction, } from './SubscriptionService-method-action-types.js'; + +export type { + SubscriptionDelegationServiceActions, + SubscriptionDelegationServiceEvents, + SubscriptionDelegationServiceMessenger, + SubscriptionDelegationServiceOptions, +} from './subscription-delegation/SubscriptionDelegationService.js'; +export { + SubscriptionDelegationService, + serviceName as subscriptionDelegationServiceName, +} from './subscription-delegation/SubscriptionDelegationService.js'; +export type { SubscriptionDelegationServicePrepareDelegationAction } from './subscription-delegation/SubscriptionDelegationService-method-action-types.js'; +export type { + PrepareSubscriptionDelegationRequest, + PreparedSubscriptionDelegation, + SubscriptionDelegationConfig, +} from './subscription-delegation/types.js'; +export { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './subscription-delegation/types.js'; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts new file mode 100644 index 00000000000..290e50b8f55 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -0,0 +1,27 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { SubscriptionDelegationService } from './SubscriptionDelegationService.js'; + +/** + * Prepares a subscription-payment delegation and returns its verified hash. + * + * Reuses a stored AUS delegation that matches the semantic fingerprint when + * one exists (ensuring a CHOMP intent is active for its hash). Otherwise + * builds, signs, verifies, persists, and registers a new delegation. + * + * @param request - Authoritative pricing and payer details for the delegation. + * @returns The verified delegation hash and whether it was created or reused. + */ +export type SubscriptionDelegationServicePrepareDelegationAction = { + type: `SubscriptionDelegationService:prepareDelegation`; + handler: SubscriptionDelegationService['prepareDelegation']; +}; + +/** + * Union of all SubscriptionDelegationService action types. + */ +export type SubscriptionDelegationServiceMethodActions = + SubscriptionDelegationServicePrepareDelegationAction; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts new file mode 100644 index 00000000000..f0888579bc2 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -0,0 +1,452 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; +import { + createERC20TokenPeriodTransferTerms, + createValueLteTerms, + hashDelegation, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import type { Hex } from '@metamask/utils'; + +import { PRODUCT_TYPES, RECURRING_INTERVALS } from '../types.js'; +import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; + +import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; +import { + SubscriptionDelegationService, + serviceName, +} from './SubscriptionDelegationService.js'; +import type { SubscriptionDelegationServiceMessenger } from './SubscriptionDelegationService.js'; +import type { + PrepareSubscriptionDelegationRequest, + SubscriptionDelegationConfig, +} from './types.js'; +import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; + +const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; +const PAYER = '0x5555555555555555555555555555555555555555' as Hex; +const CHAIN_ID = '0x1' as Hex; +const SIGNATURE: Hex = `0x${'ab'.repeat(65)}`; +const { + ValueLteEnforcer: VALUE_LTE, + ERC20PeriodTransferEnforcer: PERIOD, +} = DELEGATOR_CONTRACTS['1.3.0'][1]; + +const CONFIG: SubscriptionDelegationConfig = { + chainId: CHAIN_ID, + delegateAddress: DELEGATE, +}; + +const REQUEST: PrepareSubscriptionDelegationRequest = { + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + recurringInterval: RECURRING_INTERVALS.month, + chainId: CHAIN_ID, + payerAddress: PAYER, + tokenAddress: TOKEN, + tokenSymbol: 'pvmUSD', + tokenDecimals: 18, + unitAmount: 1000, + unitDecimals: 2, + minimumFundingCycles: 3, +}; + +const PERIOD_AMOUNT = calculatePeriodAmount({ + unitAmount: REQUEST.unitAmount, + unitDecimals: REQUEST.unitDecimals, + tokenDecimals: REQUEST.tokenDecimals, +}); +const PERIOD_DURATION = getPeriodDuration(REQUEST.recurringInterval); + +type Mocks = { + listDelegations: jest.Mock; + createDelegation: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function setup( + options: { + listDelegations?: unknown[]; + intents?: unknown[]; + verify?: { valid: boolean; delegationHash?: Hex; errors?: string[] }; + config?: SubscriptionDelegationConfig; + } = {}, +) { + const mocks: Mocks = { + listDelegations: jest.fn().mockResolvedValue(options.listDelegations ?? []), + createDelegation: jest.fn().mockResolvedValue(undefined), + signDelegation: jest.fn().mockResolvedValue(SIGNATURE), + verifyDelegation: jest + .fn() + .mockImplementation(async ({ signedDelegation }) => { + if (options.verify) { + return options.verify; + } + const delegationHash = hashDelegation({ + ...signedDelegation, + salt: BigInt(signedDelegation.salt), + }); + return { valid: true, delegationHash }; + }), + getIntentsByAddress: jest.fn().mockResolvedValue(options.intents ?? []), + createIntents: jest.fn().mockResolvedValue([]), + }; + + type AllowedActions = + | { + type: 'AuthenticatedUserStorageService:listDelegations'; + handler: Mocks['listDelegations']; + } + | { + type: 'AuthenticatedUserStorageService:createDelegation'; + handler: Mocks['createDelegation']; + } + | { + type: 'DelegationController:signDelegation'; + handler: Mocks['signDelegation']; + } + | { + type: 'ChompApiService:verifyDelegation'; + handler: Mocks['verifyDelegation']; + } + | { + type: 'ChompApiService:getIntentsByAddress'; + handler: Mocks['getIntentsByAddress']; + } + | { + type: 'ChompApiService:createIntents'; + handler: Mocks['createIntents']; + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + | AllowedActions + | { + type: `${typeof serviceName}:prepareDelegation`; + handler: SubscriptionDelegationService['prepareDelegation']; + }, + never + >({ namespace: MOCK_ANY_NAMESPACE }); + + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); + + const messenger: SubscriptionDelegationServiceMessenger = new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', + ], + events: [], + }); + + const service = new SubscriptionDelegationService({ + messenger, + config: options.config ?? CONFIG, + }); + + return { service, rootMessenger, mocks }; +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function buildStoredDelegation({ + periodAmount = PERIOD_AMOUNT, + periodDuration = PERIOD_DURATION, + delegationHash = `0x${'dd'.repeat(32)}`, +}: { + periodAmount?: bigint; + periodDuration?: number; + delegationHash?: Hex; +} = {}) { + return { + signedDelegation: { + delegate: DELEGATE, + delegator: PAYER, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: VALUE_LTE, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x', + }, + { + enforcer: PERIOD, + terms: createERC20TokenPeriodTransferTerms({ + tokenAddress: TOKEN, + periodAmount, + periodDuration, + startDate: 1_700_000_000, + }), + args: '0x', + }, + ], + salt: `0x${'aa'.repeat(32)}`, + signature: SIGNATURE, + }, + metadata: { + delegationHash, + chainIdHex: CHAIN_ID, + allowance: `0x${periodAmount.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress: TOKEN, + type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + }, + }; +} + +function expectNoSideEffects(mocks: Mocks): void { + expect(mocks.listDelegations).not.toHaveBeenCalled(); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + expect(mocks.getIntentsByAddress).not.toHaveBeenCalled(); + expect(mocks.createIntents).not.toHaveBeenCalled(); +} + +describe('SubscriptionDelegationService', () => { + describe('constructor', () => { + it('throws when Delegation Framework contracts are unavailable for the configured chain', () => { + expect(() => + setup({ + config: { + chainId: '0xffffffff', + delegateAddress: DELEGATE, + }, + }), + ).toThrow( + `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: 0xffffffff`, + ); + }); + }); + + describe('prepareDelegation', () => { + it('creates, verifies, persists, and registers a new delegation using config', async () => { + const { service, mocks } = setup(); + + const result = await service.prepareDelegation(REQUEST); + + expect(result.disposition).toBe('created'); + expect(result.delegationHash).toMatch(/^0x[0-9a-fA-F]{64}$/u); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.signDelegation).toHaveBeenCalledWith({ + delegation: expect.objectContaining({ + delegate: DELEGATE, + delegator: PAYER, + caveats: [ + expect.objectContaining({ enforcer: VALUE_LTE }), + expect.objectContaining({ enforcer: PERIOD }), + ], + }), + chainId: CHAIN_ID, + }); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledWith({ + signedDelegation: expect.objectContaining({ + delegate: DELEGATE, + delegator: PAYER, + signature: SIGNATURE, + }), + metadata: expect.objectContaining({ + delegationHash: result.delegationHash, + chainIdHex: CHAIN_ID, + allowance: `0x${PERIOD_AMOUNT.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress: TOKEN, + type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + }), + }); + expect(mocks.createIntents).toHaveBeenCalledWith([ + { + account: PAYER, + delegationHash: result.delegationHash, + chainId: CHAIN_ID, + metadata: { + allowance: `0x${PERIOD_AMOUNT.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress: TOKEN, + type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + }, + }, + ]); + }); + + it('is callable through the messenger', async () => { + const { rootMessenger } = setup(); + + const result = await rootMessenger.call( + 'SubscriptionDelegationService:prepareDelegation', + REQUEST, + ); + + expect(result.disposition).toBe('created'); + }); + + it('reuses a matching stored delegation and skips sign/verify when an intent is active', async () => { + const stored = buildStoredDelegation(); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [ + { + account: PAYER, + delegationHash: stored.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: stored.metadata, + }, + ], + }); + + const result = await service.prepareDelegation(REQUEST); + + expect(result).toStrictEqual({ + delegationHash: stored.metadata.delegationHash, + disposition: 'reused', + }); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('reuses a matching delegation and registers an intent when missing', async () => { + const stored = buildStoredDelegation(); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [], + }); + + const result = await service.prepareDelegation(REQUEST); + + expect(result.disposition).toBe('reused'); + expect(mocks.createIntents).toHaveBeenCalledWith([ + expect.objectContaining({ + account: PAYER, + delegationHash: stored.metadata.delegationHash, + }), + ]); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + }); + + it('throws when CHOMP rejects the delegation and does not persist', async () => { + const { service, mocks } = setup({ + verify: { valid: false, errors: ['bad caveat'] }, + }); + + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.ChompRejectedDelegation, + ); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('reports an unknown error when CHOMP provides no rejection details', async () => { + const { service } = setup({ + verify: { valid: false }, + }); + + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + `${SubscriptionDelegationServiceErrorMessage.ChompRejectedDelegation}: unknown error`, + ); + }); + + it('throws when CHOMP omits the delegation hash', async () => { + const { service, mocks } = setup({ + verify: { valid: true }, + }); + + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.ChompMissingDelegationHash, + ); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('throws when CHOMP returns a mismatched delegation hash', async () => { + const { service, mocks } = setup({ + verify: { + valid: true, + delegationHash: `0x${'ee'.repeat(32)}`, + }, + }); + + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.ChompDelegationHashMismatch, + ); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('rejects Shield before any side effects', async () => { + const { service, mocks } = setup(); + const shieldRequest = { + ...REQUEST, + product: PRODUCT_TYPES.SHIELD, + } as unknown as PrepareSubscriptionDelegationRequest; + + await expect(service.prepareDelegation(shieldRequest)).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.UnsupportedProduct, + ); + expectNoSideEffects(mocks); + }); + + it('rejects a chainId that does not match config before any side effects', async () => { + const { service, mocks } = setup(); + + await expect( + service.prepareDelegation({ + ...REQUEST, + chainId: '0x89', + }), + ).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.ChainIdMismatch, + ); + expectNoSideEffects(mocks); + }); + + it('accepts a matching chainId case-insensitively', async () => { + const { service, mocks } = setup(); + + const result = await service.prepareDelegation({ + ...REQUEST, + chainId: '0X1' as Hex, + }); + + expect(result.disposition).toBe('created'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts new file mode 100644 index 00000000000..fa3eca4a483 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -0,0 +1,340 @@ +import type { + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceListDelegationsAction, +} from '@metamask/authenticated-user-storage'; +import type { + ChompApiServiceCreateIntentsAction, + ChompApiServiceGetIntentsByAddressAction, + ChompApiServiceVerifyDelegationAction, +} from '@metamask/chomp-api-service'; +import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; +import { hashDelegation } from '@metamask/delegation-core'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import type { Messenger } from '@metamask/messenger'; +import { add0x, hexToNumber } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; +import { PRODUCT_TYPES } from '../types.js'; +import type { SubscriptionDelegationServiceMethodActions } from './SubscriptionDelegationService-method-action-types.js'; +import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; +import { buildUnsignedSubscriptionDelegation } from './caveats.js'; +import { + equalsIgnoreCase, + makeMatchesSubscriptionDelegation, +} from './fingerprint.js'; +import type { + PrepareSubscriptionDelegationRequest, + PreparedSubscriptionDelegation, + SubscriptionDelegationConfig, + SubscriptionDelegationEnforcers, +} from './types.js'; +import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; + +/** + * The name of the {@link SubscriptionDelegationService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'SubscriptionDelegationService'; + +const MESSENGER_EXPOSED_METHODS = ['prepareDelegation'] as const; + +const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; + +function resolveEnforcers(chainId: Hex): SubscriptionDelegationEnforcers { + const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]?.[hexToNumber(chainId)]; + + if ( + !contracts?.ValueLteEnforcer || + !contracts.ERC20PeriodTransferEnforcer + ) { + throw new Error( + `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: ${chainId}`, + ); + } + + return { + valueLte: contracts.ValueLteEnforcer, + erc20TokenPeriodTransfer: contracts.ERC20PeriodTransferEnforcer, + }; +} + +/** + * Actions that {@link SubscriptionDelegationService} exposes to other consumers. + */ +export type SubscriptionDelegationServiceActions = + SubscriptionDelegationServiceMethodActions; + +/** + * Actions from other messengers that {@link SubscriptionDelegationServiceMessenger} calls. + */ +type AllowedActions = + | AuthenticatedUserStorageServiceListDelegationsAction + | AuthenticatedUserStorageServiceCreateDelegationAction + | ChompApiServiceVerifyDelegationAction + | ChompApiServiceCreateIntentsAction + | ChompApiServiceGetIntentsByAddressAction + | DelegationControllerSignDelegationAction; + +/** + * Events that {@link SubscriptionDelegationService} exposes to other consumers. + */ +export type SubscriptionDelegationServiceEvents = never; + +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link SubscriptionDelegationService}. + */ +export type SubscriptionDelegationServiceMessenger = Messenger< + typeof serviceName, + SubscriptionDelegationServiceActions | AllowedActions, + SubscriptionDelegationServiceEvents | AllowedEvents +>; + +/** + * Options for constructing {@link SubscriptionDelegationService}. + */ +export type SubscriptionDelegationServiceOptions = { + messenger: SubscriptionDelegationServiceMessenger; + /** + * Immutable, chain-scoped CHOMP delegate configuration for Money Account + * Plus subscription-payment delegations. + */ + config: SubscriptionDelegationConfig; +}; + +type SubscriptionIntentParams = { + account: Hex; + chainId: Hex; + delegationHash: Hex; + allowance: Hex; + tokenSymbol: string; + tokenAddress: Hex; +}; + +/** + * Stateless orchestrator for subscription-payment delegation setup. + * + * Owns the workflow: size periodic caveats → sign → CHOMP verify → persist to + * Authenticated User Storage → register CHOMP intent. Returns a verified + * `delegationHash` for `SubscriptionController.startSubscriptionWithCrypto`. + * + * The CHOMP delegate comes from constructor + * {@link SubscriptionDelegationConfig}; Delegation Framework enforcers are + * resolved from `@metamask/delegation-deployments` for the configured chain. + * + * Does not own subscription state; `SubscriptionController` does not depend on + * this service. Only Money Account Plus is supported. + */ +export class SubscriptionDelegationService { + readonly name: typeof serviceName = serviceName; + + readonly #messenger: SubscriptionDelegationServiceMessenger; + + readonly #config: SubscriptionDelegationConfig; + + readonly #enforcers: SubscriptionDelegationEnforcers; + + constructor(options: SubscriptionDelegationServiceOptions) { + this.#messenger = options.messenger; + this.#config = options.config; + this.#enforcers = resolveEnforcers(this.#config.chainId); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Prepares a subscription-payment delegation and returns its verified hash. + * + * Reuses a stored AUS delegation that matches the semantic fingerprint when + * one exists (ensuring a CHOMP intent is active for its hash). Otherwise + * builds, signs, verifies, persists, and registers a new delegation. + * + * @param request - Authoritative pricing and payer details for the delegation. + * @returns The verified delegation hash and whether it was created or reused. + */ + async prepareDelegation( + request: PrepareSubscriptionDelegationRequest, + ): Promise { + if (request.product !== PRODUCT_TYPES.MONEY_ACCOUNT_PLUS) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.UnsupportedProduct, + ); + } + + if (!equalsIgnoreCase(request.chainId, this.#config.chainId)) { + throw new Error(SubscriptionDelegationServiceErrorMessage.ChainIdMismatch); + } + + const periodAmount = calculatePeriodAmount({ + unitAmount: request.unitAmount, + unitDecimals: request.unitDecimals, + tokenDecimals: request.tokenDecimals, + }); + const periodDuration = getPeriodDuration(request.recurringInterval); + + const matches = makeMatchesSubscriptionDelegation({ + delegatorAddress: request.payerAddress, + delegateAddress: this.#config.delegateAddress, + chainId: request.chainId, + tokenAddress: request.tokenAddress, + periodAmount, + periodDuration, + enforcers: this.#enforcers, + }); + + const existingDelegations = await this.#messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + const reusable = existingDelegations.find(matches); + if (reusable) { + await this.#ensureIntent({ + account: request.payerAddress, + chainId: request.chainId, + delegationHash: reusable.metadata.delegationHash, + allowance: reusable.metadata.allowance, + tokenSymbol: reusable.metadata.tokenSymbol, + tokenAddress: reusable.metadata.tokenAddress, + }); + return { + delegationHash: reusable.metadata.delegationHash, + disposition: 'reused', + }; + } + + const startDate = Math.floor(Date.now() / 1000); + const unsigned = buildUnsignedSubscriptionDelegation({ + delegateAddress: this.#config.delegateAddress, + delegatorAddress: request.payerAddress, + enforcers: this.#enforcers, + tokenAddress: request.tokenAddress, + periodAmount, + periodDuration, + startDate, + }); + + const signature = (await this.#messenger.call( + 'DelegationController:signDelegation', + { delegation: unsigned, chainId: request.chainId }, + )) as Hex; + + const signedDelegation = { ...unsigned, signature }; + + const verifyResult = await this.#messenger.call( + 'ChompApiService:verifyDelegation', + { + signedDelegation, + chainId: request.chainId, + }, + ); + + if (!verifyResult.valid) { + throw new Error( + `${SubscriptionDelegationServiceErrorMessage.ChompRejectedDelegation}: ${ + verifyResult.errors?.join(', ') ?? 'unknown error' + }`, + ); + } + + const delegationHash = hashDelegation({ + ...unsigned, + salt: BigInt(unsigned.salt), + signature, + }); + + if (!verifyResult.delegationHash) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.ChompMissingDelegationHash, + ); + } + if (!equalsIgnoreCase(verifyResult.delegationHash, delegationHash)) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.ChompDelegationHashMismatch, + ); + } + + const allowance: Hex = add0x(periodAmount.toString(16)); + + await this.#messenger.call( + 'AuthenticatedUserStorageService:createDelegation', + { + signedDelegation, + metadata: { + delegationHash, + chainIdHex: request.chainId, + allowance, + tokenSymbol: request.tokenSymbol, + tokenAddress: request.tokenAddress, + type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + }, + }, + ); + + await this.#createIntent({ + account: request.payerAddress, + chainId: request.chainId, + delegationHash, + allowance, + tokenSymbol: request.tokenSymbol, + tokenAddress: request.tokenAddress, + }); + + return { + delegationHash, + disposition: 'created', + }; + } + + /** + * Ensures an active CHOMP intent exists for the given delegation hash, + * registering one when missing or revoked. + * + * @param params - Intent identity and metadata. + * @param params.account - Delegator / payer address. + * @param params.chainId - Chain ID of the delegation. + * @param params.delegationHash - Hash of the stored delegation. + * @param params.allowance - Period allowance stored with the delegation. + * @param params.tokenSymbol - Payment token symbol. + * @param params.tokenAddress - Payment token address. + */ + async #ensureIntent(params: SubscriptionIntentParams): Promise { + const existingIntents = await this.#messenger.call( + 'ChompApiService:getIntentsByAddress', + params.account, + ); + + const hasActiveIntent = existingIntents.some( + (intent) => + equalsIgnoreCase(intent.delegationHash, params.delegationHash) && + intent.status === 'active', + ); + + if (hasActiveIntent) { + return; + } + + await this.#createIntent(params); + } + + async #createIntent(params: SubscriptionIntentParams): Promise { + await this.#messenger.call('ChompApiService:createIntents', [ + { + account: params.account, + delegationHash: params.delegationHash, + chainId: params.chainId, + metadata: { + allowance: params.allowance, + tokenSymbol: params.tokenSymbol, + tokenAddress: params.tokenAddress, + type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + }, + }, + ]); + } +} diff --git a/packages/subscription-controller/src/subscription-delegation/amount.test.ts b/packages/subscription-controller/src/subscription-delegation/amount.test.ts new file mode 100644 index 00000000000..55b63322ea5 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/amount.test.ts @@ -0,0 +1,94 @@ +import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; +import { RECURRING_INTERVALS } from '../types.js'; + +import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; + +describe('calculatePeriodAmount', () => { + it('returns the same amount when decimals match', () => { + expect( + calculatePeriodAmount({ + unitAmount: 1_000_000, + unitDecimals: 6, + tokenDecimals: 6, + }), + ).toBe(1_000_000n); + }); + + it('scales up from 2 to 18 decimals', () => { + expect( + calculatePeriodAmount({ + unitAmount: 10_00, + unitDecimals: 2, + tokenDecimals: 18, + }), + ).toBe(10n * 10n ** 18n); + }); + + it('scales up from 2 to 6 decimals', () => { + expect( + calculatePeriodAmount({ + unitAmount: 10_00, + unitDecimals: 2, + tokenDecimals: 6, + }), + ).toBe(10n * 10n ** 6n); + }); + + it('scales down when the amount divides evenly', () => { + expect( + calculatePeriodAmount({ + unitAmount: 1_000_000, + unitDecimals: 6, + tokenDecimals: 2, + }), + ).toBe(100n); + }); + + it('throws on lossy downscaling', () => { + expect(() => + calculatePeriodAmount({ + unitAmount: 1_000_001, + unitDecimals: 6, + tokenDecimals: 2, + }), + ).toThrow(SubscriptionDelegationServiceErrorMessage.LossyAmountScale); + }); + + it.each([ + { unitAmount: -1, unitDecimals: 2, tokenDecimals: 2 }, + { unitAmount: 1.5, unitDecimals: 2, tokenDecimals: 2 }, + ])('throws on invalid unitAmount %#', (params) => { + expect(() => calculatePeriodAmount(params)).toThrow( + SubscriptionDelegationServiceErrorMessage.InvalidAmount, + ); + }); + + it.each([ + { unitAmount: 1, unitDecimals: -1, tokenDecimals: 2 }, + { unitAmount: 1, unitDecimals: 1.5, tokenDecimals: 2 }, + { unitAmount: 1, unitDecimals: 2, tokenDecimals: -1 }, + { unitAmount: 1, unitDecimals: 2, tokenDecimals: 1.5 }, + ])('throws on invalid decimals %#', (params) => { + expect(() => calculatePeriodAmount(params)).toThrow( + SubscriptionDelegationServiceErrorMessage.InvalidDecimals, + ); + }); +}); + +describe('getPeriodDuration', () => { + it('returns 28 days in seconds for month', () => { + expect(getPeriodDuration(RECURRING_INTERVALS.month)).toBe(28 * 86_400); + }); + + it('returns 365 days in seconds for year', () => { + expect(getPeriodDuration(RECURRING_INTERVALS.year)).toBe(365 * 86_400); + }); + + it('throws for an unsupported interval', () => { + expect(() => + getPeriodDuration('week' as (typeof RECURRING_INTERVALS)['month']), + ).toThrow( + SubscriptionDelegationServiceErrorMessage.UnsupportedRecurringInterval, + ); + }); +}); diff --git a/packages/subscription-controller/src/subscription-delegation/amount.ts b/packages/subscription-controller/src/subscription-delegation/amount.ts new file mode 100644 index 00000000000..0e2a0096fb5 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/amount.ts @@ -0,0 +1,90 @@ +import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; +import { RECURRING_INTERVALS } from '../types.js'; +import type { RecurringInterval } from '../types.js'; + +const SECONDS_PER_DAY = 86_400; + +/** + * Rescales a plan fee from pricing `unitDecimals` into token base units. + * + * Uses a 1:1 numeric mapping (ADR 0057): the period amount is one billing + * interval's fee, not `fee × minimumFundingCycles`. + * + * @param params - Amount and decimal inputs. + * @param params.unitAmount - Fee in pricing minor units (non-negative integer). + * @param params.unitDecimals - Decimals of `unitAmount`. + * @param params.tokenDecimals - Decimals of the payment token. + * @returns The period amount in token base units. + * @throws If inputs are not non-negative integers, or downscaling would lose precision. + */ +export function calculatePeriodAmount({ + unitAmount, + unitDecimals, + tokenDecimals, +}: { + unitAmount: number; + unitDecimals: number; + tokenDecimals: number; +}): bigint { + assertNonNegativeInteger( + unitAmount, + SubscriptionDelegationServiceErrorMessage.InvalidAmount, + ); + assertNonNegativeInteger( + unitDecimals, + SubscriptionDelegationServiceErrorMessage.InvalidDecimals, + ); + assertNonNegativeInteger( + tokenDecimals, + SubscriptionDelegationServiceErrorMessage.InvalidDecimals, + ); + + const amount = BigInt(unitAmount); + + if (tokenDecimals === unitDecimals) { + return amount; + } + + if (tokenDecimals > unitDecimals) { + return amount * 10n ** BigInt(tokenDecimals - unitDecimals); + } + + const divisor = 10n ** BigInt(unitDecimals - tokenDecimals); + if (amount % divisor !== 0n) { + throw new Error(SubscriptionDelegationServiceErrorMessage.LossyAmountScale); + } + return amount / divisor; +} + +/** + * Plan-scoped ERC-20 period duration in seconds (ADR 0057). + * + * - Monthly: 28 days (minimum Stripe monthly invoice gap). + * - Yearly: 365 days. + * + * @param recurringInterval - Subscription billing interval. + * @returns Period duration in seconds. + */ +export function getPeriodDuration( + recurringInterval: RecurringInterval, +): number { + if (recurringInterval === RECURRING_INTERVALS.month) { + return 28 * SECONDS_PER_DAY; + } + if (recurringInterval === RECURRING_INTERVALS.year) { + return 365 * SECONDS_PER_DAY; + } + throw new Error( + SubscriptionDelegationServiceErrorMessage.UnsupportedRecurringInterval, + ); +} + +/** + * @param value - Candidate number. + * @param message - Error message when invalid. + */ +function assertNonNegativeInteger(value: number, message: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(message); + } +} diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts new file mode 100644 index 00000000000..6e095793429 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts @@ -0,0 +1,96 @@ +import { + decodeERC20TokenPeriodTransferTerms, + decodeValueLteTerms, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +import { + buildSubscriptionPaymentCaveats, + buildUnsignedSubscriptionDelegation, +} from './caveats.js'; + +const VALUE_LTE_ENFORCER = '0x1111111111111111111111111111111111111111' as Hex; +const PERIOD_ENFORCER = '0x2222222222222222222222222222222222222222' as Hex; +const TOKEN_ADDRESS = '0x3333333333333333333333333333333333333333' as Hex; +const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; +const DELEGATOR = '0x5555555555555555555555555555555555555555' as Hex; + +describe('buildSubscriptionPaymentCaveats', () => { + it('builds ValueLte(0) then ERC20TokenPeriodTransfer caveats', () => { + const caveats = buildSubscriptionPaymentCaveats({ + enforcers: { + valueLte: VALUE_LTE_ENFORCER, + erc20TokenPeriodTransfer: PERIOD_ENFORCER, + }, + tokenAddress: TOKEN_ADDRESS, + periodAmount: 10n * 10n ** 18n, + periodDuration: 28 * 86_400, + startDate: 1_700_000_000, + }); + + expect(caveats).toHaveLength(2); + const [valueLteCaveat, periodCaveat] = caveats; + expect(valueLteCaveat).toBeDefined(); + expect(periodCaveat).toBeDefined(); + expect(valueLteCaveat?.enforcer).toBe(VALUE_LTE_ENFORCER); + expect(periodCaveat?.enforcer).toBe(PERIOD_ENFORCER); + expect(decodeValueLteTerms(valueLteCaveat?.terms ?? '0x')).toStrictEqual({ + maxValue: 0n, + }); + expect( + decodeERC20TokenPeriodTransferTerms(periodCaveat?.terms ?? '0x'), + ).toStrictEqual({ + tokenAddress: TOKEN_ADDRESS, + periodAmount: 10n * 10n ** 18n, + periodDuration: 28 * 86_400, + startDate: 1_700_000_000, + }); + }); +}); + +describe('buildUnsignedSubscriptionDelegation', () => { + it('builds a root unsigned delegation with a 32-byte salt', () => { + const salt = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const unsigned = buildUnsignedSubscriptionDelegation({ + delegateAddress: DELEGATE, + delegatorAddress: DELEGATOR, + enforcers: { + valueLte: VALUE_LTE_ENFORCER, + erc20TokenPeriodTransfer: PERIOD_ENFORCER, + }, + tokenAddress: TOKEN_ADDRESS, + periodAmount: 100n, + periodDuration: 365 * 86_400, + startDate: 1_700_000_000, + salt, + }); + + expect(unsigned).toStrictEqual({ + delegate: DELEGATE, + delegator: DELEGATOR, + authority: ROOT_AUTHORITY, + caveats: expect.any(Array), + salt, + }); + expect(unsigned.salt).toMatch(/^0x[0-9a-fA-F]{64}$/u); + }); + + it('generates a random 32-byte salt when omitted', () => { + const unsigned = buildUnsignedSubscriptionDelegation({ + delegateAddress: DELEGATE, + delegatorAddress: DELEGATOR, + enforcers: { + valueLte: VALUE_LTE_ENFORCER, + erc20TokenPeriodTransfer: PERIOD_ENFORCER, + }, + tokenAddress: TOKEN_ADDRESS, + periodAmount: 100n, + periodDuration: 28 * 86_400, + startDate: 1_700_000_000, + }); + + expect(unsigned.salt).toMatch(/^0x[0-9a-fA-F]{64}$/u); + }); +}); diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts new file mode 100644 index 00000000000..f742ee1b2d9 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -0,0 +1,87 @@ +import { + ROOT_AUTHORITY, + createERC20TokenPeriodTransferTerms, + createValueLteTerms, +} from '@metamask/delegation-core'; +import type { SignedDelegation } from '@metamask/authenticated-user-storage'; +import { bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { SubscriptionDelegationEnforcers } from './types.js'; + +export type UnsignedSubscriptionDelegation = Omit< + SignedDelegation, + 'signature' +>; + +export type BuildSubscriptionPaymentCaveatsParams = { + enforcers: SubscriptionDelegationEnforcers; + tokenAddress: Hex; + periodAmount: bigint; + periodDuration: number; + startDate: number; +}; + +/** + * Builds the caveat list for a subscription-payment delegation: + * `ValueLte(0)` then `ERC20TokenPeriodTransfer(...)`. + * + * @param params - Enforcer addresses and period terms. + * @returns Caveats in enforcer order. + */ +export function buildSubscriptionPaymentCaveats( + params: BuildSubscriptionPaymentCaveatsParams, +): SignedDelegation['caveats'] { + const { enforcers, tokenAddress, periodAmount, periodDuration, startDate } = + params; + + return [ + { + enforcer: enforcers.valueLte, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x', + }, + { + enforcer: enforcers.erc20TokenPeriodTransfer, + terms: createERC20TokenPeriodTransferTerms({ + tokenAddress, + periodAmount, + periodDuration, + startDate, + }), + args: '0x', + }, + ]; +} + +export type BuildUnsignedSubscriptionDelegationParams = + BuildSubscriptionPaymentCaveatsParams & { + delegateAddress: Hex; + delegatorAddress: Hex; + /** + * Optional salt for tests. When omitted, a random 32-byte salt is generated. + */ + salt?: Hex; + }; + +/** + * Builds an unsigned root subscription-payment delegation. + * + * @param params - Delegation parties, enforcers, and period terms. + * @returns An unsigned delegation ready for signing. + */ +export function buildUnsignedSubscriptionDelegation( + params: BuildUnsignedSubscriptionDelegationParams, +): UnsignedSubscriptionDelegation { + const salt = + params.salt ?? + bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(32))); + + return { + delegate: params.delegateAddress, + delegator: params.delegatorAddress, + authority: ROOT_AUTHORITY, + caveats: buildSubscriptionPaymentCaveats(params), + salt, + }; +} diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts new file mode 100644 index 00000000000..561a9cda210 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -0,0 +1,180 @@ +import { + createERC20TokenPeriodTransferTerms, + createValueLteTerms, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import type { Hex } from '@metamask/utils'; + +import { + equalsIgnoreCase, + makeMatchesSubscriptionDelegation, +} from './fingerprint.js'; +import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; + +const VALUE_LTE = '0x1111111111111111111111111111111111111111' as Hex; +const PERIOD = '0x2222222222222222222222222222222222222222' as Hex; +const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; +const DELEGATOR = '0x5555555555555555555555555555555555555555' as Hex; +const CHAIN_ID = '0x1' as Hex; +const PERIOD_AMOUNT = 10n * 10n ** 18n; +const PERIOD_DURATION = 28 * 86_400; + +function buildEntry({ + type = SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + delegator = DELEGATOR, + delegate = DELEGATE, + chainIdHex = CHAIN_ID, + tokenAddress = TOKEN, + periodAmount = PERIOD_AMOUNT, + periodDuration = PERIOD_DURATION, + startDate = 1_700_000_000, + valueLteEnforcer = VALUE_LTE, + periodEnforcer = PERIOD, + maxValue = 0n, +}: { + type?: string; + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + periodAmount?: bigint; + periodDuration?: number; + startDate?: number; + valueLteEnforcer?: Hex; + periodEnforcer?: Hex; + maxValue?: bigint; +} = {}): DelegationResponse { + return { + signedDelegation: { + delegate, + delegator, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue }), + args: '0x', + }, + { + enforcer: periodEnforcer, + terms: createERC20TokenPeriodTransferTerms({ + tokenAddress, + periodAmount, + periodDuration, + startDate, + }), + args: '0x', + }, + ], + salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + signature: `0x${'bb'.repeat(65)}`, + }, + metadata: { + delegationHash: `0x${'cc'.repeat(32)}`, + chainIdHex, + allowance: `0x${periodAmount.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress, + type, + }, + }; +} + +const expected = { + delegatorAddress: DELEGATOR, + delegateAddress: DELEGATE, + chainId: CHAIN_ID, + tokenAddress: TOKEN, + periodAmount: PERIOD_AMOUNT, + periodDuration: PERIOD_DURATION, + enforcers: { + valueLte: VALUE_LTE, + erc20TokenPeriodTransfer: PERIOD, + }, +}; + +describe('equalsIgnoreCase', () => { + it('compares hex case-insensitively', () => { + expect(equalsIgnoreCase('0xAbC', '0xabc')).toBe(true); + expect(equalsIgnoreCase('0xAbC', '0xabd')).toBe(false); + }); +}); + +describe('makeMatchesSubscriptionDelegation', () => { + const matches = makeMatchesSubscriptionDelegation(expected); + + it('matches an equivalent stored delegation', () => { + expect(matches(buildEntry())).toBe(true); + }); + + it('matches when startDate differs', () => { + expect(matches(buildEntry({ startDate: 1_800_000_000 }))).toBe(true); + }); + + it('matches case-insensitively on addresses and chain id', () => { + const upper = (value: Hex): Hex => { + return `0x${value.slice(2).toUpperCase()}`; + }; + + expect( + matches( + buildEntry({ + delegator: upper(DELEGATOR), + delegate: upper(DELEGATE), + tokenAddress: upper(TOKEN), + chainIdHex: upper(CHAIN_ID), + }), + ), + ).toBe(true); + }); + + it.each([ + ['type', { type: 'cash-deposit' }], + [ + 'delegator', + { delegator: '0x6666666666666666666666666666666666666666' as Hex }, + ], + [ + 'delegate', + { delegate: '0x6666666666666666666666666666666666666666' as Hex }, + ], + ['chainId', { chainIdHex: '0x89' as Hex }], + [ + 'token', + { tokenAddress: '0x6666666666666666666666666666666666666666' as Hex }, + ], + [ + 'valueLteEnforcer', + { + valueLteEnforcer: '0x6666666666666666666666666666666666666666' as Hex, + }, + ], + [ + 'periodEnforcer', + { + periodEnforcer: '0x6666666666666666666666666666666666666666' as Hex, + }, + ], + ['periodAmount', { periodAmount: PERIOD_AMOUNT + 1n }], + ['periodDuration', { periodDuration: PERIOD_DURATION + 1 }], + ['maxValue', { maxValue: 1n }], + ] as const)('rejects mismatch on %s', (_label, overrides) => { + expect(matches(buildEntry(overrides))).toBe(false); + }); + + it('rejects a delegation with missing caveats', () => { + const entry = buildEntry(); + entry.signedDelegation.caveats = []; + + expect(matches(entry)).toBe(false); + }); + + it('rejects a delegation with malformed caveat terms', () => { + const entry = buildEntry(); + entry.signedDelegation.caveats[0].terms = '0x'; + + expect(matches(entry)).toBe(false); + }); +}); diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts new file mode 100644 index 00000000000..b14d821cb63 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -0,0 +1,106 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + decodeERC20TokenPeriodTransferTerms, + decodeValueLteTerms, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +import type { SubscriptionDelegationEnforcers } from './types.js'; +import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; + +export type SubscriptionDelegationFingerprint = { + delegatorAddress: Hex; + delegateAddress: Hex; + chainId: Hex; + tokenAddress: Hex; + periodAmount: bigint; + periodDuration: number; + enforcers: SubscriptionDelegationEnforcers; +}; + +/** + * Case-insensitive hex equality. + * + * @param left - First hex value. + * @param right - Second hex value. + * @returns Whether the values are equal ignoring case. + */ +export function equalsIgnoreCase(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +/** + * Builds a predicate that matches a stored AUS delegation to the semantic + * subscription-payment fingerprint. Salt and period `startDate` are ignored so + * a previously signed equivalent permission can be reused. + * + * @param expected - Semantic fields that must match. + * @returns Predicate over {@link DelegationResponse}. + */ +export function makeMatchesSubscriptionDelegation( + expected: SubscriptionDelegationFingerprint, +): (entry: DelegationResponse) => boolean { + return (entry) => { + if (entry.metadata.type !== SUBSCRIPTION_PAYMENT_DELEGATION_TYPE) { + return false; + } + if ( + !equalsIgnoreCase( + entry.signedDelegation.delegator, + expected.delegatorAddress, + ) + ) { + return false; + } + if ( + !equalsIgnoreCase( + entry.signedDelegation.delegate, + expected.delegateAddress, + ) + ) { + return false; + } + if (!equalsIgnoreCase(entry.metadata.chainIdHex, expected.chainId)) { + return false; + } + if (!equalsIgnoreCase(entry.metadata.tokenAddress, expected.tokenAddress)) { + return false; + } + + const { caveats } = entry.signedDelegation; + if (caveats.length < 2) { + return false; + } + + const valueLteCaveat = caveats.find((caveat) => + equalsIgnoreCase(caveat.enforcer, expected.enforcers.valueLte), + ); + const periodCaveat = caveats.find((caveat) => + equalsIgnoreCase( + caveat.enforcer, + expected.enforcers.erc20TokenPeriodTransfer, + ), + ); + if (!valueLteCaveat || !periodCaveat) { + return false; + } + + try { + const valueTerms = decodeValueLteTerms(valueLteCaveat.terms); + if (valueTerms.maxValue !== 0n) { + return false; + } + + const periodTerms = decodeERC20TokenPeriodTransferTerms( + periodCaveat.terms, + ); + return ( + equalsIgnoreCase(periodTerms.tokenAddress, expected.tokenAddress) && + periodTerms.periodAmount === expected.periodAmount && + Number(periodTerms.periodDuration) === expected.periodDuration + ); + } catch { + return false; + } + }; +} diff --git a/packages/subscription-controller/src/subscription-delegation/types.ts b/packages/subscription-controller/src/subscription-delegation/types.ts new file mode 100644 index 00000000000..6bcbc3ad21e --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -0,0 +1,61 @@ +import type { Hex } from '@metamask/utils'; + +import { PRODUCT_TYPES } from '../types.js'; +import type { RecurringInterval } from '../types.js'; + +/** + * Storage / CHOMP metadata type for subscription-payment delegations. + */ +export const SUBSCRIPTION_PAYMENT_DELEGATION_TYPE = 'subscription-payment'; + +/** + * Request to prepare a subscription-payment delegation. + * + * Pricing fields (`unitAmount`, `unitDecimals`, token details, + * `minimumFundingCycles`) must come from authoritative subscription pricing — + * never from editable UI input. + * + * Only Money Account Plus is supported; Shield continues to use ERC-20 + * approval rather than delegation. + */ +export type PrepareSubscriptionDelegationRequest = { + product: typeof PRODUCT_TYPES.MONEY_ACCOUNT_PLUS; + recurringInterval: RecurringInterval; + chainId: Hex; + payerAddress: Hex; + tokenAddress: Hex; + tokenSymbol: string; + tokenDecimals: number; + unitAmount: number; + unitDecimals: number; + minimumFundingCycles: number; +}; + +/** + * Result of {@link SubscriptionDelegationService.prepareDelegation}. + */ +export type PreparedSubscriptionDelegation = { + delegationHash: Hex; + disposition: 'created' | 'reused'; +}; + +/** + * Delegation Framework enforcers used by subscription-payment delegations. + */ +export type SubscriptionDelegationEnforcers = { + valueLte: Hex; + erc20TokenPeriodTransfer: Hex; +}; + +/** + * Immutable, chain-scoped CHOMP subscription-payment configuration supplied + * at service construction (mirrors Money Account upgrade config). + * + * Wallet supplies the CHOMP delegate. The service resolves Delegation + * Framework enforcers for {@link chainId} from + * `@metamask/delegation-deployments`. + */ +export type SubscriptionDelegationConfig = { + chainId: Hex; + delegateAddress: Hex; +}; diff --git a/packages/subscription-controller/tsconfig.build.json b/packages/subscription-controller/tsconfig.build.json index 2e58daaf3e6..626b2efdba6 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -6,27 +6,39 @@ "rootDir": "./src" }, "references": [ + { + "path": "../authenticated-user-storage/tsconfig.build.json" + }, { "path": "../base-controller/tsconfig.build.json" }, { - "path": "../messenger/tsconfig.build.json" + "path": "../base-data-service/tsconfig.build.json" }, { - "path": "../profile-sync-controller/tsconfig.build.json" + "path": "../chomp-api-service/tsconfig.build.json" }, { - "path": "../polling-controller/tsconfig.build.json" + "path": "../controller-utils/tsconfig.build.json" }, { - "path": "../transaction-controller/tsconfig.build.json" + "path": "../delegation-controller/tsconfig.build.json" }, { - "path": "../controller-utils/tsconfig.build.json" + "path": "../messenger/tsconfig.build.json" }, { - "path": "../base-data-service/tsconfig.build.json" + "path": "../polling-controller/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" } ], - "include": ["../../types", "./src"] + "include": [ + "../../types", + "./src" + ] } diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 1a2428b6b78..6d75497dcca 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -4,27 +4,40 @@ "baseUrl": "./" }, "references": [ + { + "path": "../authenticated-user-storage" + }, { "path": "../base-controller" }, { - "path": "../messenger" + "path": "../base-data-service" }, { - "path": "../profile-sync-controller" + "path": "../chomp-api-service" }, { - "path": "../polling-controller" + "path": "../controller-utils" }, { - "path": "../transaction-controller" + "path": "../delegation-controller" }, { - "path": "../controller-utils" + "path": "../messenger" }, { - "path": "../base-data-service" + "path": "../polling-controller" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../transaction-controller" } ], - "include": ["../../types", "./src", "./tests"] + "include": [ + "../../types", + "./src", + "./tests" + ] } diff --git a/yarn.lock b/yarn.lock index debb45a5a3f..b928111c03f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9118,10 +9118,15 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: + "@metamask/authenticated-user-storage": "npm:^3.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/base-data-service": "npm:^1.0.0" + "@metamask/chomp-api-service": "npm:^4.0.2" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/delegation-controller": "npm:^3.0.2" + "@metamask/delegation-core": "npm:^2.2.1" + "@metamask/delegation-deployments": "npm:^1.4.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/profile-sync-controller": "npm:^29.0.0" From e7e9b2462863eb61e04bf70877d107179b78db27 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 20:19:53 +0700 Subject: [PATCH 02/23] feat: update dependencies and enhance subscription delegation service --- packages/subscription-controller/CHANGELOG.md | 2 +- packages/subscription-controller/package.json | 2 + .../subscription-controller/src/constants.ts | 3 +- packages/subscription-controller/src/index.ts | 1 - .../SubscriptionDelegationService.test.ts | 136 ++++++++++++------ .../SubscriptionDelegationService.ts | 89 ++++++++---- .../src/subscription-delegation/types.ts | 14 -- .../tsconfig.build.json | 6 + .../subscription-controller/tsconfig.json | 6 + yarn.lock | 2 + 10 files changed, 173 insertions(+), 88 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index d4ad921c615..4191788be8e 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `SubscriptionDelegationService` for Money Account Plus subscription-payment delegation setup. - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - - Construct with immutable, chain-scoped `SubscriptionDelegationConfig` (CHOMP delegate address); Delegation Framework enforcers are resolved from `@metamask/delegation-deployments`. + - On each call, resolves the chain from `moneyAccountVaultConfig`, the temporary delegate from CHOMP's `autoDepositDelegate`, and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. - Add `getBenefits` to fetch and persist Money Account Plus subscription benefits. ([#10103](https://github.com/MetaMask/core/pull/10103)) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 4ddb4813eeb..871bfc247f4 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -64,8 +64,10 @@ "@metamask/delegation-core": "^2.2.1", "@metamask/delegation-deployments": "^1.4.0", "@metamask/messenger": "^2.0.0", + "@metamask/money-account-utils": "^1.2.0", "@metamask/polling-controller": "^16.0.9", "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/remote-feature-flag-controller": "^6.1.0", "@metamask/superstruct": "^3.4.1", "@metamask/transaction-controller": "^69.8.0", "@metamask/utils": "^11.12.0", diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index 36613598b34..a92204f9220 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -75,8 +75,9 @@ export enum SubscriptionDelegationServiceErrorMessage { LossyAmountScale = 'Subscription delegation amount cannot be scaled to token decimals without remainder', UnsupportedRecurringInterval = 'Unsupported subscription recurring interval', UnsupportedProduct = 'Subscription delegation is only supported for Money Account Plus', - ChainIdMismatch = 'Subscription delegation request chainId does not match the configured chainId', + MissingMoneyAccountVaultConfig = 'Money Account vault configuration is missing or invalid', DelegationContractsNotFound = 'Subscription delegation contracts were not found for the configured chain', + ChompChainNotFound = 'Subscription delegation chain was not found in CHOMP service details', ChompRejectedDelegation = 'CHOMP rejected the subscription delegation', ChompMissingDelegationHash = 'CHOMP verify response did not include a delegation hash', ChompDelegationHashMismatch = 'CHOMP verify response delegation hash does not match the locally computed hash', diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 2a3cbca142b..8c5a25331c5 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -164,6 +164,5 @@ export type { SubscriptionDelegationServicePrepareDelegationAction } from './sub export type { PrepareSubscriptionDelegationRequest, PreparedSubscriptionDelegation, - SubscriptionDelegationConfig, } from './subscription-delegation/types.js'; export { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './subscription-delegation/types.js'; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index f0888579bc2..2ffaad8858c 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -1,3 +1,4 @@ +import type { ServiceDetailsResponse } from '@metamask/chomp-api-service'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; import { @@ -18,10 +19,7 @@ import { serviceName, } from './SubscriptionDelegationService.js'; import type { SubscriptionDelegationServiceMessenger } from './SubscriptionDelegationService.js'; -import type { - PrepareSubscriptionDelegationRequest, - SubscriptionDelegationConfig, -} from './types.js'; +import type { PrepareSubscriptionDelegationRequest } from './types.js'; import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; @@ -34,15 +32,31 @@ const { ERC20PeriodTransferEnforcer: PERIOD, } = DELEGATOR_CONTRACTS['1.3.0'][1]; -const CONFIG: SubscriptionDelegationConfig = { +const MONEY_ACCOUNT_VAULT_CONFIG = { chainId: CHAIN_ID, - delegateAddress: DELEGATE, + boringVault: '0x1111111111111111111111111111111111111111', + tellerAddress: '0x2222222222222222222222222222222222222222', + accountantAddress: '0x6666666666666666666666666666666666666666', + lensAddress: '0x7777777777777777777777777777777777777777', +}; + +const REMOTE_FEATURE_FLAGS: Record = { + moneyAccountVaultConfig: MONEY_ACCOUNT_VAULT_CONFIG, +}; + +const SERVICE_DETAILS: ServiceDetailsResponse = { + auth: { message: 'CHOMP Authentication' }, + chains: { + [CHAIN_ID]: { + autoDepositDelegate: DELEGATE, + protocol: {}, + }, + }, }; const REQUEST: PrepareSubscriptionDelegationRequest = { product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, recurringInterval: RECURRING_INTERVALS.month, - chainId: CHAIN_ID, payerAddress: PAYER, tokenAddress: TOKEN, tokenSymbol: 'pvmUSD', @@ -66,6 +80,8 @@ type Mocks = { verifyDelegation: jest.Mock; getIntentsByAddress: jest.Mock; createIntents: jest.Mock; + getRemoteFeatureFlagState: jest.Mock; + getServiceDetails: jest.Mock; }; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -74,7 +90,8 @@ function setup( listDelegations?: unknown[]; intents?: unknown[]; verify?: { valid: boolean; delegationHash?: Hex; errors?: string[] }; - config?: SubscriptionDelegationConfig; + remoteFeatureFlags?: Record; + serviceDetails?: ServiceDetailsResponse; } = {}, ) { const mocks: Mocks = { @@ -95,6 +112,13 @@ function setup( }), getIntentsByAddress: jest.fn().mockResolvedValue(options.intents ?? []), createIntents: jest.fn().mockResolvedValue([]), + getRemoteFeatureFlagState: jest.fn().mockReturnValue({ + remoteFeatureFlags: options.remoteFeatureFlags ?? REMOTE_FEATURE_FLAGS, + cacheTimestamp: 0, + }), + getServiceDetails: jest + .fn() + .mockResolvedValue(options.serviceDetails ?? SERVICE_DETAILS), }; type AllowedActions = @@ -121,6 +145,14 @@ function setup( | { type: 'ChompApiService:createIntents'; handler: Mocks['createIntents']; + } + | { + type: 'RemoteFeatureFlagController:getState'; + handler: Mocks['getRemoteFeatureFlagState']; + } + | { + type: 'ChompApiService:getServiceDetails'; + handler: Mocks['getServiceDetails']; }; const rootMessenger = new Messenger< @@ -157,6 +189,14 @@ function setup( 'ChompApiService:createIntents', mocks.createIntents, ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mocks.getRemoteFeatureFlagState, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getServiceDetails', + mocks.getServiceDetails, + ); const messenger: SubscriptionDelegationServiceMessenger = new Messenger({ namespace: serviceName, @@ -172,13 +212,14 @@ function setup( 'ChompApiService:verifyDelegation', 'ChompApiService:getIntentsByAddress', 'ChompApiService:createIntents', + 'RemoteFeatureFlagController:getState', + 'ChompApiService:getServiceDetails', ], events: [], }); const service = new SubscriptionDelegationService({ messenger, - config: options.config ?? CONFIG, }); return { service, rootMessenger, mocks }; @@ -240,29 +281,16 @@ function expectNoSideEffects(mocks: Mocks): void { } describe('SubscriptionDelegationService', () => { - describe('constructor', () => { - it('throws when Delegation Framework contracts are unavailable for the configured chain', () => { - expect(() => - setup({ - config: { - chainId: '0xffffffff', - delegateAddress: DELEGATE, - }, - }), - ).toThrow( - `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: 0xffffffff`, - ); - }); - }); - describe('prepareDelegation', () => { - it('creates, verifies, persists, and registers a new delegation using config', async () => { + it('creates, verifies, persists, and registers using feature flag and CHOMP config', async () => { const { service, mocks } = setup(); const result = await service.prepareDelegation(REQUEST); expect(result.disposition).toBe('created'); expect(result.delegationHash).toMatch(/^0x[0-9a-fA-F]{64}$/u); + expect(mocks.getRemoteFeatureFlagState).toHaveBeenCalledTimes(1); + expect(mocks.getServiceDetails).toHaveBeenCalledWith([CHAIN_ID]); expect(mocks.signDelegation).toHaveBeenCalledTimes(1); expect(mocks.signDelegation).toHaveBeenCalledWith({ delegation: expect.objectContaining({ @@ -420,33 +448,57 @@ describe('SubscriptionDelegationService', () => { await expect(service.prepareDelegation(shieldRequest)).rejects.toThrow( SubscriptionDelegationServiceErrorMessage.UnsupportedProduct, ); + expect(mocks.getRemoteFeatureFlagState).not.toHaveBeenCalled(); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); expectNoSideEffects(mocks); }); - it('rejects a chainId that does not match config before any side effects', async () => { - const { service, mocks } = setup(); + it.each([ + ['missing', {}], + ['malformed', { moneyAccountVaultConfig: { chainId: 'invalid' } }], + ])( + 'rejects %s Money Account vault config before CHOMP or delegation calls', + async (_condition, remoteFeatureFlags) => { + const { service, mocks } = setup({ remoteFeatureFlags }); + + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.MissingMoneyAccountVaultConfig, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + expectNoSideEffects(mocks); + }, + ); - await expect( - service.prepareDelegation({ - ...REQUEST, - chainId: '0x89', - }), - ).rejects.toThrow( - SubscriptionDelegationServiceErrorMessage.ChainIdMismatch, + it('rejects when Delegation Framework contracts are unavailable for the feature-flag chain', async () => { + const { service, mocks } = setup({ + remoteFeatureFlags: { + moneyAccountVaultConfig: { + ...MONEY_ACCOUNT_VAULT_CONFIG, + chainId: '0xffffffff', + }, + }, + }); + + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: 0xffffffff`, ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); expectNoSideEffects(mocks); }); - it('accepts a matching chainId case-insensitively', async () => { - const { service, mocks } = setup(); - - const result = await service.prepareDelegation({ - ...REQUEST, - chainId: '0X1' as Hex, + it('rejects when CHOMP service details omit the feature-flag chain', async () => { + const { service, mocks } = setup({ + serviceDetails: { + auth: { message: 'CHOMP Authentication' }, + chains: {}, + }, }); - expect(result.disposition).toBe('created'); - expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( + `${SubscriptionDelegationServiceErrorMessage.ChompChainNotFound}: ${CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).toHaveBeenCalledWith([CHAIN_ID]); + expectNoSideEffects(mocks); }); }); }); diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index fa3eca4a483..31271ed5bd2 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -5,12 +5,15 @@ import type { import type { ChompApiServiceCreateIntentsAction, ChompApiServiceGetIntentsByAddressAction, + ChompApiServiceGetServiceDetailsAction, ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; import { hashDelegation } from '@metamask/delegation-core'; import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { Messenger } from '@metamask/messenger'; +import { getMoneyAccountVaultConfig } from '@metamask/money-account-utils'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import { add0x, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -26,7 +29,6 @@ import { import type { PrepareSubscriptionDelegationRequest, PreparedSubscriptionDelegation, - SubscriptionDelegationConfig, SubscriptionDelegationEnforcers, } from './types.js'; import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; @@ -75,7 +77,9 @@ type AllowedActions = | ChompApiServiceVerifyDelegationAction | ChompApiServiceCreateIntentsAction | ChompApiServiceGetIntentsByAddressAction - | DelegationControllerSignDelegationAction; + | ChompApiServiceGetServiceDetailsAction + | DelegationControllerSignDelegationAction + | RemoteFeatureFlagControllerGetStateAction; /** * Events that {@link SubscriptionDelegationService} exposes to other consumers. @@ -99,11 +103,6 @@ export type SubscriptionDelegationServiceMessenger = Messenger< */ export type SubscriptionDelegationServiceOptions = { messenger: SubscriptionDelegationServiceMessenger; - /** - * Immutable, chain-scoped CHOMP delegate configuration for Money Account - * Plus subscription-payment delegations. - */ - config: SubscriptionDelegationConfig; }; type SubscriptionIntentParams = { @@ -115,6 +114,12 @@ type SubscriptionIntentParams = { tokenAddress: Hex; }; +type ResolvedSubscriptionDelegationConfig = { + chainId: Hex; + delegateAddress: Hex; + enforcers: SubscriptionDelegationEnforcers; +}; + /** * Stateless orchestrator for subscription-payment delegation setup. * @@ -122,9 +127,9 @@ type SubscriptionIntentParams = { * Authenticated User Storage → register CHOMP intent. Returns a verified * `delegationHash` for `SubscriptionController.startSubscriptionWithCrypto`. * - * The CHOMP delegate comes from constructor - * {@link SubscriptionDelegationConfig}; Delegation Framework enforcers are - * resolved from `@metamask/delegation-deployments` for the configured chain. + * Each call resolves the Money Account chain from remote feature flags, the + * delegate from CHOMP service details, and Delegation Framework enforcers + * from `@metamask/delegation-deployments`. * * Does not own subscription state; `SubscriptionController` does not depend on * this service. Only Money Account Plus is supported. @@ -134,14 +139,8 @@ export class SubscriptionDelegationService { readonly #messenger: SubscriptionDelegationServiceMessenger; - readonly #config: SubscriptionDelegationConfig; - - readonly #enforcers: SubscriptionDelegationEnforcers; - constructor(options: SubscriptionDelegationServiceOptions) { this.#messenger = options.messenger; - this.#config = options.config; - this.#enforcers = resolveEnforcers(this.#config.chainId); this.#messenger.registerMethodActionHandlers( this, @@ -168,9 +167,8 @@ export class SubscriptionDelegationService { ); } - if (!equalsIgnoreCase(request.chainId, this.#config.chainId)) { - throw new Error(SubscriptionDelegationServiceErrorMessage.ChainIdMismatch); - } + const { chainId, delegateAddress, enforcers } = + await this.#resolveConfiguration(); const periodAmount = calculatePeriodAmount({ unitAmount: request.unitAmount, @@ -181,12 +179,12 @@ export class SubscriptionDelegationService { const matches = makeMatchesSubscriptionDelegation({ delegatorAddress: request.payerAddress, - delegateAddress: this.#config.delegateAddress, - chainId: request.chainId, + delegateAddress, + chainId, tokenAddress: request.tokenAddress, periodAmount, periodDuration, - enforcers: this.#enforcers, + enforcers, }); const existingDelegations = await this.#messenger.call( @@ -196,7 +194,7 @@ export class SubscriptionDelegationService { if (reusable) { await this.#ensureIntent({ account: request.payerAddress, - chainId: request.chainId, + chainId, delegationHash: reusable.metadata.delegationHash, allowance: reusable.metadata.allowance, tokenSymbol: reusable.metadata.tokenSymbol, @@ -210,9 +208,9 @@ export class SubscriptionDelegationService { const startDate = Math.floor(Date.now() / 1000); const unsigned = buildUnsignedSubscriptionDelegation({ - delegateAddress: this.#config.delegateAddress, + delegateAddress, delegatorAddress: request.payerAddress, - enforcers: this.#enforcers, + enforcers, tokenAddress: request.tokenAddress, periodAmount, periodDuration, @@ -221,7 +219,7 @@ export class SubscriptionDelegationService { const signature = (await this.#messenger.call( 'DelegationController:signDelegation', - { delegation: unsigned, chainId: request.chainId }, + { delegation: unsigned, chainId }, )) as Hex; const signedDelegation = { ...unsigned, signature }; @@ -230,7 +228,7 @@ export class SubscriptionDelegationService { 'ChompApiService:verifyDelegation', { signedDelegation, - chainId: request.chainId, + chainId, }, ); @@ -267,7 +265,7 @@ export class SubscriptionDelegationService { signedDelegation, metadata: { delegationHash, - chainIdHex: request.chainId, + chainIdHex: chainId, allowance, tokenSymbol: request.tokenSymbol, tokenAddress: request.tokenAddress, @@ -278,7 +276,7 @@ export class SubscriptionDelegationService { await this.#createIntent({ account: request.payerAddress, - chainId: request.chainId, + chainId, delegationHash, allowance, tokenSymbol: request.tokenSymbol, @@ -291,6 +289,39 @@ export class SubscriptionDelegationService { }; } + async #resolveConfiguration(): Promise { + const { remoteFeatureFlags } = this.#messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const vaultConfig = getMoneyAccountVaultConfig(remoteFeatureFlags); + if (!vaultConfig) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.MissingMoneyAccountVaultConfig, + ); + } + + const { chainId } = vaultConfig; + const enforcers = resolveEnforcers(chainId); + const { chains } = await this.#messenger.call( + 'ChompApiService:getServiceDetails', + [chainId], + ); + const chain = chains[chainId]; + if (!chain) { + throw new Error( + `${SubscriptionDelegationServiceErrorMessage.ChompChainNotFound}: ${chainId}`, + ); + } + + // TODO(SUB-911/SUB-914): Use the subscription-payment delegate once CHOMP + // exposes one instead of reusing the Money Account auto-deposit delegate. + return { + chainId, + delegateAddress: chain.autoDepositDelegate, + enforcers, + }; + } + /** * Ensures an active CHOMP intent exists for the given delegation hash, * registering one when missing or revoked. diff --git a/packages/subscription-controller/src/subscription-delegation/types.ts b/packages/subscription-controller/src/subscription-delegation/types.ts index 6bcbc3ad21e..107f2d1533e 100644 --- a/packages/subscription-controller/src/subscription-delegation/types.ts +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -21,7 +21,6 @@ export const SUBSCRIPTION_PAYMENT_DELEGATION_TYPE = 'subscription-payment'; export type PrepareSubscriptionDelegationRequest = { product: typeof PRODUCT_TYPES.MONEY_ACCOUNT_PLUS; recurringInterval: RecurringInterval; - chainId: Hex; payerAddress: Hex; tokenAddress: Hex; tokenSymbol: string; @@ -46,16 +45,3 @@ export type SubscriptionDelegationEnforcers = { valueLte: Hex; erc20TokenPeriodTransfer: Hex; }; - -/** - * Immutable, chain-scoped CHOMP subscription-payment configuration supplied - * at service construction (mirrors Money Account upgrade config). - * - * Wallet supplies the CHOMP delegate. The service resolves Delegation - * Framework enforcers for {@link chainId} from - * `@metamask/delegation-deployments`. - */ -export type SubscriptionDelegationConfig = { - chainId: Hex; - delegateAddress: Hex; -}; diff --git a/packages/subscription-controller/tsconfig.build.json b/packages/subscription-controller/tsconfig.build.json index 626b2efdba6..cf15dfd13da 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -27,12 +27,18 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../money-account-utils/tsconfig.build.json" + }, { "path": "../polling-controller/tsconfig.build.json" }, { "path": "../profile-sync-controller/tsconfig.build.json" }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, { "path": "../transaction-controller/tsconfig.build.json" } diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 6d75497dcca..021342658ac 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -25,12 +25,18 @@ { "path": "../messenger" }, + { + "path": "../money-account-utils" + }, { "path": "../polling-controller" }, { "path": "../profile-sync-controller" }, + { + "path": "../remote-feature-flag-controller" + }, { "path": "../transaction-controller" } diff --git a/yarn.lock b/yarn.lock index b928111c03f..0f7d7221375 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9128,8 +9128,10 @@ __metadata: "@metamask/delegation-core": "npm:^2.2.1" "@metamask/delegation-deployments": "npm:^1.4.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/money-account-utils": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/profile-sync-controller": "npm:^29.0.0" + "@metamask/remote-feature-flag-controller": "npm:^6.1.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/transaction-controller": "npm:^69.8.0" "@metamask/utils": "npm:^11.12.0" From 5558a54d583293b6464492558f106d4da690ef96 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 22:11:01 +0700 Subject: [PATCH 03/23] feat: integrate SubscriptionDelegationService into wallet initialization --- packages/wallet/CHANGELOG.md | 7 +++ .../src/initialization/instances/index.ts | 1 + .../subscription-delegation-service.test.ts | 62 +++++++++++++++++++ .../subscription-delegation-service.ts | 38 ++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts create mode 100644 packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index e1ed662f0d3..32eb4802798 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Wire `SubscriptionDelegationService` into the default wallet initialization. + - Stateless orchestrator for Money Account Plus subscription-payment delegation setup via `SubscriptionDelegationService:prepareDelegation`. + - Delegates `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:createIntents`, `ChompApiService:getIntentsByAddress`, `ChompApiService:getServiceDetails`, `DelegationController:signDelegation`, and `RemoteFeatureFlagController:getState` from the wallet root messenger. + - Hosts must register `AuthenticatedUserStorageService`, `ChompApiService`, and `DelegationController` on the supplied root messenger before calling `prepareDelegation`; `RemoteFeatureFlagController` is already initialized by default. + ### Changed - Bump `@metamask/claims-controller` from `^0.6.0` to `^0.6.1` ([#9972](https://github.com/MetaMask/core/pull/9972)) diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index c8f4b529ec9..c9dc71e58a2 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -16,5 +16,6 @@ export { shieldApiService } from './shield-api-service/shield-api-service.js'; export { shieldController } from './shield-controller/shield-controller.js'; export { storageService } from './storage-service/storage-service.js'; export { subscriptionController } from './subscription-controller/subscription-controller.js'; +export { subscriptionDelegationService } from './subscription-delegation-service/subscription-delegation-service.js'; export { subscriptionService } from './subscription-service/subscription-service.js'; export { transactionController } from './transaction-controller/transaction-controller.js'; diff --git a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts new file mode 100644 index 00000000000..09c5046ac4f --- /dev/null +++ b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts @@ -0,0 +1,62 @@ +import { Messenger } from '@metamask/messenger'; +import { SubscriptionDelegationService } from '@metamask/subscription-controller'; + +import { defaultConfigurations } from '../../defaults.js'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import { subscriptionDelegationService } from './subscription-delegation-service.js'; + +/** + * Creates a root messenger for use in tests. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: 'Root' }); +} + +describe('subscriptionDelegationService', () => { + it('is registered as a default initialization configuration', () => { + expect(Object.values(defaultConfigurations)).toContain( + subscriptionDelegationService, + ); + }); + + it('initializes a SubscriptionDelegationService', () => { + const messenger = subscriptionDelegationService.getMessenger( + getRootMessenger(), + ); + + const instance = subscriptionDelegationService.init({ + state: undefined, + messenger, + options: {}, + }); + + expect(instance).toBeInstanceOf(SubscriptionDelegationService); + }); + + it('delegates the actions the service calls on other messengers', () => { + const parent = getRootMessenger(); + const delegateSpy = jest.spyOn(parent, 'delegate'); + + const messenger = subscriptionDelegationService.getMessenger(parent); + + expect(delegateSpy).toHaveBeenCalledWith({ + messenger, + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:createIntents', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:getServiceDetails', + 'DelegationController:signDelegation', + 'RemoteFeatureFlagController:getState', + ], + }); + }); +}); diff --git a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts new file mode 100644 index 00000000000..5db223e8152 --- /dev/null +++ b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts @@ -0,0 +1,38 @@ +import { Messenger } from '@metamask/messenger'; +import { SubscriptionDelegationService } from '@metamask/subscription-controller'; +import type { SubscriptionDelegationServiceMessenger } from '@metamask/subscription-controller'; + +import type { InitializationConfiguration } from '../../types.js'; + +export const subscriptionDelegationService: InitializationConfiguration< + SubscriptionDelegationService, + SubscriptionDelegationServiceMessenger +> = { + name: 'SubscriptionDelegationService', + init: ({ messenger }) => + new SubscriptionDelegationService({ + messenger, + }), + getMessenger: (parent) => { + const messenger: SubscriptionDelegationServiceMessenger = new Messenger({ + namespace: 'SubscriptionDelegationService', + parent, + }); + + parent.delegate({ + messenger, + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:createIntents', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:getServiceDetails', + 'DelegationController:signDelegation', + 'RemoteFeatureFlagController:getState', + ], + }); + + return messenger; + }, +}; From 4a47512d5583fe394dd4a56c2217020b7b286851 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 22:21:09 +0700 Subject: [PATCH 04/23] chore: update changelog --- packages/chomp-api-service/CHANGELOG.md | 2 +- packages/subscription-controller/CHANGELOG.md | 2 +- packages/wallet/CHANGELOG.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index 40e25bc8dc1..83a847fe642 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Accept `'subscription-payment'` as a CHOMP intent / delegation metadata type alongside `'cash-deposit'` and `'cash-withdrawal'`. +- Accept `'subscription-payment'` as a CHOMP intent / delegation metadata type alongside `'cash-deposit'` and `'cash-withdrawal'`. ([#10130](https://github.com/MetaMask/core/pull/10130)) - Adds exported `ChompIntentType` alias covering all three values. - Response structs for create-intents, get-intents-by-address, and service-details now accept the new type. diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 4191788be8e..bb63cbd9129 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `SubscriptionDelegationService` for Money Account Plus subscription-payment delegation setup. +- Add `SubscriptionDelegationService` for Money Account Plus subscription-payment delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - On each call, resolves the chain from `moneyAccountVaultConfig`, the temporary delegate from CHOMP's `autoDepositDelegate`, and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 32eb4802798..ef0fe41852e 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Wire `SubscriptionDelegationService` into the default wallet initialization. +- Wire `SubscriptionDelegationService` into the default wallet initialization. ([#10130](https://github.com/MetaMask/core/pull/10130)) - Stateless orchestrator for Money Account Plus subscription-payment delegation setup via `SubscriptionDelegationService:prepareDelegation`. - Delegates `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:createIntents`, `ChompApiService:getIntentsByAddress`, `ChompApiService:getServiceDetails`, `DelegationController:signDelegation`, and `RemoteFeatureFlagController:getState` from the wallet root messenger. - Hosts must register `AuthenticatedUserStorageService`, `ChompApiService`, and `DelegationController` on the supplied root messenger before calling `prepareDelegation`; `RemoteFeatureFlagController` is already initialized by default. From 2874cd1df5737236e4782f17a6885e1cee5a186e Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 22:27:00 +0700 Subject: [PATCH 05/23] fix: lint --- .../SubscriptionDelegationService.test.ts | 13 +++++-------- .../SubscriptionDelegationService.ts | 7 ++----- .../src/subscription-delegation/amount.test.ts | 1 - .../src/subscription-delegation/caveats.ts | 2 +- .../src/subscription-delegation/fingerprint.test.ts | 2 +- .../subscription-controller/tsconfig.build.json | 5 +---- packages/subscription-controller/tsconfig.json | 6 +----- .../subscription-delegation-service.test.ts | 5 ++--- 8 files changed, 13 insertions(+), 28 deletions(-) diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index 2ffaad8858c..b1627edf6ed 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -1,6 +1,4 @@ import type { ServiceDetailsResponse } from '@metamask/chomp-api-service'; -import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; -import type { MockAnyNamespace } from '@metamask/messenger'; import { createERC20TokenPeriodTransferTerms, createValueLteTerms, @@ -8,11 +6,12 @@ import { ROOT_AUTHORITY, } from '@metamask/delegation-core'; import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; -import { PRODUCT_TYPES, RECURRING_INTERVALS } from '../types.js'; import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; - +import { PRODUCT_TYPES, RECURRING_INTERVALS } from '../types.js'; import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; import { SubscriptionDelegationService, @@ -27,10 +26,8 @@ const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; const PAYER = '0x5555555555555555555555555555555555555555' as Hex; const CHAIN_ID = '0x1' as Hex; const SIGNATURE: Hex = `0x${'ab'.repeat(65)}`; -const { - ValueLteEnforcer: VALUE_LTE, - ERC20PeriodTransferEnforcer: PERIOD, -} = DELEGATOR_CONTRACTS['1.3.0'][1]; +const { ValueLteEnforcer: VALUE_LTE, ERC20PeriodTransferEnforcer: PERIOD } = + DELEGATOR_CONTRACTS['1.3.0'][1]; const MONEY_ACCOUNT_VAULT_CONFIG = { chainId: CHAIN_ID, diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index 31271ed5bd2..3898055b943 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -19,13 +19,13 @@ import type { Hex } from '@metamask/utils'; import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; import { PRODUCT_TYPES } from '../types.js'; -import type { SubscriptionDelegationServiceMethodActions } from './SubscriptionDelegationService-method-action-types.js'; import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; import { buildUnsignedSubscriptionDelegation } from './caveats.js'; import { equalsIgnoreCase, makeMatchesSubscriptionDelegation, } from './fingerprint.js'; +import type { SubscriptionDelegationServiceMethodActions } from './SubscriptionDelegationService-method-action-types.js'; import type { PrepareSubscriptionDelegationRequest, PreparedSubscriptionDelegation, @@ -47,10 +47,7 @@ function resolveEnforcers(chainId: Hex): SubscriptionDelegationEnforcers { const contracts = DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]?.[hexToNumber(chainId)]; - if ( - !contracts?.ValueLteEnforcer || - !contracts.ERC20PeriodTransferEnforcer - ) { + if (!contracts?.ValueLteEnforcer || !contracts.ERC20PeriodTransferEnforcer) { throw new Error( `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: ${chainId}`, ); diff --git a/packages/subscription-controller/src/subscription-delegation/amount.test.ts b/packages/subscription-controller/src/subscription-delegation/amount.test.ts index 55b63322ea5..1400b63ab64 100644 --- a/packages/subscription-controller/src/subscription-delegation/amount.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/amount.test.ts @@ -1,6 +1,5 @@ import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; import { RECURRING_INTERVALS } from '../types.js'; - import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; describe('calculatePeriodAmount', () => { diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts index f742ee1b2d9..e772950a1fc 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -1,9 +1,9 @@ +import type { SignedDelegation } from '@metamask/authenticated-user-storage'; import { ROOT_AUTHORITY, createERC20TokenPeriodTransferTerms, createValueLteTerms, } from '@metamask/delegation-core'; -import type { SignedDelegation } from '@metamask/authenticated-user-storage'; import { bytesToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index 561a9cda210..bd539de6efc 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -1,9 +1,9 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; import { createERC20TokenPeriodTransferTerms, createValueLteTerms, ROOT_AUTHORITY, } from '@metamask/delegation-core'; -import type { DelegationResponse } from '@metamask/authenticated-user-storage'; import type { Hex } from '@metamask/utils'; import { diff --git a/packages/subscription-controller/tsconfig.build.json b/packages/subscription-controller/tsconfig.build.json index cf15dfd13da..cea334f10b5 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -43,8 +43,5 @@ "path": "../transaction-controller/tsconfig.build.json" } ], - "include": [ - "../../types", - "./src" - ] + "include": ["../../types", "./src"] } diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 021342658ac..0ba73e375e8 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -41,9 +41,5 @@ "path": "../transaction-controller" } ], - "include": [ - "../../types", - "./src", - "./tests" - ] + "include": ["../../types", "./src", "./tests"] } diff --git a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts index 09c5046ac4f..03358f284b0 100644 --- a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts +++ b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts @@ -26,9 +26,8 @@ describe('subscriptionDelegationService', () => { }); it('initializes a SubscriptionDelegationService', () => { - const messenger = subscriptionDelegationService.getMessenger( - getRootMessenger(), - ); + const messenger = + subscriptionDelegationService.getMessenger(getRootMessenger()); const instance = subscriptionDelegationService.init({ state: undefined, From db183421a619402f4eb58975a92e739c907940c5 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 22:31:39 +0700 Subject: [PATCH 06/23] fix: test --- .../subscription-controller/jest.config.js | 2 ++ .../jest.environment.js | 19 +++++++++++++++++++ packages/subscription-controller/package.json | 1 + yarn.lock | 1 + 4 files changed, 23 insertions(+) create mode 100644 packages/subscription-controller/jest.environment.js diff --git a/packages/subscription-controller/jest.config.js b/packages/subscription-controller/jest.config.js index ca084133399..5576deb9671 100644 --- a/packages/subscription-controller/jest.config.js +++ b/packages/subscription-controller/jest.config.js @@ -14,6 +14,8 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + testEnvironment: '/jest.environment.js', + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/subscription-controller/jest.environment.js b/packages/subscription-controller/jest.environment.js new file mode 100644 index 00000000000..8610679374f --- /dev/null +++ b/packages/subscription-controller/jest.environment.js @@ -0,0 +1,19 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * Subscription delegation salt generation uses the Web Crypto API + * (`crypto.getRandomValues`), which is not exposed as a global by + * jest-environment-node. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 9a6ffe14958..5435e1f30ee 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -81,6 +81,7 @@ "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", "jest": "^30.4.2", + "jest-environment-node": "^30.4.1", "ts-jest": "^29.4.11", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/yarn.lock b/yarn.lock index 3671b5616e4..ab1d504dd57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9142,6 +9142,7 @@ __metadata: deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" jest: "npm:^30.4.2" + jest-environment-node: "npm:^30.4.1" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" From 7c78f0458e08761837ea5481e1187c8e8b6d649c Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 7 Sep 2026 22:32:18 +0700 Subject: [PATCH 07/23] chore: update readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 217db0a052c..f870190fa05 100644 --- a/README.md +++ b/README.md @@ -622,12 +622,17 @@ linkStyle default opacity:0.5 social_controllers --> profile_sync_controller; solana_test_validator_up --> local_node_utils; storage_service --> messenger; + subscription_controller --> authenticated_user_storage; subscription_controller --> base_controller; subscription_controller --> base_data_service; + subscription_controller --> chomp_api_service; subscription_controller --> controller_utils; + subscription_controller --> delegation_controller; subscription_controller --> messenger; + subscription_controller --> money_account_utils; subscription_controller --> polling_controller; subscription_controller --> profile_sync_controller; + subscription_controller --> remote_feature_flag_controller; subscription_controller --> transaction_controller; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; From c159d25926ccfb7900f552a94f45452df6100e74 Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 9 Sep 2026 21:27:40 +0700 Subject: [PATCH 08/23] feat: enhance SubscriptionDelegationService with balance check and trial period handling --- packages/subscription-controller/CHANGELOG.md | 6 +- packages/subscription-controller/package.json | 1 + .../subscription-controller/src/constants.ts | 5 +- packages/subscription-controller/src/index.ts | 3 + ...onDelegationService-method-action-types.ts | 15 +- .../SubscriptionDelegationService.test.ts | 370 ++++++++++++++++-- .../SubscriptionDelegationService.ts | 180 +++++++-- .../subscription-delegation/amount.test.ts | 37 +- .../src/subscription-delegation/amount.ts | 34 ++ .../subscription-delegation/caveats.test.ts | 43 +- .../src/subscription-delegation/caveats.ts | 32 +- .../fingerprint.test.ts | 66 +++- .../subscription-delegation/fingerprint.ts | 15 +- .../src/subscription-delegation/types.ts | 41 +- .../tsconfig.build.json | 3 + .../subscription-controller/tsconfig.json | 3 + yarn.lock | 3 +- 17 files changed, 723 insertions(+), 134 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index b8448583324..28e9e56cd2b 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -12,7 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `SubscriptionDelegationService` for Money Account Plus subscription-payment delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - - On each call, resolves the chain from `moneyAccountVaultConfig`, the temporary delegate from CHOMP's `autoDepositDelegate`, and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. + - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, and optional balance-check flag; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. + - Resolves the chain from `moneyAccountVaultConfig` and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. + - Uses pricing `delegateAddress` as both the delegation `delegate` and the RedeemerEnforcer redeemer. + - Offsets the period-transfer `startDate` by pricing `trialPeriodDays` only when the trial is selected. + - New messenger action `SubscriptionDelegationService:checkMoneyAccountBalance` compares Money Account convertible mUSD balance against pricing `unitAmount × minBillingCyclesForBalance`; `prepareDelegation` can gate on it via `checkBalance`. - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. - Add `selectIsActiveSubscriber` to check whether a product has an active, trialing, or provisional subscription. ([#10017](https://github.com/MetaMask/core/pull/10017)) - Add product-scoped entitlements to `SubscriptionController` state and export type-safe `selectHasEntitlement` and `selectIsUsageAvailable` selectors for Money Account Plus and Shield ([#10017](https://github.com/MetaMask/core/pull/10017)) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 5435e1f30ee..ef2d406eb68 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -64,6 +64,7 @@ "@metamask/delegation-core": "^2.2.1", "@metamask/delegation-deployments": "^1.4.0", "@metamask/messenger": "^2.0.0", + "@metamask/money-account-balance-service": "^2.4.3", "@metamask/money-account-utils": "^1.2.0", "@metamask/polling-controller": "^16.0.9", "@metamask/profile-sync-controller": "^29.0.0", diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index a92204f9220..f69aae97dfc 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -72,12 +72,15 @@ export enum SubscriptionServiceErrorMessage { export enum SubscriptionDelegationServiceErrorMessage { InvalidAmount = 'Subscription delegation amount must be a non-negative integer', InvalidDecimals = 'Subscription delegation decimals must be a non-negative integer', + InvalidTrialPeriodDays = 'Subscription delegation trial period days must be a non-negative integer', + InvalidMinimumFundingCycles = 'Subscription delegation minimum funding cycles must be a positive integer', LossyAmountScale = 'Subscription delegation amount cannot be scaled to token decimals without remainder', UnsupportedRecurringInterval = 'Unsupported subscription recurring interval', UnsupportedProduct = 'Subscription delegation is only supported for Money Account Plus', MissingMoneyAccountVaultConfig = 'Money Account vault configuration is missing or invalid', DelegationContractsNotFound = 'Subscription delegation contracts were not found for the configured chain', - ChompChainNotFound = 'Subscription delegation chain was not found in CHOMP service details', + PricingConfigurationNotFound = 'Subscription delegation pricing configuration was not found', + InsufficientBalance = 'Money Account balance is insufficient for the subscription funding requirement', ChompRejectedDelegation = 'CHOMP rejected the subscription delegation', ChompMissingDelegationHash = 'CHOMP verify response did not include a delegation hash', ChompDelegationHashMismatch = 'CHOMP verify response delegation hash does not match the locally computed hash', diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index 3c56cb187e7..d96efaf5e0d 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -173,7 +173,10 @@ export { serviceName as subscriptionDelegationServiceName, } from './subscription-delegation/SubscriptionDelegationService.js'; export type { SubscriptionDelegationServicePrepareDelegationAction } from './subscription-delegation/SubscriptionDelegationService-method-action-types.js'; +export type { SubscriptionDelegationServiceCheckMoneyAccountBalanceAction } from './subscription-delegation/SubscriptionDelegationService-method-action-types.js'; export type { + MoneyAccountBalanceCheckRequest, + MoneyAccountBalanceCheckResult, PrepareSubscriptionDelegationRequest, PreparedSubscriptionDelegation, } from './subscription-delegation/types.js'; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index 290e50b8f55..7aefe8bb579 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -5,6 +5,18 @@ import type { SubscriptionDelegationService } from './SubscriptionDelegationService.js'; +/** + * Checks whether the Money Account holds enough convertible mUSD value to + * cover pricing `unitAmount × minBillingCyclesForBalance`. + * + * @param request - Payer address and pricing amount fields. + * @returns Balance comparison in mUSD base units (6 decimals). + */ +export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { + type: `SubscriptionDelegationService:checkMoneyAccountBalance`; + handler: SubscriptionDelegationService['checkMoneyAccountBalance']; +}; + /** * Prepares a subscription-payment delegation and returns its verified hash. * @@ -24,4 +36,5 @@ export type SubscriptionDelegationServicePrepareDelegationAction = { * Union of all SubscriptionDelegationService action types. */ export type SubscriptionDelegationServiceMethodActions = - SubscriptionDelegationServicePrepareDelegationAction; + | SubscriptionDelegationServiceCheckMoneyAccountBalanceAction + | SubscriptionDelegationServicePrepareDelegationAction; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index b1627edf6ed..ae10ec66076 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -1,7 +1,8 @@ -import type { ServiceDetailsResponse } from '@metamask/chomp-api-service'; import { createERC20TokenPeriodTransferTerms, + createRedeemerTerms, createValueLteTerms, + decodeERC20TokenPeriodTransferTerms, hashDelegation, ROOT_AUTHORITY, } from '@metamask/delegation-core'; @@ -11,7 +12,16 @@ import type { MockAnyNamespace } from '@metamask/messenger'; import type { Hex } from '@metamask/utils'; import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; -import { PRODUCT_TYPES, RECURRING_INTERVALS } from '../types.js'; +import { + CRYPTO_AUTH_METHODS, + PAYMENT_TYPES, + PRODUCT_TYPES, + RECURRING_INTERVALS, +} from '../types.js'; +import type { + PricingCryptoPaymentMethod, + PricingResponse, +} from '../types.js'; import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; import { SubscriptionDelegationService, @@ -25,9 +35,13 @@ const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; const PAYER = '0x5555555555555555555555555555555555555555' as Hex; const CHAIN_ID = '0x1' as Hex; +const TOKEN_DECIMALS = 18; const SIGNATURE: Hex = `0x${'ab'.repeat(65)}`; -const { ValueLteEnforcer: VALUE_LTE, ERC20PeriodTransferEnforcer: PERIOD } = - DELEGATOR_CONTRACTS['1.3.0'][1]; +const { + ValueLteEnforcer: VALUE_LTE, + ERC20PeriodTransferEnforcer: PERIOD, + RedeemerEnforcer: REDEEMER, +} = DELEGATOR_CONTRACTS['1.3.0'][1]; const MONEY_ACCOUNT_VAULT_CONFIG = { chainId: CHAIN_ID, @@ -41,35 +55,76 @@ const REMOTE_FEATURE_FLAGS: Record = { moneyAccountVaultConfig: MONEY_ACCOUNT_VAULT_CONFIG, }; -const SERVICE_DETAILS: ServiceDetailsResponse = { - auth: { message: 'CHOMP Authentication' }, - chains: { - [CHAIN_ID]: { - autoDepositDelegate: DELEGATE, - protocol: {}, +const PRICE = { + interval: RECURRING_INTERVALS.month, + unitAmount: 1000, + unitDecimals: 2, + currency: 'usd' as const, + trialPeriodDays: 14, + minBillingCycles: 12, + minBillingCyclesForBalance: 3, +}; + +const PRICING_DELEGATION_PAYMENT_METHOD: PricingCryptoPaymentMethod = { + type: PAYMENT_TYPES.byCrypto, + cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, + products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], + chains: [ + { + chainId: CHAIN_ID, + paymentAddress: '0x2222222222222222222222222222222222222222' as Hex, + delegateAddress: DELEGATE, + tokens: [ + { + address: TOKEN, + symbol: 'pvmUSD', + decimals: TOKEN_DECIMALS, + }, + ], + }, + ], +}; + +const PRICING: PricingResponse = { + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + prices: [PRICE], }, - }, + ], + paymentMethods: [PRICING_DELEGATION_PAYMENT_METHOD], }; const REQUEST: PrepareSubscriptionDelegationRequest = { product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, recurringInterval: RECURRING_INTERVALS.month, payerAddress: PAYER, - tokenAddress: TOKEN, - tokenSymbol: 'pvmUSD', - tokenDecimals: 18, - unitAmount: 1000, - unitDecimals: 2, - minimumFundingCycles: 3, + isTrialRequested: false, }; const PERIOD_AMOUNT = calculatePeriodAmount({ - unitAmount: REQUEST.unitAmount, - unitDecimals: REQUEST.unitDecimals, - tokenDecimals: REQUEST.tokenDecimals, + unitAmount: PRICE.unitAmount, + unitDecimals: PRICE.unitDecimals, + tokenDecimals: TOKEN_DECIMALS, }); const PERIOD_DURATION = getPeriodDuration(REQUEST.recurringInterval); +const SUFFICIENT_BALANCE = { + musdBalance: '30000000', + vmusdValueInMusd: '0', + totalBalance: '30000000', + source: 'rpc' as const, + usedFallback: false, +}; + +const INSUFFICIENT_BALANCE = { + musdBalance: '100', + vmusdValueInMusd: '0', + totalBalance: '100', + source: 'rpc' as const, + usedFallback: false, +}; + type Mocks = { listDelegations: jest.Mock; createDelegation: jest.Mock; @@ -78,7 +133,8 @@ type Mocks = { getIntentsByAddress: jest.Mock; createIntents: jest.Mock; getRemoteFeatureFlagState: jest.Mock; - getServiceDetails: jest.Mock; + fetchBalanceWithFallback: jest.Mock; + getPricing: jest.Mock; }; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -88,7 +144,8 @@ function setup( intents?: unknown[]; verify?: { valid: boolean; delegationHash?: Hex; errors?: string[] }; remoteFeatureFlags?: Record; - serviceDetails?: ServiceDetailsResponse; + balance?: typeof SUFFICIENT_BALANCE; + pricing?: PricingResponse; } = {}, ) { const mocks: Mocks = { @@ -113,9 +170,10 @@ function setup( remoteFeatureFlags: options.remoteFeatureFlags ?? REMOTE_FEATURE_FLAGS, cacheTimestamp: 0, }), - getServiceDetails: jest + fetchBalanceWithFallback: jest .fn() - .mockResolvedValue(options.serviceDetails ?? SERVICE_DETAILS), + .mockResolvedValue(options.balance ?? SUFFICIENT_BALANCE), + getPricing: jest.fn().mockResolvedValue(options.pricing ?? PRICING), }; type AllowedActions = @@ -148,8 +206,12 @@ function setup( handler: Mocks['getRemoteFeatureFlagState']; } | { - type: 'ChompApiService:getServiceDetails'; - handler: Mocks['getServiceDetails']; + type: 'MoneyAccountBalanceService:fetchBalanceWithFallback'; + handler: Mocks['fetchBalanceWithFallback']; + } + | { + type: 'SubscriptionController:getPricing'; + handler: Mocks['getPricing']; }; const rootMessenger = new Messenger< @@ -158,6 +220,10 @@ function setup( | { type: `${typeof serviceName}:prepareDelegation`; handler: SubscriptionDelegationService['prepareDelegation']; + } + | { + type: `${typeof serviceName}:checkMoneyAccountBalance`; + handler: SubscriptionDelegationService['checkMoneyAccountBalance']; }, never >({ namespace: MOCK_ANY_NAMESPACE }); @@ -191,8 +257,12 @@ function setup( mocks.getRemoteFeatureFlagState, ); rootMessenger.registerActionHandler( - 'ChompApiService:getServiceDetails', - mocks.getServiceDetails, + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + mocks.fetchBalanceWithFallback, + ); + rootMessenger.registerActionHandler( + 'SubscriptionController:getPricing', + mocks.getPricing, ); const messenger: SubscriptionDelegationServiceMessenger = new Messenger({ @@ -210,7 +280,8 @@ function setup( 'ChompApiService:getIntentsByAddress', 'ChompApiService:createIntents', 'RemoteFeatureFlagController:getState', - 'ChompApiService:getServiceDetails', + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + 'SubscriptionController:getPricing', ], events: [], }); @@ -253,6 +324,11 @@ function buildStoredDelegation({ }), args: '0x', }, + { + enforcer: REDEEMER, + terms: createRedeemerTerms({ redeemers: [DELEGATE] }), + args: '0x', + }, ], salt: `0x${'aa'.repeat(32)}`, signature: SIGNATURE, @@ -277,9 +353,24 @@ function expectNoSideEffects(mocks: Mocks): void { expect(mocks.createIntents).not.toHaveBeenCalled(); } +function getSignedPeriodStartDate(mocks: Mocks): number { + const signedArgs = mocks.signDelegation.mock.calls[0][0] as { + delegation: { + caveats: { enforcer: Hex; terms: Hex }[]; + }; + }; + const periodCaveat = signedArgs.delegation.caveats.find( + (caveat) => caveat.enforcer === PERIOD, + ); + expect(periodCaveat).toBeDefined(); + + return decodeERC20TokenPeriodTransferTerms(periodCaveat?.terms ?? '0x') + .startDate; +} + describe('SubscriptionDelegationService', () => { describe('prepareDelegation', () => { - it('creates, verifies, persists, and registers using feature flag and CHOMP config', async () => { + it('creates, verifies, persists, and registers using feature flag and pricing delegate', async () => { const { service, mocks } = setup(); const result = await service.prepareDelegation(REQUEST); @@ -287,7 +378,8 @@ describe('SubscriptionDelegationService', () => { expect(result.disposition).toBe('created'); expect(result.delegationHash).toMatch(/^0x[0-9a-fA-F]{64}$/u); expect(mocks.getRemoteFeatureFlagState).toHaveBeenCalledTimes(1); - expect(mocks.getServiceDetails).toHaveBeenCalledWith([CHAIN_ID]); + expect(mocks.getPricing).toHaveBeenCalledTimes(1); + expect(mocks.fetchBalanceWithFallback).not.toHaveBeenCalled(); expect(mocks.signDelegation).toHaveBeenCalledTimes(1); expect(mocks.signDelegation).toHaveBeenCalledWith({ delegation: expect.objectContaining({ @@ -296,6 +388,11 @@ describe('SubscriptionDelegationService', () => { caveats: [ expect.objectContaining({ enforcer: VALUE_LTE }), expect.objectContaining({ enforcer: PERIOD }), + expect.objectContaining({ + enforcer: REDEEMER, + terms: createRedeemerTerms({ redeemers: [DELEGATE] }), + args: '0x', + }), ], }), chainId: CHAIN_ID, @@ -342,6 +439,43 @@ describe('SubscriptionDelegationService', () => { expect(result.disposition).toBe('created'); }); + it('offsets the period startDate by pricing trialPeriodDays when trial is requested', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + + try { + const { service, mocks } = setup(); + + await service.prepareDelegation({ + ...REQUEST, + isTrialRequested: true, + }); + + expect(getSignedPeriodStartDate(mocks)).toBe( + Math.floor(Date.now() / 1000) + PRICE.trialPeriodDays * 86_400, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('does not apply pricing trialPeriodDays when trial is not requested', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); + + try { + const { service, mocks } = setup(); + + await service.prepareDelegation(REQUEST); + + expect(getSignedPeriodStartDate(mocks)).toBe( + Math.floor(Date.now() / 1000), + ); + } finally { + jest.useRealTimers(); + } + }); + it('reuses a matching stored delegation and skips sign/verify when an intent is active', async () => { const stored = buildStoredDelegation(); const { service, mocks } = setup({ @@ -388,6 +522,31 @@ describe('SubscriptionDelegationService', () => { expect(mocks.signDelegation).not.toHaveBeenCalled(); }); + it('checks Money Account balance when checkBalance is true and proceeds when sufficient', async () => { + const { service, mocks } = setup(); + + const result = await service.prepareDelegation({ + ...REQUEST, + checkBalance: true, + }); + + expect(result.disposition).toBe('created'); + expect(mocks.fetchBalanceWithFallback).toHaveBeenCalledWith(PAYER); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + }); + + it('throws InsufficientBalance before side effects when checkBalance is true and balance is low', async () => { + const { service, mocks } = setup({ balance: INSUFFICIENT_BALANCE }); + + await expect( + service.prepareDelegation({ ...REQUEST, checkBalance: true }), + ).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.InsufficientBalance, + ); + expect(mocks.fetchBalanceWithFallback).toHaveBeenCalledWith(PAYER); + expectNoSideEffects(mocks); + }); + it('throws when CHOMP rejects the delegation and does not persist', async () => { const { service, mocks } = setup({ verify: { valid: false, errors: ['bad caveat'] }, @@ -446,7 +605,7 @@ describe('SubscriptionDelegationService', () => { SubscriptionDelegationServiceErrorMessage.UnsupportedProduct, ); expect(mocks.getRemoteFeatureFlagState).not.toHaveBeenCalled(); - expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + expect(mocks.fetchBalanceWithFallback).not.toHaveBeenCalled(); expectNoSideEffects(mocks); }); @@ -461,7 +620,6 @@ describe('SubscriptionDelegationService', () => { await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( SubscriptionDelegationServiceErrorMessage.MissingMoneyAccountVaultConfig, ); - expect(mocks.getServiceDetails).not.toHaveBeenCalled(); expectNoSideEffects(mocks); }, ); @@ -479,23 +637,151 @@ describe('SubscriptionDelegationService', () => { await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: 0xffffffff`, ); - expect(mocks.getServiceDetails).not.toHaveBeenCalled(); expectNoSideEffects(mocks); }); - it('rejects when CHOMP service details omit the feature-flag chain', async () => { - const { service, mocks } = setup({ - serviceDetails: { - auth: { message: 'CHOMP Authentication' }, - chains: {}, + it.each([ + [ + 'product price', + { + ...PRICING, + products: [], }, - }); + ], + [ + 'delegation payment method', + { + ...PRICING, + paymentMethods: [], + }, + ], + [ + 'pricing chain', + { + ...PRICING, + paymentMethods: [ + { + ...PRICING_DELEGATION_PAYMENT_METHOD, + chains: [], + }, + ], + } as PricingResponse, + ], + [ + 'delegate address', + { + ...PRICING, + paymentMethods: [ + { + ...PRICING_DELEGATION_PAYMENT_METHOD, + chains: [ + { + ...PRICING_DELEGATION_PAYMENT_METHOD.chains?.[0], + delegateAddress: undefined, + }, + ], + }, + ], + } as PricingResponse, + ], + [ + 'payment token', + { + ...PRICING, + paymentMethods: [ + { + ...PRICING_DELEGATION_PAYMENT_METHOD, + chains: [ + { + ...PRICING_DELEGATION_PAYMENT_METHOD.chains?.[0], + tokens: [], + }, + ], + }, + ], + } as PricingResponse, + ], + ])('rejects when %s is missing from pricing', async (_name, pricing) => { + const { service, mocks } = setup({ pricing }); await expect(service.prepareDelegation(REQUEST)).rejects.toThrow( - `${SubscriptionDelegationServiceErrorMessage.ChompChainNotFound}: ${CHAIN_ID}`, + SubscriptionDelegationServiceErrorMessage.PricingConfigurationNotFound, ); - expect(mocks.getServiceDetails).toHaveBeenCalledWith([CHAIN_ID]); expectNoSideEffects(mocks); }); }); + + describe('checkMoneyAccountBalance', () => { + it('reports sufficient balance when totalBalance covers unitAmount × cycles', async () => { + const { service, mocks } = setup(); + + const result = await service.checkMoneyAccountBalance({ + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + recurringInterval: RECURRING_INTERVALS.month, + payerAddress: PAYER, + }); + + expect(mocks.fetchBalanceWithFallback).toHaveBeenCalledWith(PAYER); + expect(result).toStrictEqual({ + hasSufficientBalance: true, + balance: SUFFICIENT_BALANCE.totalBalance, + requiredBalance: '30000000', + }); + }); + + it('reports insufficient balance when totalBalance is below the required amount', async () => { + const { service } = setup({ balance: INSUFFICIENT_BALANCE }); + + const result = await service.checkMoneyAccountBalance({ + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + recurringInterval: RECURRING_INTERVALS.month, + payerAddress: PAYER, + }); + + expect(result).toStrictEqual({ + hasSufficientBalance: false, + balance: INSUFFICIENT_BALANCE.totalBalance, + requiredBalance: '30000000', + }); + }); + + it('is callable through the messenger', async () => { + const { rootMessenger } = setup(); + + const result = await rootMessenger.call( + 'SubscriptionDelegationService:checkMoneyAccountBalance', + { + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + recurringInterval: RECURRING_INTERVALS.month, + payerAddress: PAYER, + }, + ); + + expect(result.hasSufficientBalance).toBe(true); + }); + + it('throws on invalid minimumFundingCycles', async () => { + const pricing = { + ...PRICING, + products: [ + { + ...PRICING.products[0], + prices: [{ ...PRICE, minBillingCyclesForBalance: 0 }], + }, + ], + }; + const { service, mocks } = setup({ pricing }); + + await expect( + service.checkMoneyAccountBalance({ + product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + recurringInterval: RECURRING_INTERVALS.month, + payerAddress: PAYER, + }), + ).rejects.toThrow( + SubscriptionDelegationServiceErrorMessage.InvalidMinimumFundingCycles, + ); + expect(mocks.fetchBalanceWithFallback).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index 3898055b943..321dc418672 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -5,21 +5,41 @@ import type { import type { ChompApiServiceCreateIntentsAction, ChompApiServiceGetIntentsByAddressAction, - ChompApiServiceGetServiceDetailsAction, ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; import { hashDelegation } from '@metamask/delegation-core'; import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { Messenger } from '@metamask/messenger'; -import { getMoneyAccountVaultConfig } from '@metamask/money-account-utils'; +import type { MoneyAccountBalanceServiceFetchBalanceWithFallbackAction } from '@metamask/money-account-balance-service'; +import { + getMoneyAccountVaultConfig, + MUSD_DECIMALS, +} from '@metamask/money-account-utils'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import { add0x, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import type { SubscriptionControllerGetPricingAction } from '../SubscriptionController-method-action-types.js'; import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; -import { PRODUCT_TYPES } from '../types.js'; -import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; +import { + CRYPTO_AUTH_METHODS, + PAYMENT_TYPES, + PRODUCT_TYPES, +} from '../types.js'; +import type { + ProductPrice, + ProductType, + PricingCryptoPaymentMethod, + RecurringInterval, + TokenPaymentInfo, +} from '../types.js'; +import { + assertPositiveInteger, + calculatePeriodAmount, + getDelegationStartDate, + getPeriodDuration, +} from './amount.js'; import { buildUnsignedSubscriptionDelegation } from './caveats.js'; import { equalsIgnoreCase, @@ -27,6 +47,8 @@ import { } from './fingerprint.js'; import type { SubscriptionDelegationServiceMethodActions } from './SubscriptionDelegationService-method-action-types.js'; import type { + MoneyAccountBalanceCheckRequest, + MoneyAccountBalanceCheckResult, PrepareSubscriptionDelegationRequest, PreparedSubscriptionDelegation, SubscriptionDelegationEnforcers, @@ -39,7 +61,10 @@ import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; */ export const serviceName = 'SubscriptionDelegationService'; -const MESSENGER_EXPOSED_METHODS = ['prepareDelegation'] as const; +const MESSENGER_EXPOSED_METHODS = [ + 'prepareDelegation', + 'checkMoneyAccountBalance', +] as const; const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; @@ -47,7 +72,11 @@ function resolveEnforcers(chainId: Hex): SubscriptionDelegationEnforcers { const contracts = DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]?.[hexToNumber(chainId)]; - if (!contracts?.ValueLteEnforcer || !contracts.ERC20PeriodTransferEnforcer) { + if ( + !contracts?.ValueLteEnforcer || + !contracts.ERC20PeriodTransferEnforcer || + !contracts.RedeemerEnforcer + ) { throw new Error( `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: ${chainId}`, ); @@ -56,6 +85,7 @@ function resolveEnforcers(chainId: Hex): SubscriptionDelegationEnforcers { return { valueLte: contracts.ValueLteEnforcer, erc20TokenPeriodTransfer: contracts.ERC20PeriodTransferEnforcer, + redeemer: contracts.RedeemerEnforcer, }; } @@ -74,9 +104,10 @@ type AllowedActions = | ChompApiServiceVerifyDelegationAction | ChompApiServiceCreateIntentsAction | ChompApiServiceGetIntentsByAddressAction - | ChompApiServiceGetServiceDetailsAction | DelegationControllerSignDelegationAction - | RemoteFeatureFlagControllerGetStateAction; + | MoneyAccountBalanceServiceFetchBalanceWithFallbackAction + | RemoteFeatureFlagControllerGetStateAction + | SubscriptionControllerGetPricingAction; /** * Events that {@link SubscriptionDelegationService} exposes to other consumers. @@ -115,6 +146,8 @@ type ResolvedSubscriptionDelegationConfig = { chainId: Hex; delegateAddress: Hex; enforcers: SubscriptionDelegationEnforcers; + price: ProductPrice; + token: TokenPaymentInfo; }; /** @@ -124,9 +157,10 @@ type ResolvedSubscriptionDelegationConfig = { * Authenticated User Storage → register CHOMP intent. Returns a verified * `delegationHash` for `SubscriptionController.startSubscriptionWithCrypto`. * - * Each call resolves the Money Account chain from remote feature flags, the - * delegate from CHOMP service details, and Delegation Framework enforcers - * from `@metamask/delegation-deployments`. + * Each call resolves the Money Account chain from remote feature flags, then + * resolves its price, payment token, and delegate from `SubscriptionController` + * pricing. The pricing `delegateAddress` is used as both the delegation + * `delegate` and the RedeemerEnforcer redeemer. * * Does not own subscription state; `SubscriptionController` does not depend on * this service. Only Money Account Plus is supported. @@ -145,6 +179,52 @@ export class SubscriptionDelegationService { ); } + /** + * Checks whether the Money Account holds enough convertible mUSD value to + * cover pricing `unitAmount × minBillingCyclesForBalance`. + * + * @param request - Payer address and pricing amount fields. + * @returns Balance comparison in mUSD base units (6 decimals). + */ + async checkMoneyAccountBalance( + request: MoneyAccountBalanceCheckRequest, + ): Promise { + const { price } = await this.#resolveConfiguration( + request.product, + request.recurringInterval, + ); + return this.#compareMoneyAccountBalance(request.payerAddress, price); + } + + async #compareMoneyAccountBalance( + payerAddress: Hex, + price: ProductPrice, + ): Promise { + assertPositiveInteger( + price.minBillingCyclesForBalance, + SubscriptionDelegationServiceErrorMessage.InvalidMinimumFundingCycles, + ); + + const periodAmount = calculatePeriodAmount({ + unitAmount: price.unitAmount, + unitDecimals: price.unitDecimals, + tokenDecimals: MUSD_DECIMALS, + }); + const requiredBalance = + periodAmount * BigInt(price.minBillingCyclesForBalance); + + const { totalBalance } = await this.#messenger.call( + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + payerAddress, + ); + + return { + hasSufficientBalance: BigInt(totalBalance) >= requiredBalance, + balance: totalBalance, + requiredBalance: requiredBalance.toString(), + }; + } + /** * Prepares a subscription-payment delegation and returns its verified hash. * @@ -164,13 +244,28 @@ export class SubscriptionDelegationService { ); } - const { chainId, delegateAddress, enforcers } = - await this.#resolveConfiguration(); + const { chainId, delegateAddress, enforcers, price, token } = + await this.#resolveConfiguration( + request.product, + request.recurringInterval, + ); + + if (request.checkBalance) { + const { hasSufficientBalance } = await this.#compareMoneyAccountBalance( + request.payerAddress, + price, + ); + if (!hasSufficientBalance) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.InsufficientBalance, + ); + } + } const periodAmount = calculatePeriodAmount({ - unitAmount: request.unitAmount, - unitDecimals: request.unitDecimals, - tokenDecimals: request.tokenDecimals, + unitAmount: price.unitAmount, + unitDecimals: price.unitDecimals, + tokenDecimals: token.decimals, }); const periodDuration = getPeriodDuration(request.recurringInterval); @@ -178,7 +273,7 @@ export class SubscriptionDelegationService { delegatorAddress: request.payerAddress, delegateAddress, chainId, - tokenAddress: request.tokenAddress, + tokenAddress: token.address, periodAmount, periodDuration, enforcers, @@ -203,12 +298,17 @@ export class SubscriptionDelegationService { }; } - const startDate = Math.floor(Date.now() / 1000); + const startDate = getDelegationStartDate({ + nowSeconds: Math.floor(Date.now() / 1000), + trialPeriodDays: request.isTrialRequested + ? price.trialPeriodDays + : undefined, + }); const unsigned = buildUnsignedSubscriptionDelegation({ delegateAddress, delegatorAddress: request.payerAddress, enforcers, - tokenAddress: request.tokenAddress, + tokenAddress: token.address, periodAmount, periodDuration, startDate, @@ -264,8 +364,8 @@ export class SubscriptionDelegationService { delegationHash, chainIdHex: chainId, allowance, - tokenSymbol: request.tokenSymbol, - tokenAddress: request.tokenAddress, + tokenSymbol: token.symbol, + tokenAddress: token.address, type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, }, }, @@ -276,8 +376,8 @@ export class SubscriptionDelegationService { chainId, delegationHash, allowance, - tokenSymbol: request.tokenSymbol, - tokenAddress: request.tokenAddress, + tokenSymbol: token.symbol, + tokenAddress: token.address, }); return { @@ -286,7 +386,10 @@ export class SubscriptionDelegationService { }; } - async #resolveConfiguration(): Promise { + async #resolveConfiguration( + product: ProductType, + recurringInterval: RecurringInterval, + ): Promise { const { remoteFeatureFlags } = this.#messenger.call( 'RemoteFeatureFlagController:getState', ); @@ -299,23 +402,34 @@ export class SubscriptionDelegationService { const { chainId } = vaultConfig; const enforcers = resolveEnforcers(chainId); - const { chains } = await this.#messenger.call( - 'ChompApiService:getServiceDetails', - [chainId], + const pricing = await this.#messenger.call( + 'SubscriptionController:getPricing', + ); + const price = pricing.products + .find((entry) => entry.name === product) + ?.prices.find((entry) => entry.interval === recurringInterval); + const paymentMethod = pricing.paymentMethods.find( + (entry): entry is PricingCryptoPaymentMethod => + entry.type === PAYMENT_TYPES.byCrypto && + entry.cryptoAuthMethod === CRYPTO_AUTH_METHODS.DELEGATION && + entry.products?.includes(product) === true, + ); + const chain = paymentMethod?.chains?.find( + (entry) => entry.chainId === chainId, ); - const chain = chains[chainId]; - if (!chain) { + const token = chain?.tokens[0]; + if (!price || !chain?.delegateAddress || !token) { throw new Error( - `${SubscriptionDelegationServiceErrorMessage.ChompChainNotFound}: ${chainId}`, + SubscriptionDelegationServiceErrorMessage.PricingConfigurationNotFound, ); } - // TODO(SUB-911/SUB-914): Use the subscription-payment delegate once CHOMP - // exposes one instead of reusing the Money Account auto-deposit delegate. return { chainId, - delegateAddress: chain.autoDepositDelegate, + delegateAddress: chain.delegateAddress, enforcers, + price, + token, }; } diff --git a/packages/subscription-controller/src/subscription-delegation/amount.test.ts b/packages/subscription-controller/src/subscription-delegation/amount.test.ts index 1400b63ab64..6f306a98b52 100644 --- a/packages/subscription-controller/src/subscription-delegation/amount.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/amount.test.ts @@ -1,6 +1,10 @@ import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; import { RECURRING_INTERVALS } from '../types.js'; -import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; +import { + calculatePeriodAmount, + getDelegationStartDate, + getPeriodDuration, +} from './amount.js'; describe('calculatePeriodAmount', () => { it('returns the same amount when decimals match', () => { @@ -91,3 +95,34 @@ describe('getPeriodDuration', () => { ); }); }); + +describe('getDelegationStartDate', () => { + const nowSeconds = 1_700_000_000; + + it('returns now when trialPeriodDays is omitted', () => { + expect(getDelegationStartDate({ nowSeconds })).toBe(nowSeconds); + }); + + it('returns now when trialPeriodDays is 0', () => { + expect(getDelegationStartDate({ nowSeconds, trialPeriodDays: 0 })).toBe( + nowSeconds, + ); + }); + + it('offsets now by the trial period in seconds', () => { + expect(getDelegationStartDate({ nowSeconds, trialPeriodDays: 14 })).toBe( + nowSeconds + 14 * 86_400, + ); + }); + + it.each([-1, 1.5])( + 'throws on invalid trialPeriodDays %#', + (trialPeriodDays) => { + expect(() => + getDelegationStartDate({ nowSeconds, trialPeriodDays }), + ).toThrow( + SubscriptionDelegationServiceErrorMessage.InvalidTrialPeriodDays, + ); + }, + ); +}); diff --git a/packages/subscription-controller/src/subscription-delegation/amount.ts b/packages/subscription-controller/src/subscription-delegation/amount.ts index 0e2a0096fb5..fab914a34df 100644 --- a/packages/subscription-controller/src/subscription-delegation/amount.ts +++ b/packages/subscription-controller/src/subscription-delegation/amount.ts @@ -79,6 +79,30 @@ export function getPeriodDuration( ); } +/** + * Computes the ERC20TokenPeriodTransfer `startDate` for a subscription + * delegation, optionally deferred by a pricing trial. + * + * @param params - Clock and optional trial length. + * @param params.nowSeconds - Current unix timestamp in seconds. + * @param params.trialPeriodDays - Optional non-negative trial length in days. + * Defaults to `0` (no offset) when omitted. + * @returns Unix timestamp when the first period transfer may begin. + */ +export function getDelegationStartDate({ + nowSeconds, + trialPeriodDays = 0, +}: { + nowSeconds: number; + trialPeriodDays?: number; +}): number { + assertNonNegativeInteger( + trialPeriodDays, + SubscriptionDelegationServiceErrorMessage.InvalidTrialPeriodDays, + ); + return nowSeconds + trialPeriodDays * SECONDS_PER_DAY; +} + /** * @param value - Candidate number. * @param message - Error message when invalid. @@ -88,3 +112,13 @@ function assertNonNegativeInteger(value: number, message: string): void { throw new Error(message); } } + +/** + * @param value - Candidate number. + * @param message - Error message when invalid. + */ +export function assertPositiveInteger(value: number, message: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(message); + } +} diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts index 6e095793429..1a0f5cad0ae 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts @@ -1,4 +1,5 @@ import { + createRedeemerTerms, decodeERC20TokenPeriodTransferTerms, decodeValueLteTerms, ROOT_AUTHORITY, @@ -12,29 +13,33 @@ import { const VALUE_LTE_ENFORCER = '0x1111111111111111111111111111111111111111' as Hex; const PERIOD_ENFORCER = '0x2222222222222222222222222222222222222222' as Hex; +const REDEEMER_ENFORCER = '0x6666666666666666666666666666666666666666' as Hex; const TOKEN_ADDRESS = '0x3333333333333333333333333333333333333333' as Hex; const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; const DELEGATOR = '0x5555555555555555555555555555555555555555' as Hex; +const ENFORCERS = { + valueLte: VALUE_LTE_ENFORCER, + erc20TokenPeriodTransfer: PERIOD_ENFORCER, + redeemer: REDEEMER_ENFORCER, +}; + describe('buildSubscriptionPaymentCaveats', () => { - it('builds ValueLte(0) then ERC20TokenPeriodTransfer caveats', () => { + it('builds ValueLte(0), ERC20TokenPeriodTransfer, then Redeemer caveats', () => { const caveats = buildSubscriptionPaymentCaveats({ - enforcers: { - valueLte: VALUE_LTE_ENFORCER, - erc20TokenPeriodTransfer: PERIOD_ENFORCER, - }, + enforcers: ENFORCERS, + delegateAddress: DELEGATE, tokenAddress: TOKEN_ADDRESS, periodAmount: 10n * 10n ** 18n, periodDuration: 28 * 86_400, startDate: 1_700_000_000, }); - expect(caveats).toHaveLength(2); - const [valueLteCaveat, periodCaveat] = caveats; - expect(valueLteCaveat).toBeDefined(); - expect(periodCaveat).toBeDefined(); + expect(caveats).toHaveLength(3); + const [valueLteCaveat, periodCaveat, redeemerCaveat] = caveats; expect(valueLteCaveat?.enforcer).toBe(VALUE_LTE_ENFORCER); expect(periodCaveat?.enforcer).toBe(PERIOD_ENFORCER); + expect(redeemerCaveat?.enforcer).toBe(REDEEMER_ENFORCER); expect(decodeValueLteTerms(valueLteCaveat?.terms ?? '0x')).toStrictEqual({ maxValue: 0n, }); @@ -46,6 +51,10 @@ describe('buildSubscriptionPaymentCaveats', () => { periodDuration: 28 * 86_400, startDate: 1_700_000_000, }); + expect(redeemerCaveat?.terms).toBe( + createRedeemerTerms({ redeemers: [DELEGATE] }), + ); + expect(redeemerCaveat?.args).toBe('0x'); }); }); @@ -56,10 +65,7 @@ describe('buildUnsignedSubscriptionDelegation', () => { const unsigned = buildUnsignedSubscriptionDelegation({ delegateAddress: DELEGATE, delegatorAddress: DELEGATOR, - enforcers: { - valueLte: VALUE_LTE_ENFORCER, - erc20TokenPeriodTransfer: PERIOD_ENFORCER, - }, + enforcers: ENFORCERS, tokenAddress: TOKEN_ADDRESS, periodAmount: 100n, periodDuration: 365 * 86_400, @@ -75,16 +81,19 @@ describe('buildUnsignedSubscriptionDelegation', () => { salt, }); expect(unsigned.salt).toMatch(/^0x[0-9a-fA-F]{64}$/u); + expect(unsigned.caveats).toHaveLength(3); + expect(unsigned.caveats[2]).toStrictEqual({ + enforcer: REDEEMER_ENFORCER, + terms: createRedeemerTerms({ redeemers: [DELEGATE] }), + args: '0x', + }); }); it('generates a random 32-byte salt when omitted', () => { const unsigned = buildUnsignedSubscriptionDelegation({ delegateAddress: DELEGATE, delegatorAddress: DELEGATOR, - enforcers: { - valueLte: VALUE_LTE_ENFORCER, - erc20TokenPeriodTransfer: PERIOD_ENFORCER, - }, + enforcers: ENFORCERS, tokenAddress: TOKEN_ADDRESS, periodAmount: 100n, periodDuration: 28 * 86_400, diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts index e772950a1fc..fc0281707b4 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -2,6 +2,7 @@ import type { SignedDelegation } from '@metamask/authenticated-user-storage'; import { ROOT_AUTHORITY, createERC20TokenPeriodTransferTerms, + createRedeemerTerms, createValueLteTerms, } from '@metamask/delegation-core'; import { bytesToHex } from '@metamask/utils'; @@ -16,6 +17,7 @@ export type UnsignedSubscriptionDelegation = Omit< export type BuildSubscriptionPaymentCaveatsParams = { enforcers: SubscriptionDelegationEnforcers; + delegateAddress: Hex; tokenAddress: Hex; periodAmount: bigint; periodDuration: number; @@ -24,17 +26,25 @@ export type BuildSubscriptionPaymentCaveatsParams = { /** * Builds the caveat list for a subscription-payment delegation: - * `ValueLte(0)` then `ERC20TokenPeriodTransfer(...)`. + * `ValueLte(0)`, `ERC20TokenPeriodTransfer(...)`, then `Redeemer(delegate)`. * - * @param params - Enforcer addresses and period terms. + * @param params - Enforcer addresses, parties, and period terms. + * @param params.enforcers - Delegation Framework enforcer addresses. + * @param params.delegateAddress - Sole permitted redeemer. + * @param params.tokenAddress - Subscription settlement token. + * @param params.periodAmount - Maximum token amount per period. + * @param params.periodDuration - Period length in seconds. + * @param params.startDate - Unix timestamp when transfers may begin. * @returns Caveats in enforcer order. */ -export function buildSubscriptionPaymentCaveats( - params: BuildSubscriptionPaymentCaveatsParams, -): SignedDelegation['caveats'] { - const { enforcers, tokenAddress, periodAmount, periodDuration, startDate } = - params; - +export function buildSubscriptionPaymentCaveats({ + enforcers, + delegateAddress, + tokenAddress, + periodAmount, + periodDuration, + startDate, +}: BuildSubscriptionPaymentCaveatsParams): SignedDelegation['caveats'] { return [ { enforcer: enforcers.valueLte, @@ -51,12 +61,16 @@ export function buildSubscriptionPaymentCaveats( }), args: '0x', }, + { + enforcer: enforcers.redeemer, + terms: createRedeemerTerms({ redeemers: [delegateAddress] }), + args: '0x', + }, ]; } export type BuildUnsignedSubscriptionDelegationParams = BuildSubscriptionPaymentCaveatsParams & { - delegateAddress: Hex; delegatorAddress: Hex; /** * Optional salt for tests. When omitted, a random 32-byte salt is generated. diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index bd539de6efc..0e918860529 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -1,6 +1,7 @@ import type { DelegationResponse } from '@metamask/authenticated-user-storage'; import { createERC20TokenPeriodTransferTerms, + createRedeemerTerms, createValueLteTerms, ROOT_AUTHORITY, } from '@metamask/delegation-core'; @@ -14,9 +15,11 @@ import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; const VALUE_LTE = '0x1111111111111111111111111111111111111111' as Hex; const PERIOD = '0x2222222222222222222222222222222222222222' as Hex; +const REDEEMER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; const DELEGATOR = '0x5555555555555555555555555555555555555555' as Hex; +const OTHER_REDEEMER = '0x6666666666666666666666666666666666666666' as Hex; const CHAIN_ID = '0x1' as Hex; const PERIOD_AMOUNT = 10n * 10n ** 18n; const PERIOD_DURATION = 28 * 86_400; @@ -32,6 +35,9 @@ function buildEntry({ startDate = 1_700_000_000, valueLteEnforcer = VALUE_LTE, periodEnforcer = PERIOD, + redeemerEnforcer = REDEEMER, + redeemerAddress = DELEGATE, + includeRedeemer = true, maxValue = 0n, }: { type?: string; @@ -44,30 +50,43 @@ function buildEntry({ startDate?: number; valueLteEnforcer?: Hex; periodEnforcer?: Hex; + redeemerEnforcer?: Hex; + redeemerAddress?: Hex; + includeRedeemer?: boolean; maxValue?: bigint; } = {}): DelegationResponse { + const caveats = [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue }), + args: '0x' as Hex, + }, + { + enforcer: periodEnforcer, + terms: createERC20TokenPeriodTransferTerms({ + tokenAddress, + periodAmount, + periodDuration, + startDate, + }), + args: '0x' as Hex, + }, + ]; + + if (includeRedeemer) { + caveats.push({ + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [redeemerAddress] }), + args: '0x' as Hex, + }); + } + return { signedDelegation: { delegate, delegator, authority: ROOT_AUTHORITY, - caveats: [ - { - enforcer: valueLteEnforcer, - terms: createValueLteTerms({ maxValue }), - args: '0x', - }, - { - enforcer: periodEnforcer, - terms: createERC20TokenPeriodTransferTerms({ - tokenAddress, - periodAmount, - periodDuration, - startDate, - }), - args: '0x', - }, - ], + caveats, salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', signature: `0x${'bb'.repeat(65)}`, }, @@ -92,6 +111,7 @@ const expected = { enforcers: { valueLte: VALUE_LTE, erc20TokenPeriodTransfer: PERIOD, + redeemer: REDEEMER, }, }; @@ -125,6 +145,7 @@ describe('makeMatchesSubscriptionDelegation', () => { delegate: upper(DELEGATE), tokenAddress: upper(TOKEN), chainIdHex: upper(CHAIN_ID), + redeemerAddress: upper(DELEGATE), }), ), ).toBe(true); @@ -157,6 +178,13 @@ describe('makeMatchesSubscriptionDelegation', () => { periodEnforcer: '0x6666666666666666666666666666666666666666' as Hex, }, ], + [ + 'redeemerEnforcer', + { + redeemerEnforcer: '0x6666666666666666666666666666666666666666' as Hex, + }, + ], + ['redeemerAddress', { redeemerAddress: OTHER_REDEEMER }], ['periodAmount', { periodAmount: PERIOD_AMOUNT + 1n }], ['periodDuration', { periodDuration: PERIOD_DURATION + 1 }], ['maxValue', { maxValue: 1n }], @@ -171,6 +199,10 @@ describe('makeMatchesSubscriptionDelegation', () => { expect(matches(entry)).toBe(false); }); + it('rejects a delegation missing the redeemer caveat', () => { + expect(matches(buildEntry({ includeRedeemer: false }))).toBe(false); + }); + it('rejects a delegation with malformed caveat terms', () => { const entry = buildEntry(); entry.signedDelegation.caveats[0].terms = '0x'; diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts index b14d821cb63..e7108b311b8 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -1,5 +1,6 @@ import type { DelegationResponse } from '@metamask/authenticated-user-storage'; import { + createRedeemerTerms, decodeERC20TokenPeriodTransferTerms, decodeValueLteTerms, } from '@metamask/delegation-core'; @@ -40,6 +41,10 @@ export function equalsIgnoreCase(left: string, right: string): boolean { export function makeMatchesSubscriptionDelegation( expected: SubscriptionDelegationFingerprint, ): (entry: DelegationResponse) => boolean { + const expectedRedeemerTerms = createRedeemerTerms({ + redeemers: [expected.delegateAddress], + }); + return (entry) => { if (entry.metadata.type !== SUBSCRIPTION_PAYMENT_DELEGATION_TYPE) { return false; @@ -68,7 +73,7 @@ export function makeMatchesSubscriptionDelegation( } const { caveats } = entry.signedDelegation; - if (caveats.length < 2) { + if (caveats.length < 3) { return false; } @@ -81,7 +86,13 @@ export function makeMatchesSubscriptionDelegation( expected.enforcers.erc20TokenPeriodTransfer, ), ); - if (!valueLteCaveat || !periodCaveat) { + const redeemerCaveat = caveats.find((caveat) => + equalsIgnoreCase(caveat.enforcer, expected.enforcers.redeemer), + ); + if (!valueLteCaveat || !periodCaveat || !redeemerCaveat) { + return false; + } + if (!equalsIgnoreCase(redeemerCaveat.terms, expectedRedeemerTerms)) { return false; } diff --git a/packages/subscription-controller/src/subscription-delegation/types.ts b/packages/subscription-controller/src/subscription-delegation/types.ts index 107f2d1533e..f4c250771eb 100644 --- a/packages/subscription-controller/src/subscription-delegation/types.ts +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -11,9 +11,8 @@ export const SUBSCRIPTION_PAYMENT_DELEGATION_TYPE = 'subscription-payment'; /** * Request to prepare a subscription-payment delegation. * - * Pricing fields (`unitAmount`, `unitDecimals`, token details, - * `minimumFundingCycles`) must come from authoritative subscription pricing — - * never from editable UI input. + * The service resolves all amount, token, delegate, and trial-duration fields + * from authoritative pricing held by `SubscriptionController`. * * Only Money Account Plus is supported; Shield continues to use ERC-20 * approval rather than delegation. @@ -22,12 +21,16 @@ export type PrepareSubscriptionDelegationRequest = { product: typeof PRODUCT_TYPES.MONEY_ACCOUNT_PLUS; recurringInterval: RecurringInterval; payerAddress: Hex; - tokenAddress: Hex; - tokenSymbol: string; - tokenDecimals: number; - unitAmount: number; - unitDecimals: number; - minimumFundingCycles: number; + /** + * Whether the user selected the pricing trial. Pricing `trialPeriodDays` + * only affects the delegation start date when this is true. + */ + isTrialRequested: boolean; + /** + * When true, gates preparation on a sufficient Money Account balance + * (`unitAmount × minBillingCyclesForBalance` in mUSD) before side effects. + */ + checkBalance?: boolean; }; /** @@ -38,10 +41,30 @@ export type PreparedSubscriptionDelegation = { disposition: 'created' | 'reused'; }; +/** + * Request to check whether a Money Account holds enough convertible mUSD value + * to cover the subscription funding requirement. + */ +export type MoneyAccountBalanceCheckRequest = Pick< + PrepareSubscriptionDelegationRequest, + 'product' | 'recurringInterval' | 'payerAddress' +>; + +/** + * Result of {@link SubscriptionDelegationService.checkMoneyAccountBalance}. + * `balance` and `requiredBalance` are mUSD base units (6 decimals). + */ +export type MoneyAccountBalanceCheckResult = { + hasSufficientBalance: boolean; + balance: string; + requiredBalance: string; +}; + /** * Delegation Framework enforcers used by subscription-payment delegations. */ export type SubscriptionDelegationEnforcers = { valueLte: Hex; erc20TokenPeriodTransfer: Hex; + redeemer: Hex; }; diff --git a/packages/subscription-controller/tsconfig.build.json b/packages/subscription-controller/tsconfig.build.json index cea334f10b5..68a7504b551 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -27,6 +27,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../money-account-balance-service/tsconfig.build.json" + }, { "path": "../money-account-utils/tsconfig.build.json" }, diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 0ba73e375e8..a706730efed 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -25,6 +25,9 @@ { "path": "../messenger" }, + { + "path": "../money-account-balance-service" + }, { "path": "../money-account-utils" }, diff --git a/yarn.lock b/yarn.lock index ab1d504dd57..0f43c2e3cf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7728,7 +7728,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-balance-service@workspace:packages/money-account-balance-service": +"@metamask/money-account-balance-service@npm:^2.4.3, @metamask/money-account-balance-service@workspace:packages/money-account-balance-service": version: 0.0.0-use.local resolution: "@metamask/money-account-balance-service@workspace:packages/money-account-balance-service" dependencies: @@ -9128,6 +9128,7 @@ __metadata: "@metamask/delegation-core": "npm:^2.2.1" "@metamask/delegation-deployments": "npm:^1.4.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/money-account-balance-service": "npm:^2.4.3" "@metamask/money-account-utils": "npm:^1.2.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/profile-sync-controller": "npm:^29.0.0" From 0d1c3df43a1ca3c58c93a86cc3e2eb5ff05a21d7 Mon Sep 17 00:00:00 2001 From: Tuna Date: Wed, 9 Sep 2026 21:52:10 +0700 Subject: [PATCH 09/23] feat: update CHOMP intent types and integrate new cash-subscription metadata into SubscriptionDelegationService --- packages/chomp-api-service/CHANGELOG.md | 6 +++--- .../src/chomp-api-service.test.ts | 9 +++++---- .../chomp-api-service/src/chomp-api-service.ts | 7 ++----- packages/chomp-api-service/src/index.ts | 1 + packages/chomp-api-service/src/types.ts | 14 ++++++++++---- packages/subscription-controller/CHANGELOG.md | 3 ++- packages/subscription-controller/src/index.ts | 2 +- ...ionDelegationService-method-action-types.ts | 2 +- .../SubscriptionDelegationService.test.ts | 13 +++++-------- .../SubscriptionDelegationService.ts | 18 +++++++----------- .../subscription-delegation/caveats.test.ts | 6 +++--- .../src/subscription-delegation/caveats.ts | 14 +++++++------- .../fingerprint.test.ts | 4 ++-- .../src/subscription-delegation/fingerprint.ts | 6 +++--- .../src/subscription-delegation/types.ts | 10 ++++++---- packages/wallet/CHANGELOG.md | 6 +++--- .../subscription-delegation-service.test.ts | 3 ++- .../subscription-delegation-service.ts | 3 ++- 18 files changed, 65 insertions(+), 62 deletions(-) diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index 83a847fe642..7e4326fa956 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Accept `'subscription-payment'` as a CHOMP intent / delegation metadata type alongside `'cash-deposit'` and `'cash-withdrawal'`. ([#10130](https://github.com/MetaMask/core/pull/10130)) - - Adds exported `ChompIntentType` alias covering all three values. - - Response structs for create-intents, get-intents-by-address, and service-details now accept the new type. +- Accept `'cash-subscription'`, `'cash-deposit-premium'`, and `'cash-withdrawal-premium'` as CHOMP intent / delegation metadata types alongside `'cash-deposit'` and `'cash-withdrawal'`. ([#10130](https://github.com/MetaMask/core/pull/10130)) + - Adds exported `CHOMP_INTENT_TYPES` const object and `ChompIntentType` covering all five values. + - Response structs for create-intents, get-intents-by-address, and service-details now accept the new types. ## [4.0.2] diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts index bbe8b4a3207..ded4215aeb8 100644 --- a/packages/chomp-api-service/src/chomp-api-service.test.ts +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -9,6 +9,7 @@ import nock from 'nock'; import type { ChompApiServiceMessenger } from './chomp-api-service.js'; import { ChompApiService } from './chomp-api-service.js'; +import { CHOMP_INTENT_TYPES } from './types.js'; const BASE_URL = 'https://api.chomp.example.com'; const MOCK_TOKEN = 'mock-jwt-token'; @@ -521,7 +522,7 @@ describe('ChompApiService', () => { ); }); - it('accepts subscription-payment intent metadata type', async () => { + it('accepts cash-subscription intent metadata type', async () => { const subscriptionIntentParams = [ { account: '0xabc' as const, @@ -531,7 +532,7 @@ describe('ChompApiService', () => { allowance: '0xff' as const, tokenSymbol: 'pvmUSD', tokenAddress: '0x123' as const, - type: 'subscription-payment' as const, + type: CHOMP_INTENT_TYPES.CASH_SUBSCRIPTION, }, }, ]; @@ -542,7 +543,7 @@ describe('ChompApiService', () => { allowance: '0xff', tokenSymbol: 'pvmUSD', tokenAddress: '0x123', - type: 'subscription-payment', + type: CHOMP_INTENT_TYPES.CASH_SUBSCRIPTION, }, createdAt: '2026-01-01T00:00:00Z', }, @@ -687,7 +688,7 @@ describe('ChompApiService', () => { }, ], adapterAddress: '0x4839b1BA117BdFFA986FCfA4E5fE6b9027b8f8B1', - intentTypes: ['cash-deposit', 'cash-withdrawal'], + intentTypes: Object.values(CHOMP_INTENT_TYPES), }, }, }, diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index 2cfc198e5db..cb69deb2e2f 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -46,6 +46,7 @@ import type { VerifyDelegationParams, VerifyDelegationResponse, } from './types.js'; +import { CHOMP_INTENT_TYPES } from './types.js'; // === GENERAL === @@ -193,11 +194,7 @@ const VerifyDelegationResponseStruct = type({ errors: optional(array(string())), }); -const ChompIntentTypeStruct = enums([ - 'cash-deposit', - 'cash-withdrawal', - 'subscription-payment', -]); +const ChompIntentTypeStruct = enums(Object.values(CHOMP_INTENT_TYPES)); const SendIntentResponseArrayStruct = array( type({ diff --git a/packages/chomp-api-service/src/index.ts b/packages/chomp-api-service/src/index.ts index 407511bb6a3..d7144f27cdb 100644 --- a/packages/chomp-api-service/src/index.ts +++ b/packages/chomp-api-service/src/index.ts @@ -44,3 +44,4 @@ export type { VerifyDelegationParams, VerifyDelegationResponse, } from './types.js'; +export { CHOMP_INTENT_TYPES } from './types.js'; diff --git a/packages/chomp-api-service/src/types.ts b/packages/chomp-api-service/src/types.ts index 89c2f60b259..083331af52d 100644 --- a/packages/chomp-api-service/src/types.ts +++ b/packages/chomp-api-service/src/types.ts @@ -41,12 +41,18 @@ export type VerifyDelegationParams = { }; /** - * CHOMP intent / delegation metadata type discriminator. + * CHOMP intent / delegation metadata type discriminators. */ +export const CHOMP_INTENT_TYPES = { + CASH_DEPOSIT: 'cash-deposit', + CASH_WITHDRAWAL: 'cash-withdrawal', + CASH_SUBSCRIPTION: 'cash-subscription', + CASH_DEPOSIT_PREMIUM: 'cash-deposit-premium', + CASH_WITHDRAWAL_PREMIUM: 'cash-withdrawal-premium', +} as const; + export type ChompIntentType = - | 'cash-deposit' - | 'cash-withdrawal' - | 'subscription-payment'; + (typeof CHOMP_INTENT_TYPES)[keyof typeof CHOMP_INTENT_TYPES]; export type IntentMetadataParams = { allowance: Hex; diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 28e9e56cd2b..0911d555e5c 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `SubscriptionDelegationService` for Money Account Plus subscription-payment delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) +- Add `SubscriptionDelegationService` for Money Account Plus cash-subscription delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, and optional balance-check flag; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Uses pricing `delegateAddress` as both the delegation `delegate` and the RedeemerEnforcer redeemer. - Offsets the period-transfer `startDate` by pricing `trialPeriodDays` only when the trial is selected. - New messenger action `SubscriptionDelegationService:checkMoneyAccountBalance` compares Money Account convertible mUSD balance against pricing `unitAmount × minBillingCyclesForBalance`; `prepareDelegation` can gate on it via `checkBalance`. + - Exports `CASH_SUBSCRIPTION_DELEGATION_TYPE` (`'cash-subscription'`) for AUS and CHOMP intent metadata. - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. - Add `selectIsActiveSubscriber` to check whether a product has an active, trialing, or provisional subscription. ([#10017](https://github.com/MetaMask/core/pull/10017)) - Add product-scoped entitlements to `SubscriptionController` state and export type-safe `selectHasEntitlement` and `selectIsUsageAvailable` selectors for Money Account Plus and Shield ([#10017](https://github.com/MetaMask/core/pull/10017)) diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index d96efaf5e0d..ae496ea0d7a 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -180,4 +180,4 @@ export type { PrepareSubscriptionDelegationRequest, PreparedSubscriptionDelegation, } from './subscription-delegation/types.js'; -export { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './subscription-delegation/types.js'; +export { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './subscription-delegation/types.js'; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index 7aefe8bb579..d49099de8d9 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -18,7 +18,7 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { }; /** - * Prepares a subscription-payment delegation and returns its verified hash. + * Prepares a cash-subscription delegation and returns its verified hash. * * Reuses a stored AUS delegation that matches the semantic fingerprint when * one exists (ensuring a CHOMP intent is active for its hash). Otherwise diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index ae10ec66076..1980dcef01a 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -18,10 +18,7 @@ import { PRODUCT_TYPES, RECURRING_INTERVALS, } from '../types.js'; -import type { - PricingCryptoPaymentMethod, - PricingResponse, -} from '../types.js'; +import type { PricingCryptoPaymentMethod, PricingResponse } from '../types.js'; import { calculatePeriodAmount, getPeriodDuration } from './amount.js'; import { SubscriptionDelegationService, @@ -29,7 +26,7 @@ import { } from './SubscriptionDelegationService.js'; import type { SubscriptionDelegationServiceMessenger } from './SubscriptionDelegationService.js'; import type { PrepareSubscriptionDelegationRequest } from './types.js'; -import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; +import { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './types.js'; const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; @@ -339,7 +336,7 @@ function buildStoredDelegation({ allowance: `0x${periodAmount.toString(16)}`, tokenSymbol: 'pvmUSD', tokenAddress: TOKEN, - type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, }, }; } @@ -410,7 +407,7 @@ describe('SubscriptionDelegationService', () => { allowance: `0x${PERIOD_AMOUNT.toString(16)}`, tokenSymbol: 'pvmUSD', tokenAddress: TOKEN, - type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, }), }); expect(mocks.createIntents).toHaveBeenCalledWith([ @@ -422,7 +419,7 @@ describe('SubscriptionDelegationService', () => { allowance: `0x${PERIOD_AMOUNT.toString(16)}`, tokenSymbol: 'pvmUSD', tokenAddress: TOKEN, - type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, }, }, ]); diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index 321dc418672..d6200b9029a 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -20,13 +20,9 @@ import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import { add0x, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { SubscriptionControllerGetPricingAction } from '../SubscriptionController-method-action-types.js'; import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; -import { - CRYPTO_AUTH_METHODS, - PAYMENT_TYPES, - PRODUCT_TYPES, -} from '../types.js'; +import type { SubscriptionControllerGetPricingAction } from '../SubscriptionController-method-action-types.js'; +import { CRYPTO_AUTH_METHODS, PAYMENT_TYPES, PRODUCT_TYPES } from '../types.js'; import type { ProductPrice, ProductType, @@ -53,7 +49,7 @@ import type { PreparedSubscriptionDelegation, SubscriptionDelegationEnforcers, } from './types.js'; -import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; +import { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './types.js'; /** * The name of the {@link SubscriptionDelegationService}, used to namespace the @@ -151,7 +147,7 @@ type ResolvedSubscriptionDelegationConfig = { }; /** - * Stateless orchestrator for subscription-payment delegation setup. + * Stateless orchestrator for cash-subscription delegation setup. * * Owns the workflow: size periodic caveats → sign → CHOMP verify → persist to * Authenticated User Storage → register CHOMP intent. Returns a verified @@ -226,7 +222,7 @@ export class SubscriptionDelegationService { } /** - * Prepares a subscription-payment delegation and returns its verified hash. + * Prepares a cash-subscription delegation and returns its verified hash. * * Reuses a stored AUS delegation that matches the semantic fingerprint when * one exists (ensuring a CHOMP intent is active for its hash). Otherwise @@ -366,7 +362,7 @@ export class SubscriptionDelegationService { allowance, tokenSymbol: token.symbol, tokenAddress: token.address, - type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, }, }, ); @@ -474,7 +470,7 @@ export class SubscriptionDelegationService { allowance: params.allowance, tokenSymbol: params.tokenSymbol, tokenAddress: params.tokenAddress, - type: SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, }, }, ]); diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts index 1a0f5cad0ae..dbef0c4787d 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts @@ -7,7 +7,7 @@ import { import type { Hex } from '@metamask/utils'; import { - buildSubscriptionPaymentCaveats, + buildSubscriptionCaveats, buildUnsignedSubscriptionDelegation, } from './caveats.js'; @@ -24,9 +24,9 @@ const ENFORCERS = { redeemer: REDEEMER_ENFORCER, }; -describe('buildSubscriptionPaymentCaveats', () => { +describe('buildSubscriptionCaveats', () => { it('builds ValueLte(0), ERC20TokenPeriodTransfer, then Redeemer caveats', () => { - const caveats = buildSubscriptionPaymentCaveats({ + const caveats = buildSubscriptionCaveats({ enforcers: ENFORCERS, delegateAddress: DELEGATE, tokenAddress: TOKEN_ADDRESS, diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts index fc0281707b4..634284befd0 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -15,7 +15,7 @@ export type UnsignedSubscriptionDelegation = Omit< 'signature' >; -export type BuildSubscriptionPaymentCaveatsParams = { +export type BuildSubscriptionCaveatsParams = { enforcers: SubscriptionDelegationEnforcers; delegateAddress: Hex; tokenAddress: Hex; @@ -25,7 +25,7 @@ export type BuildSubscriptionPaymentCaveatsParams = { }; /** - * Builds the caveat list for a subscription-payment delegation: + * Builds the caveat list for a cash-subscription delegation: * `ValueLte(0)`, `ERC20TokenPeriodTransfer(...)`, then `Redeemer(delegate)`. * * @param params - Enforcer addresses, parties, and period terms. @@ -37,14 +37,14 @@ export type BuildSubscriptionPaymentCaveatsParams = { * @param params.startDate - Unix timestamp when transfers may begin. * @returns Caveats in enforcer order. */ -export function buildSubscriptionPaymentCaveats({ +export function buildSubscriptionCaveats({ enforcers, delegateAddress, tokenAddress, periodAmount, periodDuration, startDate, -}: BuildSubscriptionPaymentCaveatsParams): SignedDelegation['caveats'] { +}: BuildSubscriptionCaveatsParams): SignedDelegation['caveats'] { return [ { enforcer: enforcers.valueLte, @@ -70,7 +70,7 @@ export function buildSubscriptionPaymentCaveats({ } export type BuildUnsignedSubscriptionDelegationParams = - BuildSubscriptionPaymentCaveatsParams & { + BuildSubscriptionCaveatsParams & { delegatorAddress: Hex; /** * Optional salt for tests. When omitted, a random 32-byte salt is generated. @@ -79,7 +79,7 @@ export type BuildUnsignedSubscriptionDelegationParams = }; /** - * Builds an unsigned root subscription-payment delegation. + * Builds an unsigned root cash-subscription delegation. * * @param params - Delegation parties, enforcers, and period terms. * @returns An unsigned delegation ready for signing. @@ -95,7 +95,7 @@ export function buildUnsignedSubscriptionDelegation( delegate: params.delegateAddress, delegator: params.delegatorAddress, authority: ROOT_AUTHORITY, - caveats: buildSubscriptionPaymentCaveats(params), + caveats: buildSubscriptionCaveats(params), salt, }; } diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index 0e918860529..6956427310a 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -11,7 +11,7 @@ import { equalsIgnoreCase, makeMatchesSubscriptionDelegation, } from './fingerprint.js'; -import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; +import { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './types.js'; const VALUE_LTE = '0x1111111111111111111111111111111111111111' as Hex; const PERIOD = '0x2222222222222222222222222222222222222222' as Hex; @@ -25,7 +25,7 @@ const PERIOD_AMOUNT = 10n * 10n ** 18n; const PERIOD_DURATION = 28 * 86_400; function buildEntry({ - type = SUBSCRIPTION_PAYMENT_DELEGATION_TYPE, + type = CASH_SUBSCRIPTION_DELEGATION_TYPE, delegator = DELEGATOR, delegate = DELEGATE, chainIdHex = CHAIN_ID, diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts index e7108b311b8..6dc3a664edd 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -7,7 +7,7 @@ import { import type { Hex } from '@metamask/utils'; import type { SubscriptionDelegationEnforcers } from './types.js'; -import { SUBSCRIPTION_PAYMENT_DELEGATION_TYPE } from './types.js'; +import { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './types.js'; export type SubscriptionDelegationFingerprint = { delegatorAddress: Hex; @@ -32,7 +32,7 @@ export function equalsIgnoreCase(left: string, right: string): boolean { /** * Builds a predicate that matches a stored AUS delegation to the semantic - * subscription-payment fingerprint. Salt and period `startDate` are ignored so + * cash-subscription fingerprint. Salt and period `startDate` are ignored so * a previously signed equivalent permission can be reused. * * @param expected - Semantic fields that must match. @@ -46,7 +46,7 @@ export function makeMatchesSubscriptionDelegation( }); return (entry) => { - if (entry.metadata.type !== SUBSCRIPTION_PAYMENT_DELEGATION_TYPE) { + if (entry.metadata.type !== CASH_SUBSCRIPTION_DELEGATION_TYPE) { return false; } if ( diff --git a/packages/subscription-controller/src/subscription-delegation/types.ts b/packages/subscription-controller/src/subscription-delegation/types.ts index f4c250771eb..e79dfce5d99 100644 --- a/packages/subscription-controller/src/subscription-delegation/types.ts +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -1,15 +1,17 @@ +import { CHOMP_INTENT_TYPES } from '@metamask/chomp-api-service'; import type { Hex } from '@metamask/utils'; import { PRODUCT_TYPES } from '../types.js'; import type { RecurringInterval } from '../types.js'; /** - * Storage / CHOMP metadata type for subscription-payment delegations. + * Storage / CHOMP metadata type for cash-subscription delegations. */ -export const SUBSCRIPTION_PAYMENT_DELEGATION_TYPE = 'subscription-payment'; +export const CASH_SUBSCRIPTION_DELEGATION_TYPE = + CHOMP_INTENT_TYPES.CASH_SUBSCRIPTION; /** - * Request to prepare a subscription-payment delegation. + * Request to prepare a cash-subscription delegation. * * The service resolves all amount, token, delegate, and trial-duration fields * from authoritative pricing held by `SubscriptionController`. @@ -61,7 +63,7 @@ export type MoneyAccountBalanceCheckResult = { }; /** - * Delegation Framework enforcers used by subscription-payment delegations. + * Delegation Framework enforcers used by cash-subscription delegations. */ export type SubscriptionDelegationEnforcers = { valueLte: Hex; diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index c0480730d9b..1a4297f4758 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -10,9 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Wire `SubscriptionDelegationService` into the default wallet initialization. ([#10130](https://github.com/MetaMask/core/pull/10130)) - - Stateless orchestrator for Money Account Plus subscription-payment delegation setup via `SubscriptionDelegationService:prepareDelegation`. - - Delegates `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:createIntents`, `ChompApiService:getIntentsByAddress`, `ChompApiService:getServiceDetails`, `DelegationController:signDelegation`, and `RemoteFeatureFlagController:getState` from the wallet root messenger. - - Hosts must register `AuthenticatedUserStorageService`, `ChompApiService`, and `DelegationController` on the supplied root messenger before calling `prepareDelegation`; `RemoteFeatureFlagController` is already initialized by default. + - Stateless orchestrator for Money Account Plus cash-subscription delegation setup via `SubscriptionDelegationService:prepareDelegation`. + - Delegates `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:createIntents`, `ChompApiService:getIntentsByAddress`, `DelegationController:signDelegation`, `MoneyAccountBalanceService:fetchBalanceWithFallback`, `RemoteFeatureFlagController:getState`, and `SubscriptionController:getPricing` from the wallet root messenger. + - Hosts must register `AuthenticatedUserStorageService`, `ChompApiService`, `DelegationController`, `MoneyAccountBalanceService`, and `SubscriptionController` on the supplied root messenger before calling `prepareDelegation`; `RemoteFeatureFlagController` is already initialized by default. ### Changed diff --git a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts index 03358f284b0..7a7379d8a98 100644 --- a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts +++ b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.test.ts @@ -52,9 +52,10 @@ describe('subscriptionDelegationService', () => { 'ChompApiService:verifyDelegation', 'ChompApiService:createIntents', 'ChompApiService:getIntentsByAddress', - 'ChompApiService:getServiceDetails', 'DelegationController:signDelegation', + 'MoneyAccountBalanceService:fetchBalanceWithFallback', 'RemoteFeatureFlagController:getState', + 'SubscriptionController:getPricing', ], }); }); diff --git a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts index 5db223e8152..08de20520cb 100644 --- a/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts +++ b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts @@ -27,9 +27,10 @@ export const subscriptionDelegationService: InitializationConfiguration< 'ChompApiService:verifyDelegation', 'ChompApiService:createIntents', 'ChompApiService:getIntentsByAddress', - 'ChompApiService:getServiceDetails', 'DelegationController:signDelegation', + 'MoneyAccountBalanceService:fetchBalanceWithFallback', 'RemoteFeatureFlagController:getState', + 'SubscriptionController:getPricing', ], }); From b2f28ea70fac0058e3cedc2958bdce95778dfda2 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 14:35:23 +0700 Subject: [PATCH 10/23] fix: correct error message --- packages/subscription-controller/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index f69aae97dfc..3f9a978383d 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -76,7 +76,7 @@ export enum SubscriptionDelegationServiceErrorMessage { InvalidMinimumFundingCycles = 'Subscription delegation minimum funding cycles must be a positive integer', LossyAmountScale = 'Subscription delegation amount cannot be scaled to token decimals without remainder', UnsupportedRecurringInterval = 'Unsupported subscription recurring interval', - UnsupportedProduct = 'Subscription delegation is only supported for Money Account Plus', + UnsupportedProduct = 'Subscription delegation is only supported for Money Account', MissingMoneyAccountVaultConfig = 'Money Account vault configuration is missing or invalid', DelegationContractsNotFound = 'Subscription delegation contracts were not found for the configured chain', PricingConfigurationNotFound = 'Subscription delegation pricing configuration was not found', From 27aee6828f49c216d7c8505000bf4766c1646265 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 14:35:41 +0700 Subject: [PATCH 11/23] fix: temporarily remove redeemer enforcer --- .../src/subscription-delegation/caveats.ts | 14 +++++------ .../subscription-delegation/fingerprint.ts | 25 ++++++++++--------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts index 634284befd0..370132cb7ea 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -2,7 +2,6 @@ import type { SignedDelegation } from '@metamask/authenticated-user-storage'; import { ROOT_AUTHORITY, createERC20TokenPeriodTransferTerms, - createRedeemerTerms, createValueLteTerms, } from '@metamask/delegation-core'; import { bytesToHex } from '@metamask/utils'; @@ -39,7 +38,6 @@ export type BuildSubscriptionCaveatsParams = { */ export function buildSubscriptionCaveats({ enforcers, - delegateAddress, tokenAddress, periodAmount, periodDuration, @@ -61,11 +59,13 @@ export function buildSubscriptionCaveats({ }), args: '0x', }, - { - enforcer: enforcers.redeemer, - terms: createRedeemerTerms({ redeemers: [delegateAddress] }), - args: '0x', - }, + // TODO: recheck with CHOMP team if we should set redeemer to subscirption payment address + // or use allowed call data + // { + // enforcer: enforcers.redeemer, + // terms: createRedeemerTerms({ redeemers: [delegateAddress] }), + // args: '0x', + // }, ]; } diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts index 6dc3a664edd..ff7f1c9a811 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -1,6 +1,5 @@ import type { DelegationResponse } from '@metamask/authenticated-user-storage'; import { - createRedeemerTerms, decodeERC20TokenPeriodTransferTerms, decodeValueLteTerms, } from '@metamask/delegation-core'; @@ -41,9 +40,9 @@ export function equalsIgnoreCase(left: string, right: string): boolean { export function makeMatchesSubscriptionDelegation( expected: SubscriptionDelegationFingerprint, ): (entry: DelegationResponse) => boolean { - const expectedRedeemerTerms = createRedeemerTerms({ - redeemers: [expected.delegateAddress], - }); + // const expectedRedeemerTerms = createRedeemerTerms({ + // redeemers: [expected.delegateAddress], + // }); return (entry) => { if (entry.metadata.type !== CASH_SUBSCRIPTION_DELEGATION_TYPE) { @@ -73,7 +72,7 @@ export function makeMatchesSubscriptionDelegation( } const { caveats } = entry.signedDelegation; - if (caveats.length < 3) { + if (caveats.length < 2) { return false; } @@ -86,15 +85,17 @@ export function makeMatchesSubscriptionDelegation( expected.enforcers.erc20TokenPeriodTransfer, ), ); - const redeemerCaveat = caveats.find((caveat) => - equalsIgnoreCase(caveat.enforcer, expected.enforcers.redeemer), - ); - if (!valueLteCaveat || !periodCaveat || !redeemerCaveat) { - return false; - } - if (!equalsIgnoreCase(redeemerCaveat.terms, expectedRedeemerTerms)) { + // TODO: recheck with CHOMP team if we should set redeemer to subscirption payment address + // or use allowed call data + // const redeemerCaveat = caveats.find((caveat) => + // equalsIgnoreCase(caveat.enforcer, expected.enforcers.redeemer), + // ); + if (!valueLteCaveat || !periodCaveat) { return false; } + // if (!equalsIgnoreCase(redeemerCaveat.terms, expectedRedeemerTerms)) { + // return false; + // } try { const valueTerms = decodeValueLteTerms(valueLteCaveat.terms); From 07b43939892d2ca8498beb5c9cf1da00845f6f22 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 14:53:36 +0700 Subject: [PATCH 12/23] fix: remove redeemer enforcer --- .../subscription-delegation/caveats.test.ts | 21 ++++++------------- .../src/subscription-delegation/caveats.ts | 5 +++-- .../fingerprint.test.ts | 14 +++---------- 3 files changed, 12 insertions(+), 28 deletions(-) diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts index dbef0c4787d..c91783a7163 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.test.ts @@ -1,5 +1,4 @@ import { - createRedeemerTerms, decodeERC20TokenPeriodTransferTerms, decodeValueLteTerms, ROOT_AUTHORITY, @@ -25,7 +24,7 @@ const ENFORCERS = { }; describe('buildSubscriptionCaveats', () => { - it('builds ValueLte(0), ERC20TokenPeriodTransfer, then Redeemer caveats', () => { + it('builds ValueLte(0) and ERC20TokenPeriodTransfer caveats', () => { const caveats = buildSubscriptionCaveats({ enforcers: ENFORCERS, delegateAddress: DELEGATE, @@ -35,11 +34,10 @@ describe('buildSubscriptionCaveats', () => { startDate: 1_700_000_000, }); - expect(caveats).toHaveLength(3); - const [valueLteCaveat, periodCaveat, redeemerCaveat] = caveats; + expect(caveats).toHaveLength(2); + const [valueLteCaveat, periodCaveat] = caveats; expect(valueLteCaveat?.enforcer).toBe(VALUE_LTE_ENFORCER); expect(periodCaveat?.enforcer).toBe(PERIOD_ENFORCER); - expect(redeemerCaveat?.enforcer).toBe(REDEEMER_ENFORCER); expect(decodeValueLteTerms(valueLteCaveat?.terms ?? '0x')).toStrictEqual({ maxValue: 0n, }); @@ -51,10 +49,6 @@ describe('buildSubscriptionCaveats', () => { periodDuration: 28 * 86_400, startDate: 1_700_000_000, }); - expect(redeemerCaveat?.terms).toBe( - createRedeemerTerms({ redeemers: [DELEGATE] }), - ); - expect(redeemerCaveat?.args).toBe('0x'); }); }); @@ -81,12 +75,9 @@ describe('buildUnsignedSubscriptionDelegation', () => { salt, }); expect(unsigned.salt).toMatch(/^0x[0-9a-fA-F]{64}$/u); - expect(unsigned.caveats).toHaveLength(3); - expect(unsigned.caveats[2]).toStrictEqual({ - enforcer: REDEEMER_ENFORCER, - terms: createRedeemerTerms({ redeemers: [DELEGATE] }), - args: '0x', - }); + expect(unsigned.caveats).toHaveLength(2); + expect(unsigned.caveats[0]?.enforcer).toBe(VALUE_LTE_ENFORCER); + expect(unsigned.caveats[1]?.enforcer).toBe(PERIOD_ENFORCER); }); it('generates a random 32-byte salt when omitted', () => { diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts index 370132cb7ea..76172f27926 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -25,7 +25,8 @@ export type BuildSubscriptionCaveatsParams = { /** * Builds the caveat list for a cash-subscription delegation: - * `ValueLte(0)`, `ERC20TokenPeriodTransfer(...)`, then `Redeemer(delegate)`. + * `ValueLte(0)` then `ERC20TokenPeriodTransfer(...)`. + * RedeemerEnforcer is temporarily omitted pending CHOMP guidance. * * @param params - Enforcer addresses, parties, and period terms. * @param params.enforcers - Delegation Framework enforcer addresses. @@ -59,7 +60,7 @@ export function buildSubscriptionCaveats({ }), args: '0x', }, - // TODO: recheck with CHOMP team if we should set redeemer to subscirption payment address + // TODO: recheck with CHOMP team if we should set redeemer to subscription payment address // or use allowed call data // { // enforcer: enforcers.redeemer, diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index 6956427310a..93f07975896 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -19,7 +19,6 @@ const REDEEMER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const TOKEN = '0x3333333333333333333333333333333333333333' as Hex; const DELEGATE = '0x4444444444444444444444444444444444444444' as Hex; const DELEGATOR = '0x5555555555555555555555555555555555555555' as Hex; -const OTHER_REDEEMER = '0x6666666666666666666666666666666666666666' as Hex; const CHAIN_ID = '0x1' as Hex; const PERIOD_AMOUNT = 10n * 10n ** 18n; const PERIOD_DURATION = 28 * 86_400; @@ -37,7 +36,7 @@ function buildEntry({ periodEnforcer = PERIOD, redeemerEnforcer = REDEEMER, redeemerAddress = DELEGATE, - includeRedeemer = true, + includeRedeemer = false, maxValue = 0n, }: { type?: string; @@ -178,13 +177,6 @@ describe('makeMatchesSubscriptionDelegation', () => { periodEnforcer: '0x6666666666666666666666666666666666666666' as Hex, }, ], - [ - 'redeemerEnforcer', - { - redeemerEnforcer: '0x6666666666666666666666666666666666666666' as Hex, - }, - ], - ['redeemerAddress', { redeemerAddress: OTHER_REDEEMER }], ['periodAmount', { periodAmount: PERIOD_AMOUNT + 1n }], ['periodDuration', { periodDuration: PERIOD_DURATION + 1 }], ['maxValue', { maxValue: 1n }], @@ -199,8 +191,8 @@ describe('makeMatchesSubscriptionDelegation', () => { expect(matches(entry)).toBe(false); }); - it('rejects a delegation missing the redeemer caveat', () => { - expect(matches(buildEntry({ includeRedeemer: false }))).toBe(false); + it('still matches when the redeemer caveat is present', () => { + expect(matches(buildEntry({ includeRedeemer: true }))).toBe(true); }); it('rejects a delegation with malformed caveat terms', () => { From aa2a94514042aee6c932b025b94a74b0e618ae52 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 14:54:27 +0700 Subject: [PATCH 13/23] feat: add skipChompInteractions flag to SubscriptionDelegationService for alpha demos/tests --- packages/subscription-controller/CHANGELOG.md | 3 +- ...onDelegationService-method-action-types.ts | 13 ++- .../SubscriptionDelegationService.test.ts | 59 +++++++++- .../SubscriptionDelegationService.ts | 103 ++++++++++-------- .../src/subscription-delegation/types.ts | 6 + 5 files changed, 130 insertions(+), 54 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 0911d555e5c..33e7b2ef29e 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `SubscriptionDelegationService` for Money Account Plus cash-subscription delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, and optional balance-check flag; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. + - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, optional balance-check flag, and optional `skipChompInteractions` flag for alpha demos/tests; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. + - When `skipChompInteractions` is true, CHOMP verify and intent registration are skipped; the returned hash is computed locally and AUS persistence still occurs. - Resolves the chain from `moneyAccountVaultConfig` and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. - Uses pricing `delegateAddress` as both the delegation `delegate` and the RedeemerEnforcer redeemer. - Offsets the period-transfer `startDate` by pricing `trialPeriodDays` only when the trial is selected. diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index d49099de8d9..0bbd6c3c302 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -18,14 +18,19 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { }; /** - * Prepares a cash-subscription delegation and returns its verified hash. + * Prepares a cash-subscription delegation and returns its hash. * * Reuses a stored AUS delegation that matches the semantic fingerprint when - * one exists (ensuring a CHOMP intent is active for its hash). Otherwise - * builds, signs, verifies, persists, and registers a new delegation. + * one exists (ensuring a CHOMP intent is active for its hash, unless + * `skipChompInteractions` is true). Otherwise builds, signs, optionally + * verifies with CHOMP, persists, and optionally registers a new delegation. + * + * When `skipChompInteractions` is true (alpha demos / tests), CHOMP verify + * and intent calls are skipped; the returned hash is computed locally. * * @param request - Authoritative pricing and payer details for the delegation. - * @returns The verified delegation hash and whether it was created or reused. + * @returns The delegation hash (CHOMP-verified unless skipped) and whether it + * was created or reused. */ export type SubscriptionDelegationServicePrepareDelegationAction = { type: `SubscriptionDelegationService:prepareDelegation`; diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index 1980dcef01a..4b9651a3159 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -385,11 +385,6 @@ describe('SubscriptionDelegationService', () => { caveats: [ expect.objectContaining({ enforcer: VALUE_LTE }), expect.objectContaining({ enforcer: PERIOD }), - expect.objectContaining({ - enforcer: REDEEMER, - terms: createRedeemerTerms({ redeemers: [DELEGATE] }), - args: '0x', - }), ], }), chainId: CHAIN_ID, @@ -519,6 +514,60 @@ describe('SubscriptionDelegationService', () => { expect(mocks.signDelegation).not.toHaveBeenCalled(); }); + it('skips CHOMP verify and intent registration when skipChompInteractions is true', async () => { + const { service, mocks } = setup(); + + const result = await service.prepareDelegation({ + ...REQUEST, + skipChompInteractions: true, + }); + + expect(result.disposition).toBe('created'); + expect(result.delegationHash).toMatch(/^0x[0-9a-fA-F]{64}$/u); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledWith({ + signedDelegation: expect.objectContaining({ + delegate: DELEGATE, + delegator: PAYER, + signature: SIGNATURE, + }), + metadata: expect.objectContaining({ + delegationHash: result.delegationHash, + chainIdHex: CHAIN_ID, + allowance: `0x${PERIOD_AMOUNT.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress: TOKEN, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, + }), + }); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.getIntentsByAddress).not.toHaveBeenCalled(); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('reuses a matching delegation without CHOMP when skipChompInteractions is true', async () => { + const stored = buildStoredDelegation(); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [], + }); + + const result = await service.prepareDelegation({ + ...REQUEST, + skipChompInteractions: true, + }); + + expect(result).toStrictEqual({ + delegationHash: stored.metadata.delegationHash, + disposition: 'reused', + }); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + expect(mocks.getIntentsByAddress).not.toHaveBeenCalled(); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + it('checks Money Account balance when checkBalance is true and proceeds when sufficient', async () => { const { service, mocks } = setup(); diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index d6200b9029a..86944431848 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -152,6 +152,8 @@ type ResolvedSubscriptionDelegationConfig = { * Owns the workflow: size periodic caveats → sign → CHOMP verify → persist to * Authenticated User Storage → register CHOMP intent. Returns a verified * `delegationHash` for `SubscriptionController.startSubscriptionWithCrypto`. + * Callers may set `skipChompInteractions` to bypass CHOMP verify/intent steps + * for alpha demos and tests. * * Each call resolves the Money Account chain from remote feature flags, then * resolves its price, payment token, and delegate from `SubscriptionController` @@ -222,14 +224,19 @@ export class SubscriptionDelegationService { } /** - * Prepares a cash-subscription delegation and returns its verified hash. + * Prepares a cash-subscription delegation and returns its hash. * * Reuses a stored AUS delegation that matches the semantic fingerprint when - * one exists (ensuring a CHOMP intent is active for its hash). Otherwise - * builds, signs, verifies, persists, and registers a new delegation. + * one exists (ensuring a CHOMP intent is active for its hash, unless + * `skipChompInteractions` is true). Otherwise builds, signs, optionally + * verifies with CHOMP, persists, and optionally registers a new delegation. + * + * When `skipChompInteractions` is true (alpha demos / tests), CHOMP verify + * and intent calls are skipped; the returned hash is computed locally. * * @param request - Authoritative pricing and payer details for the delegation. - * @returns The verified delegation hash and whether it was created or reused. + * @returns The delegation hash (CHOMP-verified unless skipped) and whether it + * was created or reused. */ async prepareDelegation( request: PrepareSubscriptionDelegationRequest, @@ -240,6 +247,8 @@ export class SubscriptionDelegationService { ); } + const skipChomp = Boolean(request.skipChompInteractions); + const { chainId, delegateAddress, enforcers, price, token } = await this.#resolveConfiguration( request.product, @@ -280,14 +289,16 @@ export class SubscriptionDelegationService { ); const reusable = existingDelegations.find(matches); if (reusable) { - await this.#ensureIntent({ - account: request.payerAddress, - chainId, - delegationHash: reusable.metadata.delegationHash, - allowance: reusable.metadata.allowance, - tokenSymbol: reusable.metadata.tokenSymbol, - tokenAddress: reusable.metadata.tokenAddress, - }); + if (!skipChomp) { + await this.#ensureIntent({ + account: request.payerAddress, + chainId, + delegationHash: reusable.metadata.delegationHash, + allowance: reusable.metadata.allowance, + tokenSymbol: reusable.metadata.tokenSymbol, + tokenAddress: reusable.metadata.tokenAddress, + }); + } return { delegationHash: reusable.metadata.delegationHash, disposition: 'reused', @@ -317,37 +328,39 @@ export class SubscriptionDelegationService { const signedDelegation = { ...unsigned, signature }; - const verifyResult = await this.#messenger.call( - 'ChompApiService:verifyDelegation', - { - signedDelegation, - chainId, - }, - ); - - if (!verifyResult.valid) { - throw new Error( - `${SubscriptionDelegationServiceErrorMessage.ChompRejectedDelegation}: ${ - verifyResult.errors?.join(', ') ?? 'unknown error' - }`, - ); - } - const delegationHash = hashDelegation({ ...unsigned, salt: BigInt(unsigned.salt), signature, }); - if (!verifyResult.delegationHash) { - throw new Error( - SubscriptionDelegationServiceErrorMessage.ChompMissingDelegationHash, - ); - } - if (!equalsIgnoreCase(verifyResult.delegationHash, delegationHash)) { - throw new Error( - SubscriptionDelegationServiceErrorMessage.ChompDelegationHashMismatch, + if (!skipChomp) { + const verifyResult = await this.#messenger.call( + 'ChompApiService:verifyDelegation', + { + signedDelegation, + chainId, + }, ); + + if (!verifyResult.valid) { + throw new Error( + `${SubscriptionDelegationServiceErrorMessage.ChompRejectedDelegation}: ${ + verifyResult.errors?.join(', ') ?? 'unknown error' + }`, + ); + } + + if (!verifyResult.delegationHash) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.ChompMissingDelegationHash, + ); + } + if (!equalsIgnoreCase(verifyResult.delegationHash, delegationHash)) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.ChompDelegationHashMismatch, + ); + } } const allowance: Hex = add0x(periodAmount.toString(16)); @@ -367,14 +380,16 @@ export class SubscriptionDelegationService { }, ); - await this.#createIntent({ - account: request.payerAddress, - chainId, - delegationHash, - allowance, - tokenSymbol: token.symbol, - tokenAddress: token.address, - }); + if (!skipChomp) { + await this.#createIntent({ + account: request.payerAddress, + chainId, + delegationHash, + allowance, + tokenSymbol: token.symbol, + tokenAddress: token.address, + }); + } return { delegationHash, diff --git a/packages/subscription-controller/src/subscription-delegation/types.ts b/packages/subscription-controller/src/subscription-delegation/types.ts index e79dfce5d99..2dfbcb1f671 100644 --- a/packages/subscription-controller/src/subscription-delegation/types.ts +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -33,6 +33,12 @@ export type PrepareSubscriptionDelegationRequest = { * (`unitAmount × minBillingCyclesForBalance` in mUSD) before side effects. */ checkBalance?: boolean; + /** + * When true, skips CHOMP verify/intent interactions. Intended for alpha + * demos and tests where the subscription API can create a subscription + * without a registered CHOMP intent. Defaults to false. + */ + skipChompInteractions?: boolean; }; /** From 18b43294fe1dae3ec540dce8549c9efd6b561d18 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:33:35 +0700 Subject: [PATCH 14/23] refactor: remove CHOMP intent types and update related tests in chomp-api-service --- packages/chomp-api-service/CHANGELOG.md | 6 --- .../src/chomp-api-service.test.ts | 44 +------------------ .../src/chomp-api-service.ts | 9 ++-- packages/chomp-api-service/src/index.ts | 2 - packages/chomp-api-service/src/types.ts | 22 ++-------- packages/subscription-controller/CHANGELOG.md | 4 +- ...onDelegationService-method-action-types.ts | 6 ++- .../SubscriptionDelegationService.ts | 23 +++++++--- .../src/subscription-delegation/types.ts | 16 ++++--- 9 files changed, 42 insertions(+), 90 deletions(-) diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index 7e4326fa956..faaa749075b 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Accept `'cash-subscription'`, `'cash-deposit-premium'`, and `'cash-withdrawal-premium'` as CHOMP intent / delegation metadata types alongside `'cash-deposit'` and `'cash-withdrawal'`. ([#10130](https://github.com/MetaMask/core/pull/10130)) - - Adds exported `CHOMP_INTENT_TYPES` const object and `ChompIntentType` covering all five values. - - Response structs for create-intents, get-intents-by-address, and service-details now accept the new types. - ## [4.0.2] ### Changed diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts index ded4215aeb8..c80b6ba7b5c 100644 --- a/packages/chomp-api-service/src/chomp-api-service.test.ts +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -9,7 +9,6 @@ import nock from 'nock'; import type { ChompApiServiceMessenger } from './chomp-api-service.js'; import { ChompApiService } from './chomp-api-service.js'; -import { CHOMP_INTENT_TYPES } from './types.js'; const BASE_URL = 'https://api.chomp.example.com'; const MOCK_TOKEN = 'mock-jwt-token'; @@ -521,47 +520,6 @@ describe('ChompApiService', () => { 'At path: 0.delegationHash -- Expected a string', ); }); - - it('accepts cash-subscription intent metadata type', async () => { - const subscriptionIntentParams = [ - { - account: '0xabc' as const, - delegationHash: '0xdef' as const, - chainId: '0x1' as const, - metadata: { - allowance: '0xff' as const, - tokenSymbol: 'pvmUSD', - tokenAddress: '0x123' as const, - type: CHOMP_INTENT_TYPES.CASH_SUBSCRIPTION, - }, - }, - ]; - const subscriptionIntentResponse = [ - { - delegationHash: '0xdef', - metadata: { - allowance: '0xff', - tokenSymbol: 'pvmUSD', - tokenAddress: '0x123', - type: CHOMP_INTENT_TYPES.CASH_SUBSCRIPTION, - }, - createdAt: '2026-01-01T00:00:00Z', - }, - ]; - - nock(BASE_URL) - .post('/v1/intent', subscriptionIntentParams) - .matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`) - .reply(201, subscriptionIntentResponse); - const { rootMessenger } = createService(); - - const result = await rootMessenger.call( - 'ChompApiService:createIntents', - subscriptionIntentParams, - ); - - expect(result).toStrictEqual(subscriptionIntentResponse); - }); }); describe('getIntentsByAddress', () => { @@ -688,7 +646,7 @@ describe('ChompApiService', () => { }, ], adapterAddress: '0x4839b1BA117BdFFA986FCfA4E5fE6b9027b8f8B1', - intentTypes: Object.values(CHOMP_INTENT_TYPES), + intentTypes: ['cash-deposit', 'cash-withdrawal'], }, }, }, diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index cb69deb2e2f..d3348fd7a6a 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -46,7 +46,6 @@ import type { VerifyDelegationParams, VerifyDelegationResponse, } from './types.js'; -import { CHOMP_INTENT_TYPES } from './types.js'; // === GENERAL === @@ -194,8 +193,6 @@ const VerifyDelegationResponseStruct = type({ errors: optional(array(string())), }); -const ChompIntentTypeStruct = enums(Object.values(CHOMP_INTENT_TYPES)); - const SendIntentResponseArrayStruct = array( type({ delegationHash: StrictHexStruct, @@ -203,7 +200,7 @@ const SendIntentResponseArrayStruct = array( allowance: StrictHexStruct, tokenSymbol: string(), tokenAddress: StrictHexStruct, - type: ChompIntentTypeStruct, + type: enums(['cash-deposit', 'cash-withdrawal']), }), createdAt: string(), }), @@ -219,7 +216,7 @@ const IntentEntryArrayStruct = array( allowance: StrictHexStruct, tokenAddress: StrictHexStruct, tokenSymbol: string(), - type: ChompIntentTypeStruct, + type: enums(['cash-deposit', 'cash-withdrawal']), }), }), ); @@ -236,7 +233,7 @@ const ServiceDetailsProtocolStruct = type({ }), ), adapterAddress: StrictHexStruct, - intentTypes: array(ChompIntentTypeStruct), + intentTypes: array(enums(['cash-deposit', 'cash-withdrawal'])), }); const ServiceDetailsResponseStruct = type({ diff --git a/packages/chomp-api-service/src/index.ts b/packages/chomp-api-service/src/index.ts index d7144f27cdb..ab5b8faa26f 100644 --- a/packages/chomp-api-service/src/index.ts +++ b/packages/chomp-api-service/src/index.ts @@ -23,7 +23,6 @@ export type { AssociateAddressParams, AssociateAddressResponse, AuthorizationData, - ChompIntentType, CreateUpgradeParams, CreateUpgradeResponse, CreateWithdrawalParams, @@ -44,4 +43,3 @@ export type { VerifyDelegationParams, VerifyDelegationResponse, } from './types.js'; -export { CHOMP_INTENT_TYPES } from './types.js'; diff --git a/packages/chomp-api-service/src/types.ts b/packages/chomp-api-service/src/types.ts index 083331af52d..e4dba142593 100644 --- a/packages/chomp-api-service/src/types.ts +++ b/packages/chomp-api-service/src/types.ts @@ -40,25 +40,11 @@ export type VerifyDelegationParams = { chainId: Hex; }; -/** - * CHOMP intent / delegation metadata type discriminators. - */ -export const CHOMP_INTENT_TYPES = { - CASH_DEPOSIT: 'cash-deposit', - CASH_WITHDRAWAL: 'cash-withdrawal', - CASH_SUBSCRIPTION: 'cash-subscription', - CASH_DEPOSIT_PREMIUM: 'cash-deposit-premium', - CASH_WITHDRAWAL_PREMIUM: 'cash-withdrawal-premium', -} as const; - -export type ChompIntentType = - (typeof CHOMP_INTENT_TYPES)[keyof typeof CHOMP_INTENT_TYPES]; - export type IntentMetadataParams = { allowance: Hex; tokenSymbol: string; tokenAddress: Hex; - type: ChompIntentType; + type: 'cash-deposit' | 'cash-withdrawal'; }; export type SendIntentParams = { @@ -151,7 +137,7 @@ export type IntentMetadataResponse = { allowance: Hex; tokenSymbol: string; tokenAddress: Hex; - type: ChompIntentType; + type: 'cash-deposit' | 'cash-withdrawal'; }; export type SendIntentResponse = { @@ -172,7 +158,7 @@ export type IntentEntry = { allowance: Hex; tokenAddress: Hex; tokenSymbol: string; - type: ChompIntentType; + type: 'cash-deposit' | 'cash-withdrawal'; }; }; @@ -190,7 +176,7 @@ export type ServiceDetailsSupportedToken = { export type ServiceDetailsProtocol = { supportedTokens: ServiceDetailsSupportedToken[]; adapterAddress: Hex; - intentTypes: ChompIntentType[]; + intentTypes: ('cash-deposit' | 'cash-withdrawal')[]; }; export type ServiceDetailsChain = { diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 33e7b2ef29e..8378f5489ad 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -12,13 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `SubscriptionDelegationService` for Money Account Plus cash-subscription delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, optional balance-check flag, and optional `skipChompInteractions` flag for alpha demos/tests; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. + - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, optional balance-check flag, and optional `skipChompInteractions` flag; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. - When `skipChompInteractions` is true, CHOMP verify and intent registration are skipped; the returned hash is computed locally and AUS persistence still occurs. - Resolves the chain from `moneyAccountVaultConfig` and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. - Uses pricing `delegateAddress` as both the delegation `delegate` and the RedeemerEnforcer redeemer. - Offsets the period-transfer `startDate` by pricing `trialPeriodDays` only when the trial is selected. - New messenger action `SubscriptionDelegationService:checkMoneyAccountBalance` compares Money Account convertible mUSD balance against pricing `unitAmount × minBillingCyclesForBalance`; `prepareDelegation` can gate on it via `checkBalance`. - - Exports `CASH_SUBSCRIPTION_DELEGATION_TYPE` (`'cash-subscription'`) for AUS and CHOMP intent metadata. + - Exports `CASH_SUBSCRIPTION_DELEGATION_TYPE` (`'cash-subscription'`) for AUS metadata (and for CHOMP intent metadata once chomp-api-service supports that type). - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. - Add `selectIsActiveSubscriber` to check whether a product has an active, trialing, or provisional subscription. ([#10017](https://github.com/MetaMask/core/pull/10017)) - Add product-scoped entitlements to `SubscriptionController` state and export type-safe `selectHasEntitlement` and `selectIsUsageAvailable` selectors for Money Account Plus and Shield ([#10017](https://github.com/MetaMask/core/pull/10017)) diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index 0bbd6c3c302..5192f0d8366 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -25,8 +25,10 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { * `skipChompInteractions` is true). Otherwise builds, signs, optionally * verifies with CHOMP, persists, and optionally registers a new delegation. * - * When `skipChompInteractions` is true (alpha demos / tests), CHOMP verify - * and intent calls are skipped; the returned hash is computed locally. + * When `skipChompInteractions` is true (required for alpha), CHOMP verify + * and intent calls are skipped; the returned hash is computed locally. The + * default CHOMP-enabled path requires a follow-up chomp-api-service release + * that accepts `'cash-subscription'` intent metadata. * * @param request - Authoritative pricing and payer details for the delegation. * @returns The delegation hash (CHOMP-verified unless skipped) and whether it diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index 86944431848..cec1fcbf5aa 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -152,8 +152,11 @@ type ResolvedSubscriptionDelegationConfig = { * Owns the workflow: size periodic caveats → sign → CHOMP verify → persist to * Authenticated User Storage → register CHOMP intent. Returns a verified * `delegationHash` for `SubscriptionController.startSubscriptionWithCrypto`. - * Callers may set `skipChompInteractions` to bypass CHOMP verify/intent steps - * for alpha demos and tests. + * + * Alpha callers must pass `skipChompInteractions: true` until a follow-up + * `@metamask/chomp-api-service` release accepts `'cash-subscription'` intent + * metadata. The CHOMP-enabled path (`skipChompInteractions` unset/false) + * remains dormant and is not production-ready without that package support. * * Each call resolves the Money Account chain from remote feature flags, then * resolves its price, payment token, and delegate from `SubscriptionController` @@ -231,8 +234,10 @@ export class SubscriptionDelegationService { * `skipChompInteractions` is true). Otherwise builds, signs, optionally * verifies with CHOMP, persists, and optionally registers a new delegation. * - * When `skipChompInteractions` is true (alpha demos / tests), CHOMP verify - * and intent calls are skipped; the returned hash is computed locally. + * When `skipChompInteractions` is true (required for alpha), CHOMP verify + * and intent calls are skipped; the returned hash is computed locally. The + * default CHOMP-enabled path requires a follow-up chomp-api-service release + * that accepts `'cash-subscription'` intent metadata. * * @param request - Authoritative pricing and payer details for the delegation. * @returns The delegation hash (CHOMP-verified unless skipped) and whether it @@ -476,6 +481,12 @@ export class SubscriptionDelegationService { } async #createIntent(params: SubscriptionIntentParams): Promise { + // Published `@metamask/chomp-api-service` only types intent metadata as + // `'cash-deposit' | 'cash-withdrawal'`. The dormant production path still + // passes `'cash-subscription'`; a follow-up chomp-api-service release must + // accept that discriminator before this path is production-ready. Alpha + // callers must use `skipChompInteractions: true` so this method is not + // reached. await this.#messenger.call('ChompApiService:createIntents', [ { account: params.account, @@ -485,7 +496,9 @@ export class SubscriptionDelegationService { allowance: params.allowance, tokenSymbol: params.tokenSymbol, tokenAddress: params.tokenAddress, - type: CASH_SUBSCRIPTION_DELEGATION_TYPE, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE as + | 'cash-deposit' + | 'cash-withdrawal', }, }, ]); diff --git a/packages/subscription-controller/src/subscription-delegation/types.ts b/packages/subscription-controller/src/subscription-delegation/types.ts index 2dfbcb1f671..cc6fdae76b1 100644 --- a/packages/subscription-controller/src/subscription-delegation/types.ts +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -1,4 +1,3 @@ -import { CHOMP_INTENT_TYPES } from '@metamask/chomp-api-service'; import type { Hex } from '@metamask/utils'; import { PRODUCT_TYPES } from '../types.js'; @@ -6,9 +5,13 @@ import type { RecurringInterval } from '../types.js'; /** * Storage / CHOMP metadata type for cash-subscription delegations. + * + * Defined locally so this package does not depend on an unreleased + * `@metamask/chomp-api-service` intent type. Production CHOMP intent + * registration still requires a follow-up chomp-api-service release that + * accepts `'cash-subscription'`. */ -export const CASH_SUBSCRIPTION_DELEGATION_TYPE = - CHOMP_INTENT_TYPES.CASH_SUBSCRIPTION; +export const CASH_SUBSCRIPTION_DELEGATION_TYPE = 'cash-subscription' as const; /** * Request to prepare a cash-subscription delegation. @@ -34,9 +37,10 @@ export type PrepareSubscriptionDelegationRequest = { */ checkBalance?: boolean; /** - * When true, skips CHOMP verify/intent interactions. Intended for alpha - * demos and tests where the subscription API can create a subscription - * without a registered CHOMP intent. Defaults to false. + * When true, skips CHOMP verify/intent interactions. Required for alpha + * until `@metamask/chomp-api-service` accepts `'cash-subscription'` intent + * metadata. Defaults to false (production path; unsupported until that + * follow-up release). */ skipChompInteractions?: boolean; }; From 575b9b78920bf57cd2387d7ff1a2a249010e6294 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:45:16 +0700 Subject: [PATCH 15/23] fix: update changelog --- packages/subscription-controller/CHANGELOG.md | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 29fc1dfe4cd..6e9d8871041 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `SubscriptionDelegationService` for Money Account Plus cash-subscription delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) + - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. + - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. + - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, optional balance-check flag, and optional `skipChompInteractions` flag; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. + - When `skipChompInteractions` is true, CHOMP verify and intent registration are skipped; the returned hash is computed locally and AUS persistence still occurs. + - Resolves the chain from `moneyAccountVaultConfig` and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. + - Uses pricing `delegateAddress` as both the delegation `delegate` and the RedeemerEnforcer redeemer. + - Offsets the period-transfer `startDate` by pricing `trialPeriodDays` only when the trial is selected. + - New messenger action `SubscriptionDelegationService:checkMoneyAccountBalance` compares Money Account convertible mUSD balance against pricing `unitAmount × minBillingCyclesForBalance`; `prepareDelegation` can gate on it via `checkBalance`. + - Exports `CASH_SUBSCRIPTION_DELEGATION_TYPE` (`'cash-subscription'`) for AUS metadata (and for CHOMP intent metadata once chomp-api-service supports that type). + - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. + ## [9.0.0] ### Changed @@ -28,17 +42,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `SubscriptionDelegationService` for Money Account Plus cash-subscription delegation setup. ([#10130](https://github.com/MetaMask/core/pull/10130)) - - New messenger action `SubscriptionDelegationService:prepareDelegation` orchestrates periodic caveat construction, signing, CHOMP verification, Authenticated User Storage persistence, and CHOMP intent registration. - - Returns a verified `delegationHash` with `disposition: 'created' | 'reused'` for `SubscriptionController.startSubscriptionWithCrypto`; the controller does not depend on this service. - - `prepareDelegation` accepts the product, recurring interval, payer address, trial selection, optional balance-check flag, and optional `skipChompInteractions` flag; it resolves plan, token, and delegate data through `SubscriptionController:getPricing`. - - When `skipChompInteractions` is true, CHOMP verify and intent registration are skipped; the returned hash is computed locally and AUS persistence still occurs. - - Resolves the chain from `moneyAccountVaultConfig` and Delegation Framework v1.3.0 enforcers from `@metamask/delegation-deployments`. - - Uses pricing `delegateAddress` as both the delegation `delegate` and the RedeemerEnforcer redeemer. - - Offsets the period-transfer `startDate` by pricing `trialPeriodDays` only when the trial is selected. - - New messenger action `SubscriptionDelegationService:checkMoneyAccountBalance` compares Money Account convertible mUSD balance against pricing `unitAmount × minBillingCyclesForBalance`; `prepareDelegation` can gate on it via `checkBalance`. - - Exports `CASH_SUBSCRIPTION_DELEGATION_TYPE` (`'cash-subscription'`) for AUS metadata (and for CHOMP intent metadata once chomp-api-service supports that type). - - Only Money Account Plus is supported; Shield continues to use ERC-20 approval. - Add `selectIsActiveSubscriber` to check whether a product has an active, trialing, or provisional subscription. ([#10017](https://github.com/MetaMask/core/pull/10017)) - Add product-scoped entitlements to `SubscriptionController` state and export type-safe `selectHasEntitlement` and `selectIsUsageAvailable` selectors for Money Account Plus and Shield ([#10017](https://github.com/MetaMask/core/pull/10017)) - Add `getBenefits` to fetch and persist Money Account Plus subscription benefits. ([#10103](https://github.com/MetaMask/core/pull/10103)) From 8a12c4b36abd9c9becf2724a0ae1b91ada4dc77b Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:49:42 +0700 Subject: [PATCH 16/23] feat: enhance subscription delegation logic to support trial periods --- .../SubscriptionDelegationService.test.ts | 86 ++++++++++++++++++- .../SubscriptionDelegationService.ts | 21 +++-- .../fingerprint.test.ts | 34 +++++++- .../subscription-delegation/fingerprint.ts | 22 ++++- 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index 4b9651a3159..28770db4090 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -295,10 +295,12 @@ function buildStoredDelegation({ periodAmount = PERIOD_AMOUNT, periodDuration = PERIOD_DURATION, delegationHash = `0x${'dd'.repeat(32)}`, + startDate = 1_700_000_000, }: { periodAmount?: bigint; periodDuration?: number; delegationHash?: Hex; + startDate?: number; } = {}) { return { signedDelegation: { @@ -317,7 +319,7 @@ function buildStoredDelegation({ tokenAddress: TOKEN, periodAmount, periodDuration, - startDate: 1_700_000_000, + startDate, }), args: '0x', }, @@ -545,6 +547,88 @@ describe('SubscriptionDelegationService', () => { expect(mocks.createIntents).not.toHaveBeenCalled(); }); + it('does not reuse an immediately redeemable delegation when trial is requested', async () => { + const stored = buildStoredDelegation({ + startDate: Math.floor(Date.now() / 1000), + }); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [ + { + account: PAYER, + delegationHash: stored.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: stored.metadata, + }, + ], + }); + + const result = await service.prepareDelegation({ + ...REQUEST, + isTrialRequested: true, + }); + + expect(result.disposition).toBe('created'); + expect(result.delegationHash).not.toBe(stored.metadata.delegationHash); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + }); + + it('does not reuse a trial-deferred delegation when trial is not requested', async () => { + const stored = buildStoredDelegation({ + startDate: + Math.floor(Date.now() / 1000) + PRICE.trialPeriodDays * 86_400, + }); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [ + { + account: PAYER, + delegationHash: stored.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: stored.metadata, + }, + ], + }); + + const result = await service.prepareDelegation(REQUEST); + + expect(result.disposition).toBe('created'); + expect(result.delegationHash).not.toBe(stored.metadata.delegationHash); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + }); + + it('reuses a trial-deferred delegation when trial is requested', async () => { + const stored = buildStoredDelegation({ + startDate: + Math.floor(Date.now() / 1000) + PRICE.trialPeriodDays * 86_400, + }); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [ + { + account: PAYER, + delegationHash: stored.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: stored.metadata, + }, + ], + }); + + const result = await service.prepareDelegation({ + ...REQUEST, + isTrialRequested: true, + }); + + expect(result).toStrictEqual({ + delegationHash: stored.metadata.delegationHash, + disposition: 'reused', + }); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + }); + it('reuses a matching delegation without CHOMP when skipChompInteractions is true', async () => { const stored = buildStoredDelegation(); const { service, mocks } = setup({ diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index cec1fcbf5aa..487733f135c 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -231,8 +231,10 @@ export class SubscriptionDelegationService { * * Reuses a stored AUS delegation that matches the semantic fingerprint when * one exists (ensuring a CHOMP intent is active for its hash, unless - * `skipChompInteractions` is true). Otherwise builds, signs, optionally - * verifies with CHOMP, persists, and optionally registers a new delegation. + * `skipChompInteractions` is true). Period `startDate` must still be + * trial-deferred when `isTrialRequested` is true, and immediately redeemable + * otherwise. If there is no match, builds, signs, optionally verifies with + * CHOMP, persists, and optionally registers a new delegation. * * When `skipChompInteractions` is true (required for alpha), CHOMP verify * and intent calls are skipped; the returned hash is computed locally. The @@ -278,6 +280,13 @@ export class SubscriptionDelegationService { tokenDecimals: token.decimals, }); const periodDuration = getPeriodDuration(request.recurringInterval); + const nowSeconds = Math.floor(Date.now() / 1000); + const startDate = getDelegationStartDate({ + nowSeconds, + trialPeriodDays: request.isTrialRequested + ? price.trialPeriodDays + : undefined, + }); const matches = makeMatchesSubscriptionDelegation({ delegatorAddress: request.payerAddress, @@ -286,6 +295,8 @@ export class SubscriptionDelegationService { tokenAddress: token.address, periodAmount, periodDuration, + nowSeconds, + isTrialRequested: request.isTrialRequested, enforcers, }); @@ -310,12 +321,6 @@ export class SubscriptionDelegationService { }; } - const startDate = getDelegationStartDate({ - nowSeconds: Math.floor(Date.now() / 1000), - trialPeriodDays: request.isTrialRequested - ? price.trialPeriodDays - : undefined, - }); const unsigned = buildUnsignedSubscriptionDelegation({ delegateAddress, delegatorAddress: request.payerAddress, diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index 93f07975896..feb3dd91c16 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -100,6 +100,8 @@ function buildEntry({ }; } +const NOW_SECONDS = 1_700_000_000; + const expected = { delegatorAddress: DELEGATOR, delegateAddress: DELEGATE, @@ -107,6 +109,8 @@ const expected = { tokenAddress: TOKEN, periodAmount: PERIOD_AMOUNT, periodDuration: PERIOD_DURATION, + nowSeconds: NOW_SECONDS, + isTrialRequested: false, enforcers: { valueLte: VALUE_LTE, erc20TokenPeriodTransfer: PERIOD, @@ -128,8 +132,34 @@ describe('makeMatchesSubscriptionDelegation', () => { expect(matches(buildEntry())).toBe(true); }); - it('matches when startDate differs', () => { - expect(matches(buildEntry({ startDate: 1_800_000_000 }))).toBe(true); + it('matches when startDate is earlier but still immediately redeemable', () => { + expect(matches(buildEntry({ startDate: NOW_SECONDS - 86_400 }))).toBe(true); + }); + + it('rejects a trial-deferred startDate when trial is not requested', () => { + expect(matches(buildEntry({ startDate: NOW_SECONDS + 86_400 }))).toBe( + false, + ); + }); + + it('matches a deferred startDate when trial is requested', () => { + const matchesTrial = makeMatchesSubscriptionDelegation({ + ...expected, + isTrialRequested: true, + }); + + expect(matchesTrial(buildEntry({ startDate: NOW_SECONDS + 86_400 }))).toBe( + true, + ); + }); + + it('rejects an immediately redeemable startDate when trial is requested', () => { + const matchesTrial = makeMatchesSubscriptionDelegation({ + ...expected, + isTrialRequested: true, + }); + + expect(matchesTrial(buildEntry({ startDate: NOW_SECONDS }))).toBe(false); }); it('matches case-insensitively on addresses and chain id', () => { diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts index ff7f1c9a811..02805245cb3 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -15,6 +15,16 @@ export type SubscriptionDelegationFingerprint = { tokenAddress: Hex; periodAmount: bigint; periodDuration: number; + /** + * Current unix timestamp in seconds. Used to classify a stored period + * `startDate` as immediately redeemable (`<= now`) vs trial-deferred (`> now`). + */ + nowSeconds: number; + /** + * When true, only a still-deferred period start matches. When false, only + * an immediately redeemable start matches. + */ + isTrialRequested: boolean; enforcers: SubscriptionDelegationEnforcers; }; @@ -31,8 +41,10 @@ export function equalsIgnoreCase(left: string, right: string): boolean { /** * Builds a predicate that matches a stored AUS delegation to the semantic - * cash-subscription fingerprint. Salt and period `startDate` are ignored so - * a previously signed equivalent permission can be reused. + * cash-subscription fingerprint. Salt is ignored so a previously signed + * equivalent permission can be reused. Period `startDate` is compared only + * as trial-deferred (`> nowSeconds`) vs immediately redeemable (`<= nowSeconds`) + * so a trial request cannot reuse a live permission and vice versa. * * @param expected - Semantic fields that must match. * @returns Predicate over {@link DelegationResponse}. @@ -106,6 +118,12 @@ export function makeMatchesSubscriptionDelegation( const periodTerms = decodeERC20TokenPeriodTransferTerms( periodCaveat.terms, ); + const storedStartDate = Number(periodTerms.startDate); + const isStoredDeferred = storedStartDate > expected.nowSeconds; + if (expected.isTrialRequested !== isStoredDeferred) { + return false; + } + return ( equalsIgnoreCase(periodTerms.tokenAddress, expected.tokenAddress) && periodTerms.periodAmount === expected.periodAmount && From eb76f2011444e8606d846a2cb13e5f6b196cee93 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:50:33 +0700 Subject: [PATCH 17/23] chore: update readme --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index f870190fa05..217db0a052c 100644 --- a/README.md +++ b/README.md @@ -622,17 +622,12 @@ linkStyle default opacity:0.5 social_controllers --> profile_sync_controller; solana_test_validator_up --> local_node_utils; storage_service --> messenger; - subscription_controller --> authenticated_user_storage; subscription_controller --> base_controller; subscription_controller --> base_data_service; - subscription_controller --> chomp_api_service; subscription_controller --> controller_utils; - subscription_controller --> delegation_controller; subscription_controller --> messenger; - subscription_controller --> money_account_utils; subscription_controller --> polling_controller; subscription_controller --> profile_sync_controller; - subscription_controller --> remote_feature_flag_controller; subscription_controller --> transaction_controller; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; From d8bb44eb7ab3516bce704d8da11f7b9d25e93758 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:51:19 +0700 Subject: [PATCH 18/23] fix: yarn constraints --- packages/subscription-controller/package.json | 12 +- yarn.lock | 672 +----------------- 2 files changed, 17 insertions(+), 667 deletions(-) diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index c00e78685df..b0569980805 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -50,20 +50,20 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/authenticated-user-storage": "^3.0.2", + "@metamask/authenticated-user-storage": "^4.0.0", "@metamask/base-controller": "^10.0.0", "@metamask/base-data-service": "^2.0.0", - "@metamask/chomp-api-service": "^4.0.2", + "@metamask/chomp-api-service": "^5.0.0", "@metamask/controller-utils": "^13.0.0", - "@metamask/delegation-controller": "^3.0.2", + "@metamask/delegation-controller": "^4.0.0", "@metamask/delegation-core": "^2.2.1", "@metamask/delegation-deployments": "^1.4.0", "@metamask/messenger": "^3.0.0", - "@metamask/money-account-balance-service": "^2.4.3", - "@metamask/money-account-utils": "^1.2.0", + "@metamask/money-account-balance-service": "^3.0.0", + "@metamask/money-account-utils": "^2.0.0", "@metamask/polling-controller": "^17.0.0", "@metamask/profile-sync-controller": "^31.0.0", - "@metamask/remote-feature-flag-controller": "^6.1.0", + "@metamask/remote-feature-flag-controller": "^7.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/transaction-controller": "^70.0.0", "@metamask/utils": "^11.12.0", diff --git a/yarn.lock b/yarn.lock index 742928ca287..83b6a5ce6bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5610,59 +5610,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/account-tree-controller@npm:^9.0.0": - version: 9.0.0 - resolution: "@metamask/account-tree-controller@npm:9.0.0" - dependencies: - "@metamask/accounts-controller": "npm:^39.1.1" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-api": "npm:^24.0.0" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/multichain-account-service": "npm:^13.0.2" - "@metamask/profile-sync-controller": "npm:^30.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.12.0" - fast-deep-equal: "npm:^3.1.3" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/8c84356d9a4b7d6ede18dc7f793d2782ef021f6164e7e074f67f8432e6a396dd2b96311c80627a34ea455ca2907499d34482cec8b36ccdd36f3826bfbd4e9b5d - languageName: node - linkType: hard - -"@metamask/accounts-controller@npm:^39.1.0, @metamask/accounts-controller@npm:^39.1.1": - version: 39.1.1 - resolution: "@metamask/accounts-controller@npm:39.1.1" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/eth-snap-keyring": "npm:^24.0.0" - "@metamask/keyring-api": "npm:^24.0.0" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/keyring-internal-api": "npm:^12.0.0" - "@metamask/keyring-sdk": "npm:^3.1.0" - "@metamask/keyring-utils": "npm:^5.0.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/network-controller": "npm:^36.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - deepmerge: "npm:^4.2.2" - ethereum-cryptography: "npm:^2.1.2" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/c0b2a3fccd4e4fc8dc646c26cf575d520b4f80ccdbb9eee5c8c0317b137d6ca5caa5a55954748548e243d31f050119c815c1b9f66659af5498f346fa8e0a13bd - languageName: node - linkType: hard - "@metamask/accounts-controller@npm:^40.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" @@ -5715,18 +5662,6 @@ __metadata: languageName: node linkType: hard -"@metamask/address-book-controller@npm:^7.1.2": - version: 7.1.2 - resolution: "@metamask/address-book-controller@npm:7.1.2" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.0.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/utils": "npm:^11.9.0" - checksum: 10/e15140418452a80d51fec783003144add72a50a4fe362377481de83c7858860f641a3cfb1575f4b802b1eb1e873b3efd03bb4183afcbee77f95c7525e05aaed4 - languageName: node - linkType: hard - "@metamask/address-book-controller@npm:^8.0.0, @metamask/address-book-controller@workspace:packages/address-book-controller": version: 0.0.0-use.local resolution: "@metamask/address-book-controller@workspace:packages/address-book-controller" @@ -5771,20 +5706,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/analytics-controller@npm:^2.0.0": - version: 2.1.0 - resolution: "@metamask/analytics-controller@npm:2.1.0" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/geolocation-controller": "npm:^1.0.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/utils": "npm:^11.12.0" - lodash: "npm:^4.17.21" - uuid: "npm:^8.3.2" - checksum: 10/4d98606b3808ef214582dd256de4694330260f72a08541f54b9c3bd51e8e55cf95c4455a87a4d1f216bdb71b8d18412873bb75ae1fcfc4103f8f2c4390051f6e - languageName: node - linkType: hard - "@metamask/analytics-controller@npm:^3.0.0, @metamask/analytics-controller@workspace:packages/analytics-controller": version: 0.0.0-use.local resolution: "@metamask/analytics-controller@workspace:packages/analytics-controller" @@ -5903,7 +5824,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/approval-controller@npm:^9.0.1, @metamask/approval-controller@npm:^9.0.2": +"@metamask/approval-controller@npm:^9.0.1": version: 9.0.2 resolution: "@metamask/approval-controller@npm:9.0.2" dependencies: @@ -6081,19 +6002,6 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@npm:^3.0.2": - version: 3.0.2 - resolution: "@metamask/authenticated-user-storage@npm:3.0.2" - dependencies: - "@metamask/base-data-service": "npm:^1.0.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - checksum: 10/b368bce476e7d56af7b0bb321c4885bc1f4a60a9bee3a97234aa37fdae016cf072eee5eb010ef7c272b90c41574e0e661503bb4519738facbc4adbd781ffc967 - languageName: node - linkType: hard - "@metamask/authenticated-user-storage@npm:^4.0.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": version: 0.0.0-use.local resolution: "@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage" @@ -6173,22 +6081,6 @@ __metadata: languageName: node linkType: hard -"@metamask/base-data-service@npm:^1.0.0": - version: 1.0.0 - resolution: "@metamask/base-data-service@npm:1.0.0" - dependencies: - "@metamask/messenger": "npm:^2.0.0" - "@metamask/storage-service": "npm:^1.0.2" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" - cockatiel: "npm:^3.1.2" - fast-deep-equal: "npm:^3.1.3" - lodash: "npm:^4.17.21" - checksum: 10/c965dc930a3c172c71624278499f4ec2da829def684c3923b2729d1a31dd2635d5c05230a9dc9ea8546adc029c4e2ed782fdc992be4692d47d9aafdb2d778deb - languageName: node - linkType: hard - "@metamask/base-data-service@npm:^2.0.0, @metamask/base-data-service@workspace:packages/base-data-service": version: 0.0.0-use.local resolution: "@metamask/base-data-service@workspace:packages/base-data-service" @@ -6377,20 +6269,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chomp-api-service@npm:^4.0.2": - version: 4.0.2 - resolution: "@metamask/chomp-api-service@npm:4.0.2" - dependencies: - "@metamask/base-data-service": "npm:^1.0.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.12.0" - "@tanstack/query-core": "npm:^5.62.16" - checksum: 10/c7e8436e3f967ee17bf71b5e555c5b30cdf9031d89c7bcc9829db9575b344b746e2dac9d05ed251a1640aec5834c89cf0fd4b0316ec4060bcfac0ae02c5fa0ea - languageName: node - linkType: hard - "@metamask/chomp-api-service@npm:^5.0.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": version: 0.0.0-use.local resolution: "@metamask/chomp-api-service@workspace:packages/chomp-api-service" @@ -6538,23 +6416,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/config-registry-controller@npm:^3.1.0": - version: 3.1.0 - resolution: "@metamask/config-registry-controller@npm:3.1.0" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/polling-controller": "npm:^16.0.9" - "@metamask/remote-feature-flag-controller": "npm:^6.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - reselect: "npm:^5.1.1" - checksum: 10/ffbe18c5ac682eadf0966acd3316d0e2e31cd223de81225ce1ece43110b094c22bc6318cc20f7f8c9663e9322e0ef21938ba71e1eab0abeece382933d56eeaed - languageName: node - linkType: hard - "@metamask/config-registry-controller@npm:^4.0.0, @metamask/config-registry-controller@workspace:packages/config-registry-controller": version: 0.0.0-use.local resolution: "@metamask/config-registry-controller@workspace:packages/config-registry-controller" @@ -6584,17 +6445,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/connectivity-controller@npm:^0.3.0": - version: 0.3.0 - resolution: "@metamask/connectivity-controller@npm:0.3.0" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/messenger": "npm:^2.0.0" - reselect: "npm:^5.1.1" - checksum: 10/4751d3e4725f6d27d0e91e9213edc5855ab10ddd794200af6095a6c4b9db1c2d5b5ed8c4ec812002a29e0e141fa809e042c8002039f02a64c4dc33b53c2c9945 - languageName: node - linkType: hard - "@metamask/connectivity-controller@npm:^1.0.0, @metamask/connectivity-controller@workspace:packages/connectivity-controller": version: 0.0.0-use.local resolution: "@metamask/connectivity-controller@workspace:packages/connectivity-controller" @@ -6644,7 +6494,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.3.0": +"@metamask/controller-utils@npm:^12.0.0": version: 12.3.0 resolution: "@metamask/controller-utils@npm:12.3.0" dependencies: @@ -6733,25 +6583,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/core-backend@npm:^9.0.0": - version: 9.1.1 - resolution: "@metamask/core-backend@npm:9.1.1" - dependencies: - "@metamask/account-tree-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/profile-sync-controller": "npm:^30.0.0" - "@metamask/remote-feature-flag-controller": "npm:^6.1.1" - "@metamask/utils": "npm:^11.12.0" - "@tanstack/query-core": "npm:^5.62.16" - async-mutex: "npm:^0.5.0" - cockatiel: "npm:^3.1.2" - uuid: "npm:^8.3.2" - checksum: 10/5070ec5d8cc2b5eca2809df83d927f23e062bc660118972c0bec90197ac275c82de0f5ce1ab6559366a924b2df418a5831b66d3cf496c83b27e90534ddee5edb - languageName: node - linkType: hard - "@metamask/core-monorepo@workspace:.": version: 0.0.0-use.local resolution: "@metamask/core-monorepo@workspace:." @@ -6866,18 +6697,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/delegation-controller@npm:^3.0.2": - version: 3.0.2 - resolution: "@metamask/delegation-controller@npm:3.0.2" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-controller": "npm:^27.0.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/utils": "npm:^11.9.0" - checksum: 10/d6c11c8edea96b72f0411c347cb5aa7b05b123bd688d6e68297aacb1db9a37ef4d2f680cfa6e59b44d66046df95b5fed6d454122b2559085eedea8f1a63e36d8 - languageName: node - linkType: hard - "@metamask/delegation-controller@npm:^4.0.0, @metamask/delegation-controller@workspace:packages/delegation-controller": version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" @@ -7085,18 +6904,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^15.0.1": - version: 15.0.1 - resolution: "@metamask/eth-block-tracker@npm:15.0.1" - dependencies: - "@metamask/eth-json-rpc-provider": "npm:^6.0.0" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.9.0" - json-rpc-random-id: "npm:^1.0.1" - checksum: 10/8c0e0f5bde00fb40aa240fc562c29d0ce84156402132705d387c95453c6f265b3b738c9b649510771965d8b5b8036c2bdc6406142ff634383bf60df3741bdbfe - languageName: node - linkType: hard - "@metamask/eth-block-tracker@npm:^16.0.0, @metamask/eth-block-tracker@workspace:packages/eth-block-tracker": version: 0.0.0-use.local resolution: "@metamask/eth-block-tracker@workspace:packages/eth-block-tracker" @@ -7165,24 +6972,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^24.0.0, @metamask/eth-json-rpc-middleware@npm:^24.0.1": - version: 24.0.2 - resolution: "@metamask/eth-json-rpc-middleware@npm:24.0.2" - dependencies: - "@metamask/eth-block-tracker": "npm:^15.0.1" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" - "@metamask/eth-sig-util": "npm:^9.0.0" - "@metamask/json-rpc-engine": "npm:^10.5.0" - "@metamask/message-manager": "npm:^14.1.2" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - klona: "npm:^2.0.6" - safe-stable-stringify: "npm:^2.4.3" - checksum: 10/f01d71878e994b43a50f97e9da67373167a1f3e02343f16a692ee91ae99348336a278273c91d1c1c09203bc5c158390ba664bd8bd6a7b906138b922c75b30922 - languageName: node - linkType: hard - "@metamask/eth-json-rpc-middleware@npm:^25.0.0, @metamask/eth-json-rpc-middleware@workspace:packages/eth-json-rpc-middleware": version: 0.0.0-use.local resolution: "@metamask/eth-json-rpc-middleware@workspace:packages/eth-json-rpc-middleware" @@ -7225,18 +7014,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^6.0.0, @metamask/eth-json-rpc-provider@npm:^6.0.1": - version: 6.0.1 - resolution: "@metamask/eth-json-rpc-provider@npm:6.0.1" - dependencies: - "@metamask/json-rpc-engine": "npm:^10.2.4" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.9.0" - nanoid: "npm:^3.3.8" - checksum: 10/06078a9e43b02f35387a3ccfe09733c7eeac2a732dee1f1be53254fc05719e230776b8512b13702a178fb692088fc7da46f727c5064550d65f51ac59d44f9d83 - languageName: node - linkType: hard - "@metamask/eth-json-rpc-provider@npm:^7.0.0, @metamask/eth-json-rpc-provider@workspace:packages/eth-json-rpc-provider": version: 0.0.0-use.local resolution: "@metamask/eth-json-rpc-provider@workspace:packages/eth-json-rpc-provider" @@ -7533,28 +7310,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/gas-fee-controller@npm:^26.3.2": - version: 26.3.2 - resolution: "@metamask/gas-fee-controller@npm:26.3.2" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/ethjs-unit": "npm:^0.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/network-controller": "npm:^36.0.0" - "@metamask/polling-controller": "npm:^16.0.9" - "@metamask/utils": "npm:^11.11.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - bn.js: "npm:^5.2.1" - uuid: "npm:^8.3.2" - peerDependencies: - "@babel/runtime": ^7.0.0 - checksum: 10/8bd8d4c925f4564f5361c733f77cff81f6bf1fdcc3c097df4d732d09375e78d4fb8d3891fe99b2edde87f484f9824a70f230dc354ead79462d295278d424c36a - languageName: node - linkType: hard - "@metamask/gas-fee-controller@npm:^27.0.0, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": version: 0.0.0-use.local resolution: "@metamask/gas-fee-controller@workspace:packages/gas-fee-controller" @@ -7623,17 +7378,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/geolocation-controller@npm:^1.0.0": - version: 1.0.0 - resolution: "@metamask/geolocation-controller@npm:1.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - checksum: 10/84dab8e1ffa732fdaac75bfc9288c3ca4f1e340125450c444d9362710f6c9d9cf299926aa54b3eaa9fc8d01e7ee15261472b4a43e3726b1d1c1b161d503df804 - languageName: node - linkType: hard - "@metamask/geolocation-controller@npm:^2.0.0, @metamask/geolocation-controller@workspace:packages/geolocation-controller": version: 0.0.0-use.local resolution: "@metamask/geolocation-controller@workspace:packages/geolocation-controller" @@ -7779,30 +7523,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^27.0.0, @metamask/keyring-controller@npm:^27.1.0, @metamask/keyring-controller@npm:^27.1.1": - version: 27.1.1 - resolution: "@metamask/keyring-controller@npm:27.1.1" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/browser-passworder": "npm:^6.0.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-hd-keyring": "npm:^15.0.0" - "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/eth-simple-keyring": "npm:^13.0.0" - "@metamask/keyring-api": "npm:^24.0.0" - "@metamask/keyring-internal-api": "npm:^12.0.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/utils": "npm:^11.11.0" - async-mutex: "npm:^0.5.0" - ethereumjs-wallet: "npm:^1.0.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - ulid: "npm:^2.3.0" - checksum: 10/c4a1a3b40f98d179dfadba128c9135e795a7440a6d2cc5cd107644716e931cd71d6e7407211cb5eacd2ffb37e38d22f48d4123da0ed6b462e10a59a902fe0ece - languageName: node - linkType: hard - "@metamask/keyring-controller@npm:^28.0.0, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" @@ -8008,22 +7728,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/message-manager@npm:^14.1.2": - version: 14.1.2 - resolution: "@metamask/message-manager@npm:14.1.2" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.0.0" - "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/utils": "npm:^11.9.0" - "@types/uuid": "npm:^8.3.0" - jsonschema: "npm:^1.4.1" - uuid: "npm:^8.3.2" - checksum: 10/82e3965069f0eb34e2141ca4dff452c118f54502d0b5d6b832af1906c6413ba1d923b13f1ed6af7f8a7b3087ef7f3b1a700bcffa56b10b06df53972ff2cf9a4f - languageName: node - linkType: hard - "@metamask/message-manager@npm:^15.0.0, @metamask/message-manager@workspace:packages/message-manager": version: 0.0.0-use.local resolution: "@metamask/message-manager@workspace:packages/message-manager" @@ -8098,17 +7802,6 @@ __metadata: languageName: node linkType: hard -"@metamask/messenger@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/messenger@npm:2.0.0" - dependencies: - "@metamask/utils": "npm:^11.11.0" - peerDependencies: - typescript: ">=5.0.0" - checksum: 10/c387511edd89db73b774f631fb4bb382c9254af2dd7f80f9bd45f776e618cf90f653cc0e9744b1bf491f71f99c8318b16e8230a847f619a4fca655ea3f6a08e1 - languageName: node - linkType: hard - "@metamask/messenger@npm:^3.0.0, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" @@ -8139,20 +7832,6 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-api-data-service@npm:^0.4.1": - version: 0.4.1 - resolution: "@metamask/money-account-api-data-service@npm:0.4.1" - dependencies: - "@metamask/base-data-service": "npm:^1.0.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" - checksum: 10/a5b66f4bc5f0c1e98ac35b430ce114eef0208ecc50b3c141608f4020755dc4f07a5aa3b716cb6090748c9bbc61404b7202d4a0df913582669debcf8e37d61e06 - languageName: node - linkType: hard - "@metamask/money-account-api-data-service@npm:^1.0.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": version: 0.0.0-use.local resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" @@ -8178,26 +7857,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-balance-service@npm:^2.4.3": - version: 2.4.3 - resolution: "@metamask/money-account-balance-service@npm:2.4.3" - dependencies: - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/base-data-service": "npm:^1.0.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/money-account-api-data-service": "npm:^0.4.1" - "@metamask/network-controller": "npm:^36.0.0" - "@metamask/remote-feature-flag-controller": "npm:^6.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - checksum: 10/2c46287ae2097be0772218efa120c2a352df070207c891d4c8e4bd30b7bd1eb8907a8c71f5af931e8cf0e439b468066ca41f1e6cef29587a95c51ec9619bcca1 - languageName: node - linkType: hard - -"@metamask/money-account-balance-service@workspace:packages/money-account-balance-service": +"@metamask/money-account-balance-service@npm:^3.0.0, @metamask/money-account-balance-service@workspace:packages/money-account-balance-service": version: 0.0.0-use.local resolution: "@metamask/money-account-balance-service@workspace:packages/money-account-balance-service" dependencies: @@ -8284,19 +7944,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-utils@npm:^1.2.0": - version: 1.2.0 - resolution: "@metamask/money-account-utils@npm:1.2.0" - dependencies: - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/abstract-provider": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/transaction-controller": "npm:^69.8.0" - "@metamask/utils": "npm:^11.12.0" - checksum: 10/8169a81a86e09be763d0ac00d3b599425380803e30dbd4770b0676995677ca9a553fc80102a4784f0a2532ab02bc03c103cd3f43ce8a156e71704162af0c5070 - languageName: node - linkType: hard - "@metamask/money-account-utils@npm:^2.0.0, @metamask/money-account-utils@workspace:packages/money-account-utils": version: 0.0.0-use.local resolution: "@metamask/money-account-utils@workspace:packages/money-account-utils" @@ -8320,37 +7967,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-account-service@npm:^13.0.2": - version: 13.0.2 - resolution: "@metamask/multichain-account-service@npm:13.0.2" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@metamask/account-api": "npm:^2.0.0" - "@metamask/accounts-controller": "npm:^39.1.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/eth-snap-keyring": "npm:^24.0.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-api": "npm:^24.0.0" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/keyring-internal-api": "npm:^12.0.0" - "@metamask/keyring-snap-client": "npm:^10.0.0" - "@metamask/keyring-utils": "npm:^5.0.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/snap-account-service": "npm:^2.1.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^11.11.0" - async-mutex: "npm:^0.5.0" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/43f554ee1e58d9700b7f5d47b3e194262d8f8192a6298f177fc29c670efc0c3f659f40b4fbedcfeb9a14b894e5d7d2b59c051d9222a899648e1923f362d83bd5 - languageName: node - linkType: hard - "@metamask/multichain-account-service@npm:^14.0.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" @@ -8549,65 +8165,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-controller@npm:^35.0.0": - version: 35.0.1 - resolution: "@metamask/network-controller@npm:35.0.1" - dependencies: - "@metamask/analytics-controller": "npm:^2.0.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/connectivity-controller": "npm:^0.3.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-block-tracker": "npm:^15.0.1" - "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^24.0.0" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.5.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/remote-feature-flag-controller": "npm:^5.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.11.0" - fast-deep-equal: "npm:^3.1.3" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - reselect: "npm:^5.1.1" - uri-js: "npm:^4.4.1" - uuid: "npm:^8.3.2" - checksum: 10/351df792507a0868c365cb3da5aadf831ea692fee7965a1f156698ab1086224b85cf0942cac2f77a02b4eece85e00c3dd2c3172546f295107a64fc0289f04b48 - languageName: node - linkType: hard - -"@metamask/network-controller@npm:^36.0.0": - version: 36.0.0 - resolution: "@metamask/network-controller@npm:36.0.0" - dependencies: - "@metamask/analytics-controller": "npm:^2.0.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/config-registry-controller": "npm:^3.1.0" - "@metamask/connectivity-controller": "npm:^0.3.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/eth-block-tracker": "npm:^15.0.1" - "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^24.0.1" - "@metamask/eth-json-rpc-provider": "npm:^6.0.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.5.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/remote-feature-flag-controller": "npm:^6.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.11.0" - fast-deep-equal: "npm:^3.1.3" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - reselect: "npm:^5.1.1" - uri-js: "npm:^4.4.1" - uuid: "npm:^8.3.2" - checksum: 10/bdc6fa528a5c48c4121ef4c67807c40d835260b58ce2f19958668b1d55a9f99275f5fef61a414c7a3fcfd3d4b7fd0d55fd2708c280394c35342215a3384bbdc5 - languageName: node - linkType: hard - "@metamask/network-controller@npm:^37.0.0, @metamask/network-controller@workspace:packages/network-controller": version: 0.0.0-use.local resolution: "@metamask/network-controller@workspace:packages/network-controller" @@ -8985,20 +8542,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/polling-controller@npm:^16.0.9": - version: 16.0.9 - resolution: "@metamask/polling-controller@npm:16.0.9" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/network-controller": "npm:^35.0.0" - "@metamask/utils": "npm:^11.11.0" - "@types/uuid": "npm:^8.3.0" - fast-json-stable-stringify: "npm:^2.1.0" - uuid: "npm:^8.3.2" - checksum: 10/997f409424f8daffead5a9e5376bc174a474e9cf9b021aee9279337e7ec537105239d19351663244bae89c7a6efefc703cfba3a524780d3527284fd3fe49610a - languageName: node - linkType: hard - "@metamask/polling-controller@npm:^17.0.0, @metamask/polling-controller@workspace:packages/polling-controller": version: 0.0.0-use.local resolution: "@metamask/polling-controller@workspace:packages/polling-controller" @@ -9089,30 +8632,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^30.0.0": - version: 30.0.0 - resolution: "@metamask/profile-sync-controller@npm:30.0.0" - dependencies: - "@metamask/address-book-controller": "npm:^7.1.2" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/seedless-onboarding-controller": "npm:^10.1.1" - "@metamask/utils": "npm:^11.12.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.2" - "@noble/hashes": "npm:^1.8.0" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - siwe: "npm:^2.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/3af06bd1b7b78f45c4fabf4353bc79af7d9be5a2b3e9e416de35d282996b32cfa3d36636ad7edfa5a6751d0ba8a6e308f814410ca846bdcb05fbb855fffcc0d2 - languageName: node - linkType: hard - "@metamask/profile-sync-controller@npm:^31.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" @@ -9256,32 +8775,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/remote-feature-flag-controller@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/remote-feature-flag-controller@npm:5.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/utils": "npm:^11.11.0" - uuid: "npm:^8.3.2" - checksum: 10/8d8b713def28721ade47a9106aef22d885c1d41006ad558b26ae38fffeda8a57e7a972fcbd87f73c2f1fdfdd2a53a9d9d716654770671aca245398684fcb7a52 - languageName: node - linkType: hard - -"@metamask/remote-feature-flag-controller@npm:^6.0.0, @metamask/remote-feature-flag-controller@npm:^6.1.0, @metamask/remote-feature-flag-controller@npm:^6.1.1": - version: 6.1.1 - resolution: "@metamask/remote-feature-flag-controller@npm:6.1.1" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/utils": "npm:^11.12.0" - uuid: "npm:^8.3.2" - checksum: 10/bf244c96c84a4e676514f4287bf02ce87a3b6c9436fb1d217523902d85a975d9ec2a14ae5bdb81579116f8fb35d2a13376617c98f7b49ca9efeef2ca00bf4d7f - languageName: node - linkType: hard - "@metamask/remote-feature-flag-controller@npm:^7.0.0, @metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller": version: 0.0.0-use.local resolution: "@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller" @@ -9361,25 +8854,6 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^10.1.1": - version: 10.1.1 - resolution: "@metamask/seedless-onboarding-controller@npm:10.1.1" - dependencies: - "@metamask/auth-network-utils": "npm:^0.3.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/browser-passworder": "npm:^6.0.0" - "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/toprf-secure-backup": "npm:^1.1.0" - "@metamask/utils": "npm:^11.11.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.2" - "@noble/hashes": "npm:^1.8.0" - async-mutex: "npm:^0.5.0" - checksum: 10/9f1ac407aa467fd17ce9809da1d46a46440a57c201c759e2cc630a52855193852acec7eee72ef82f21c15dfcead6d40d1a7609edf334b3e269792396347ba80e - languageName: node - linkType: hard - "@metamask/seedless-onboarding-controller@npm:^11.0.0, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" @@ -9587,25 +9061,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/snap-account-service@npm:^2.1.2": - version: 2.1.2 - resolution: "@metamask/snap-account-service@npm:2.1.2" - dependencies: - "@metamask/account-api": "npm:^2.0.0" - "@metamask/eth-snap-keyring": "npm:^24.0.0" - "@metamask/keyring-api": "npm:^24.0.0" - "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/keyring-internal-snap-client": "npm:^11.0.0" - "@metamask/keyring-snap-sdk": "npm:^10.0.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/utils": "npm:^11.11.0" - lodash: "npm:^4.17.21" - checksum: 10/31be9807cd8fede52d236c2dbeaa1a345540ef3fb010d0a00d4ec7899bdcba97345f29c2338211e433988d13ba7447ea72c703a6840ac3646482ab61ad39f9f1 - languageName: node - linkType: hard - "@metamask/snap-account-service@npm:^3.0.0, @metamask/snap-account-service@workspace:packages/snap-account-service": version: 0.0.0-use.local resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" @@ -9823,7 +9278,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/storage-service@npm:^1.0.1, @metamask/storage-service@npm:^1.0.2": +"@metamask/storage-service@npm:^1.0.1": version: 1.0.2 resolution: "@metamask/storage-service@npm:1.0.2" dependencies: @@ -9857,21 +9312,21 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: - "@metamask/authenticated-user-storage": "npm:^3.0.2" + "@metamask/authenticated-user-storage": "npm:^4.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^10.0.0" "@metamask/base-data-service": "npm:^2.0.0" - "@metamask/chomp-api-service": "npm:^4.0.2" + "@metamask/chomp-api-service": "npm:^5.0.0" "@metamask/controller-utils": "npm:^13.0.0" - "@metamask/delegation-controller": "npm:^3.0.2" + "@metamask/delegation-controller": "npm:^4.0.0" "@metamask/delegation-core": "npm:^2.2.1" "@metamask/delegation-deployments": "npm:^1.4.0" "@metamask/messenger": "npm:^3.0.0" - "@metamask/money-account-balance-service": "npm:^2.4.3" - "@metamask/money-account-utils": "npm:^1.2.0" + "@metamask/money-account-balance-service": "npm:^3.0.0" + "@metamask/money-account-utils": "npm:^2.0.0" "@metamask/polling-controller": "npm:^17.0.0" "@metamask/profile-sync-controller": "npm:^31.0.0" - "@metamask/remote-feature-flag-controller": "npm:^6.1.0" + "@metamask/remote-feature-flag-controller": "npm:^7.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/transaction-controller": "npm:^70.0.0" "@metamask/utils": "npm:^11.12.0" @@ -9924,45 +9379,6 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^69.8.0": - version: 69.8.1 - resolution: "@metamask/transaction-controller@npm:69.8.1" - dependencies: - "@ethereumjs/common": "npm:^4.4.0" - "@ethereumjs/rlp": "npm:^5.0.2" - "@ethereumjs/tx": "npm:^5.4.0" - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.1.1" - "@metamask/approval-controller": "npm:^9.0.2" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^9.0.0" - "@metamask/gas-fee-controller": "npm:^26.3.2" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^36.0.0" - "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^6.1.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.12.0" - async-mutex: "npm:^0.5.0" - bignumber.js: "npm:^9.1.2" - bn.js: "npm:^5.2.1" - eth-method-registry: "npm:^4.0.0" - ethereum-cryptography: "npm:^2.1.2" - fast-json-patch: "npm:^3.1.1" - lodash: "npm:^4.17.21" - uuid: "npm:^8.3.2" - peerDependencies: - "@babel/runtime": ^7.0.0 - "@metamask/eth-block-tracker": ">=9" - checksum: 10/e7bbbce3ad2dae517a1f3d2fd16f1ee7b738235b2a2297a179300b13e541468b658cf624e657a73ba4d4111dab8473cf971d6f47bc1b920a3844a295621a3bcd - languageName: node - linkType: hard - "@metamask/transaction-controller@npm:^70.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" @@ -11970,51 +11386,6 @@ __metadata: languageName: node linkType: hard -"@spruceid/siwe-parser@npm:^2.1.2": - version: 2.1.2 - resolution: "@spruceid/siwe-parser@npm:2.1.2" - dependencies: - "@noble/hashes": "npm:^1.1.2" - apg-js: "npm:^4.3.0" - uri-js: "npm:^4.4.1" - valid-url: "npm:^1.0.9" - checksum: 10/48459fe3b4d4b3091375ee87af700864c9023d4a1271d34850c6d27475e5d93a45d1efe8a71da367ad838b6921ced60c387d54737edd0a7a0d8e4e0a3cc2b8b7 - languageName: node - linkType: hard - -"@stablelib/binary@npm:^1.0.1": - version: 1.0.1 - resolution: "@stablelib/binary@npm:1.0.1" - dependencies: - "@stablelib/int": "npm:^1.0.1" - checksum: 10/c5ed769e2b5d607a5cdb72d325fcf98db437627862fade839daad934bd9ccf02a6f6e34f9de8cb3b18d72fce2ba6cc019a5d22398187d7d69d2607165f27f8bf - languageName: node - linkType: hard - -"@stablelib/int@npm:^1.0.1": - version: 1.0.1 - resolution: "@stablelib/int@npm:1.0.1" - checksum: 10/65bfbf50a382eea70c68e05366bf379cfceff8fbc076f1c267ef2f2411d7aed64fd140c415cb6c29f19a3910d3b8b7805d4b32ad5721a5007a8e744a808c7ae3 - languageName: node - linkType: hard - -"@stablelib/random@npm:^1.0.1": - version: 1.0.2 - resolution: "@stablelib/random@npm:1.0.2" - dependencies: - "@stablelib/binary": "npm:^1.0.1" - "@stablelib/wipe": "npm:^1.0.1" - checksum: 10/f5ace0a588dc4c21f01cb85837892d4c872e994ae77a58a8eb7dd61aa0b26fb1e9b46b0445e71af57d963ef7d9f5965c64258fc0d04df7b2947bc48f2d3560c5 - languageName: node - linkType: hard - -"@stablelib/wipe@npm:^1.0.1": - version: 1.0.1 - resolution: "@stablelib/wipe@npm:1.0.1" - checksum: 10/287802eb146810a46ba72af70b82022caf83a8aeebde23605f5ee0decf64fe2b97a60c856e43b6617b5801287c30cfa863cfb0469e7fcde6f02d143cf0c6cbf4 - languageName: node - linkType: hard - "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0": version: 8.0.0 resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0" @@ -13008,13 +12379,6 @@ __metadata: languageName: node linkType: hard -"@types/uuid@npm:^8.3.0": - version: 8.3.4 - resolution: "@types/uuid@npm:8.3.4" - checksum: 10/6f11f3ff70f30210edaa8071422d405e9c1d4e53abbe50fdce365150d3c698fe7bbff65c1e71ae080cbfb8fded860dbb5e174da96fdbbdfcaa3fb3daa474d20f - languageName: node - linkType: hard - "@types/uuid@npm:^9.0.8": version: 9.0.8 resolution: "@types/uuid@npm:9.0.8" @@ -14691,7 +14055,7 @@ __metadata: languageName: node linkType: hard -"apg-js@npm:^4.1.1, apg-js@npm:^4.3.0, apg-js@npm:^4.4.0": +"apg-js@npm:^4.1.1, apg-js@npm:^4.4.0": version: 4.4.0 resolution: "apg-js@npm:4.4.0" checksum: 10/425f19096026742f5f156f26542b68f55602aa60f0c4ae2d72a0a888cf15fe9622223191202262dd8979d76a6125de9d8fd164d56c95fb113f49099f405eb08c @@ -26655,20 +26019,6 @@ __metadata: languageName: node linkType: hard -"siwe@npm:^2.3.2": - version: 2.3.2 - resolution: "siwe@npm:2.3.2" - dependencies: - "@spruceid/siwe-parser": "npm:^2.1.2" - "@stablelib/random": "npm:^1.0.1" - uri-js: "npm:^4.4.1" - valid-url: "npm:^1.0.9" - peerDependencies: - ethers: ^5.6.8 || ^6.0.8 - checksum: 10/6ea5ad9a9046fa916f85bf9d3092bc898f7e339d9c552714ea53ecc17daa4f78300c3cf7cc9c70fe57baf77dcee5cb38c6e1d692400b874cd84d297b1261918c - languageName: node - linkType: hard - "skin-tone@npm:^2.0.0": version: 2.0.0 resolution: "skin-tone@npm:2.0.0" From 5209222c95c718cc910bf8ab3260f3a3c435178b Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:52:08 +0700 Subject: [PATCH 19/23] chore: dedupe --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 83b6a5ce6bd..f6af0cb221a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12051,20 +12051,13 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:*": +"@types/lodash@npm:*, @types/lodash@npm:^4.14.191, @types/lodash@npm:^4.17.20": version: 4.17.25 resolution: "@types/lodash@npm:4.17.25" checksum: 10/f786e05664439cc8d327fd97c62c060d81b3c4ed52127d01fbc926139e1a669c1554bf3cd0dbb43f8fcc7d9b5c6c23c543cc36de8dc815c9f289ea852b26ebd6 languageName: node linkType: hard -"@types/lodash@npm:^4.14.191, @types/lodash@npm:^4.17.20": - version: 4.17.20 - resolution: "@types/lodash@npm:4.17.20" - checksum: 10/8cd8ad3bd78d2e06a93ae8d6c9907981d5673655fec7cb274a4d9a59549aab5bb5b3017361280773b8990ddfccf363e14d1b37c97af8a9fe363de677f9a61524 - languageName: node - linkType: hard - "@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.2": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4" From 44bda61541f62bfb8902596ffd93337762d12c3e Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 16:56:47 +0700 Subject: [PATCH 20/23] chore: update Jest configuration and add custom test environment for Web Crypto API support --- packages/subscription-controller/jest.config.cjs | 2 +- .../{jest.environment.js => jest.environment.cjs} | 5 ++--- .../src/subscription-delegation/caveats.ts | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) rename packages/subscription-controller/{jest.environment.js => jest.environment.cjs} (72%) diff --git a/packages/subscription-controller/jest.config.cjs b/packages/subscription-controller/jest.config.cjs index ac7ac21def7..15106a1ac2a 100644 --- a/packages/subscription-controller/jest.config.cjs +++ b/packages/subscription-controller/jest.config.cjs @@ -14,7 +14,7 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, - testEnvironment: '/jest.environment.js', + testEnvironment: '/jest.environment.cjs', // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { diff --git a/packages/subscription-controller/jest.environment.js b/packages/subscription-controller/jest.environment.cjs similarity index 72% rename from packages/subscription-controller/jest.environment.js rename to packages/subscription-controller/jest.environment.cjs index 8610679374f..4d37416826b 100644 --- a/packages/subscription-controller/jest.environment.js +++ b/packages/subscription-controller/jest.environment.cjs @@ -9,9 +9,8 @@ class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); if (typeof this.global.crypto === 'undefined') { - // Only used for testing. - // eslint-disable-next-line n/no-unsupported-features/node-builtins - this.global.crypto = require('crypto').webcrypto; + const { webcrypto } = require('crypto'); + this.global.crypto = webcrypto; } } } diff --git a/packages/subscription-controller/src/subscription-delegation/caveats.ts b/packages/subscription-controller/src/subscription-delegation/caveats.ts index 76172f27926..ac283f937ed 100644 --- a/packages/subscription-controller/src/subscription-delegation/caveats.ts +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -30,7 +30,6 @@ export type BuildSubscriptionCaveatsParams = { * * @param params - Enforcer addresses, parties, and period terms. * @param params.enforcers - Delegation Framework enforcer addresses. - * @param params.delegateAddress - Sole permitted redeemer. * @param params.tokenAddress - Subscription settlement token. * @param params.periodAmount - Maximum token amount per period. * @param params.periodDuration - Period length in seconds. From 7a748933d42e83f46adc9f6a70e8b09d3741473b Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 17:00:37 +0700 Subject: [PATCH 21/23] chore: update readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 217db0a052c..fa316970e32 100644 --- a/README.md +++ b/README.md @@ -622,12 +622,18 @@ linkStyle default opacity:0.5 social_controllers --> profile_sync_controller; solana_test_validator_up --> local_node_utils; storage_service --> messenger; + subscription_controller --> authenticated_user_storage; subscription_controller --> base_controller; subscription_controller --> base_data_service; + subscription_controller --> chomp_api_service; subscription_controller --> controller_utils; + subscription_controller --> delegation_controller; subscription_controller --> messenger; + subscription_controller --> money_account_balance_service; + subscription_controller --> money_account_utils; subscription_controller --> polling_controller; subscription_controller --> profile_sync_controller; + subscription_controller --> remote_feature_flag_controller; subscription_controller --> transaction_controller; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; From c89c65ec16d0c241d2301248b464711fd306951b Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 17:18:50 +0700 Subject: [PATCH 22/23] chore: update comment --- .../SubscriptionDelegationService-method-action-types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index 5192f0d8366..630345e8e57 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -22,8 +22,10 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { * * Reuses a stored AUS delegation that matches the semantic fingerprint when * one exists (ensuring a CHOMP intent is active for its hash, unless - * `skipChompInteractions` is true). Otherwise builds, signs, optionally - * verifies with CHOMP, persists, and optionally registers a new delegation. + * `skipChompInteractions` is true). Period `startDate` must still be + * trial-deferred when `isTrialRequested` is true, and immediately redeemable + * otherwise. If there is no match, builds, signs, optionally verifies with + * CHOMP, persists, and optionally registers a new delegation. * * When `skipChompInteractions` is true (required for alpha), CHOMP verify * and intent calls are skipped; the returned hash is computed locally. The From cfb927be0ab095aa8ec56a64f571d9dd305aabb9 Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 10 Sep 2026 22:53:36 +0700 Subject: [PATCH 23/23] fix: trial requested 0 days --- ...onDelegationService-method-action-types.ts | 8 ++-- .../SubscriptionDelegationService.test.ts | 38 +++++++++++++++++++ .../SubscriptionDelegationService.ts | 11 +++--- .../fingerprint.test.ts | 12 +++--- .../subscription-delegation/fingerprint.ts | 10 ++--- 5 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts index 630345e8e57..fc5646c87d0 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -22,10 +22,10 @@ export type SubscriptionDelegationServiceCheckMoneyAccountBalanceAction = { * * Reuses a stored AUS delegation that matches the semantic fingerprint when * one exists (ensuring a CHOMP intent is active for its hash, unless - * `skipChompInteractions` is true). Period `startDate` must still be - * trial-deferred when `isTrialRequested` is true, and immediately redeemable - * otherwise. If there is no match, builds, signs, optionally verifies with - * CHOMP, persists, and optionally registers a new delegation. + * `skipChompInteractions` is true). Reuse classifies period `startDate` as + * trial-deferred (`> now`) vs immediately redeemable, matching creation. + * If there is no match, builds, signs, optionally verifies with CHOMP, + * persists, and optionally registers a new delegation. * * When `skipChompInteractions` is true (required for alpha), CHOMP verify * and intent calls are skipped; the returned hash is computed locally. The diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts index 28770db4090..b19797e0938 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -629,6 +629,44 @@ describe('SubscriptionDelegationService', () => { expect(mocks.signDelegation).not.toHaveBeenCalled(); }); + it('reuses an immediately redeemable delegation when trialPeriodDays is 0', async () => { + const stored = buildStoredDelegation({ + startDate: Math.floor(Date.now() / 1000), + }); + const { service, mocks } = setup({ + listDelegations: [stored], + intents: [ + { + account: PAYER, + delegationHash: stored.metadata.delegationHash, + chainId: CHAIN_ID, + status: 'active', + metadata: stored.metadata, + }, + ], + pricing: { + ...PRICING, + products: [ + { + name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + prices: [{ ...PRICE, trialPeriodDays: 0 }], + }, + ], + }, + }); + + const result = await service.prepareDelegation({ + ...REQUEST, + isTrialRequested: true, + }); + + expect(result).toStrictEqual({ + delegationHash: stored.metadata.delegationHash, + disposition: 'reused', + }); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + }); + it('reuses a matching delegation without CHOMP when skipChompInteractions is true', async () => { const stored = buildStoredDelegation(); const { service, mocks } = setup({ diff --git a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts index 487733f135c..58a4d1dbfda 100644 --- a/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -231,10 +231,10 @@ export class SubscriptionDelegationService { * * Reuses a stored AUS delegation that matches the semantic fingerprint when * one exists (ensuring a CHOMP intent is active for its hash, unless - * `skipChompInteractions` is true). Period `startDate` must still be - * trial-deferred when `isTrialRequested` is true, and immediately redeemable - * otherwise. If there is no match, builds, signs, optionally verifies with - * CHOMP, persists, and optionally registers a new delegation. + * `skipChompInteractions` is true). Reuse classifies period `startDate` as + * trial-deferred (`> now`) vs immediately redeemable, matching creation. + * If there is no match, builds, signs, optionally verifies with CHOMP, + * persists, and optionally registers a new delegation. * * When `skipChompInteractions` is true (required for alpha), CHOMP verify * and intent calls are skipped; the returned hash is computed locally. The @@ -287,6 +287,7 @@ export class SubscriptionDelegationService { ? price.trialPeriodDays : undefined, }); + const isTrialDeferred = startDate > nowSeconds; const matches = makeMatchesSubscriptionDelegation({ delegatorAddress: request.payerAddress, @@ -296,7 +297,7 @@ export class SubscriptionDelegationService { periodAmount, periodDuration, nowSeconds, - isTrialRequested: request.isTrialRequested, + isTrialDeferred, enforcers, }); diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts index feb3dd91c16..d58a2e894af 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -110,7 +110,7 @@ const expected = { periodAmount: PERIOD_AMOUNT, periodDuration: PERIOD_DURATION, nowSeconds: NOW_SECONDS, - isTrialRequested: false, + isTrialDeferred: false, enforcers: { valueLte: VALUE_LTE, erc20TokenPeriodTransfer: PERIOD, @@ -136,16 +136,16 @@ describe('makeMatchesSubscriptionDelegation', () => { expect(matches(buildEntry({ startDate: NOW_SECONDS - 86_400 }))).toBe(true); }); - it('rejects a trial-deferred startDate when trial is not requested', () => { + it('rejects a trial-deferred startDate when start is immediately redeemable', () => { expect(matches(buildEntry({ startDate: NOW_SECONDS + 86_400 }))).toBe( false, ); }); - it('matches a deferred startDate when trial is requested', () => { + it('matches a deferred startDate when start is trial-deferred', () => { const matchesTrial = makeMatchesSubscriptionDelegation({ ...expected, - isTrialRequested: true, + isTrialDeferred: true, }); expect(matchesTrial(buildEntry({ startDate: NOW_SECONDS + 86_400 }))).toBe( @@ -153,10 +153,10 @@ describe('makeMatchesSubscriptionDelegation', () => { ); }); - it('rejects an immediately redeemable startDate when trial is requested', () => { + it('rejects an immediately redeemable startDate when start is trial-deferred', () => { const matchesTrial = makeMatchesSubscriptionDelegation({ ...expected, - isTrialRequested: true, + isTrialDeferred: true, }); expect(matchesTrial(buildEntry({ startDate: NOW_SECONDS }))).toBe(false); diff --git a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts index 02805245cb3..7c1dfea5394 100644 --- a/packages/subscription-controller/src/subscription-delegation/fingerprint.ts +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -21,10 +21,10 @@ export type SubscriptionDelegationFingerprint = { */ nowSeconds: number; /** - * When true, only a still-deferred period start matches. When false, only - * an immediately redeemable start matches. + * When true, only a still-deferred period start (`> nowSeconds`) matches. + * When false, only an immediately redeemable start (`<= nowSeconds`) matches. */ - isTrialRequested: boolean; + isTrialDeferred: boolean; enforcers: SubscriptionDelegationEnforcers; }; @@ -44,7 +44,7 @@ export function equalsIgnoreCase(left: string, right: string): boolean { * cash-subscription fingerprint. Salt is ignored so a previously signed * equivalent permission can be reused. Period `startDate` is compared only * as trial-deferred (`> nowSeconds`) vs immediately redeemable (`<= nowSeconds`) - * so a trial request cannot reuse a live permission and vice versa. + * so a positive-length trial cannot reuse a live permission and vice versa. * * @param expected - Semantic fields that must match. * @returns Predicate over {@link DelegationResponse}. @@ -120,7 +120,7 @@ export function makeMatchesSubscriptionDelegation( ); const storedStartDate = Number(periodTerms.startDate); const isStoredDeferred = storedStartDate > expected.nowSeconds; - if (expected.isTrialRequested !== isStoredDeferred) { + if (expected.isTrialDeferred !== isStoredDeferred) { return false; }