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; diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 01519b8e380..711f65e9f87 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.1] ### Changed diff --git a/packages/subscription-controller/jest.config.cjs b/packages/subscription-controller/jest.config.cjs index 6456e074bb0..15106a1ac2a 100644 --- a/packages/subscription-controller/jest.config.cjs +++ b/packages/subscription-controller/jest.config.cjs @@ -14,6 +14,8 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + testEnvironment: '/jest.environment.cjs', + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/subscription-controller/jest.environment.cjs b/packages/subscription-controller/jest.environment.cjs new file mode 100644 index 00000000000..4d37416826b --- /dev/null +++ b/packages/subscription-controller/jest.environment.cjs @@ -0,0 +1,18 @@ +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') { + const { webcrypto } = require('crypto'); + this.global.crypto = webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 27fae9f3790..8304c1d4ff8 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -50,12 +50,20 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/authenticated-user-storage": "^4.0.0", "@metamask/base-controller": "^10.0.0", "@metamask/base-data-service": "^2.0.0", + "@metamask/chomp-api-service": "^5.0.0", "@metamask/controller-utils": "^13.0.0", + "@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": "^3.0.0", + "@metamask/money-account-utils": "^2.0.0", "@metamask/polling-controller": "^17.0.0", "@metamask/profile-sync-controller": "^32.0.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", @@ -69,6 +77,7 @@ "@typescript/native": "npm:typescript@^7.0.2", "deepmerge": "^4.2.2", "jest": "^30.4.2", + "jest-environment-node": "^30.4.1", "rimraf": "^5.0.5", "ts-jest": "^29.4.11", "tsx": "^4.20.5", diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index d01c4d6b6ca..3f9a978383d 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -69,6 +69,23 @@ 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', + 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', + 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', + 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', +} + 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 91d60965aad..ae496ea0d7a 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -128,6 +128,7 @@ export { Env, SubscriptionControllerErrorMessage, SubscriptionServiceErrorMessage, + SubscriptionDelegationServiceErrorMessage, } from './constants.js'; export type { SubscriptionServiceOptions, @@ -160,3 +161,23 @@ 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 { SubscriptionDelegationServiceCheckMoneyAccountBalanceAction } from './subscription-delegation/SubscriptionDelegationService-method-action-types.js'; +export type { + MoneyAccountBalanceCheckRequest, + MoneyAccountBalanceCheckResult, + PrepareSubscriptionDelegationRequest, + PreparedSubscriptionDelegation, +} 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 new file mode 100644 index 00000000000..fc5646c87d0 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService-method-action-types.ts @@ -0,0 +1,49 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +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 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, unless + * `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 + * 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 + * was created or reused. + */ +export type SubscriptionDelegationServicePrepareDelegationAction = { + type: `SubscriptionDelegationService:prepareDelegation`; + handler: SubscriptionDelegationService['prepareDelegation']; +}; + +/** + * Union of all SubscriptionDelegationService action types. + */ +export type SubscriptionDelegationServiceMethodActions = + | SubscriptionDelegationServiceCheckMoneyAccountBalanceAction + | 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..b19797e0938 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.test.ts @@ -0,0 +1,955 @@ +import { + createERC20TokenPeriodTransferTerms, + createRedeemerTerms, + createValueLteTerms, + decodeERC20TokenPeriodTransferTerms, + hashDelegation, + 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 { SubscriptionDelegationServiceErrorMessage } from '../constants.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, + serviceName, +} from './SubscriptionDelegationService.js'; +import type { SubscriptionDelegationServiceMessenger } from './SubscriptionDelegationService.js'; +import type { PrepareSubscriptionDelegationRequest } from './types.js'; +import { CASH_SUBSCRIPTION_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 TOKEN_DECIMALS = 18; +const SIGNATURE: Hex = `0x${'ab'.repeat(65)}`; +const { + ValueLteEnforcer: VALUE_LTE, + ERC20PeriodTransferEnforcer: PERIOD, + RedeemerEnforcer: REDEEMER, +} = DELEGATOR_CONTRACTS['1.3.0'][1]; + +const MONEY_ACCOUNT_VAULT_CONFIG = { + chainId: CHAIN_ID, + boringVault: '0x1111111111111111111111111111111111111111', + tellerAddress: '0x2222222222222222222222222222222222222222', + accountantAddress: '0x6666666666666666666666666666666666666666', + lensAddress: '0x7777777777777777777777777777777777777777', +}; + +const REMOTE_FEATURE_FLAGS: Record = { + moneyAccountVaultConfig: MONEY_ACCOUNT_VAULT_CONFIG, +}; + +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, + isTrialRequested: false, +}; + +const PERIOD_AMOUNT = calculatePeriodAmount({ + 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; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; + getRemoteFeatureFlagState: jest.Mock; + fetchBalanceWithFallback: jest.Mock; + getPricing: 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[] }; + remoteFeatureFlags?: Record; + balance?: typeof SUFFICIENT_BALANCE; + pricing?: PricingResponse; + } = {}, +) { + 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([]), + getRemoteFeatureFlagState: jest.fn().mockReturnValue({ + remoteFeatureFlags: options.remoteFeatureFlags ?? REMOTE_FEATURE_FLAGS, + cacheTimestamp: 0, + }), + fetchBalanceWithFallback: jest + .fn() + .mockResolvedValue(options.balance ?? SUFFICIENT_BALANCE), + getPricing: jest.fn().mockResolvedValue(options.pricing ?? PRICING), + }; + + 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']; + } + | { + type: 'RemoteFeatureFlagController:getState'; + handler: Mocks['getRemoteFeatureFlagState']; + } + | { + type: 'MoneyAccountBalanceService:fetchBalanceWithFallback'; + handler: Mocks['fetchBalanceWithFallback']; + } + | { + type: 'SubscriptionController:getPricing'; + handler: Mocks['getPricing']; + }; + + const rootMessenger = new Messenger< + MockAnyNamespace, + | AllowedActions + | { + type: `${typeof serviceName}:prepareDelegation`; + handler: SubscriptionDelegationService['prepareDelegation']; + } + | { + type: `${typeof serviceName}:checkMoneyAccountBalance`; + handler: SubscriptionDelegationService['checkMoneyAccountBalance']; + }, + 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, + ); + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + mocks.getRemoteFeatureFlagState, + ); + rootMessenger.registerActionHandler( + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + mocks.fetchBalanceWithFallback, + ); + rootMessenger.registerActionHandler( + 'SubscriptionController:getPricing', + mocks.getPricing, + ); + + 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', + 'RemoteFeatureFlagController:getState', + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + 'SubscriptionController:getPricing', + ], + events: [], + }); + + const service = new SubscriptionDelegationService({ + messenger, + }); + + 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)}`, + startDate = 1_700_000_000, +}: { + periodAmount?: bigint; + periodDuration?: number; + delegationHash?: Hex; + startDate?: number; +} = {}) { + 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, + }), + args: '0x', + }, + { + enforcer: REDEEMER, + terms: createRedeemerTerms({ redeemers: [DELEGATE] }), + args: '0x', + }, + ], + salt: `0x${'aa'.repeat(32)}`, + signature: SIGNATURE, + }, + metadata: { + delegationHash, + chainIdHex: CHAIN_ID, + allowance: `0x${periodAmount.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress: TOKEN, + type: CASH_SUBSCRIPTION_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(); +} + +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 pricing delegate', 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.getPricing).toHaveBeenCalledTimes(1); + expect(mocks.fetchBalanceWithFallback).not.toHaveBeenCalled(); + 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: CASH_SUBSCRIPTION_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: CASH_SUBSCRIPTION_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('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({ + 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('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('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 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({ + 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(); + + 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'] }, + }); + + 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, + ); + expect(mocks.getRemoteFeatureFlagState).not.toHaveBeenCalled(); + expect(mocks.fetchBalanceWithFallback).not.toHaveBeenCalled(); + expectNoSideEffects(mocks); + }); + + 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, + ); + expectNoSideEffects(mocks); + }, + ); + + 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`, + ); + expectNoSideEffects(mocks); + }); + + 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.PricingConfigurationNotFound, + ); + 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 new file mode 100644 index 00000000000..58a4d1dbfda --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/SubscriptionDelegationService.ts @@ -0,0 +1,512 @@ +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 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 { SubscriptionDelegationServiceErrorMessage } from '../constants.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, + PricingCryptoPaymentMethod, + RecurringInterval, + TokenPaymentInfo, +} from '../types.js'; +import { + assertPositiveInteger, + calculatePeriodAmount, + getDelegationStartDate, + 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 { + MoneyAccountBalanceCheckRequest, + MoneyAccountBalanceCheckResult, + PrepareSubscriptionDelegationRequest, + PreparedSubscriptionDelegation, + SubscriptionDelegationEnforcers, +} from './types.js'; +import { CASH_SUBSCRIPTION_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', + 'checkMoneyAccountBalance', +] 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 || + !contracts.RedeemerEnforcer + ) { + throw new Error( + `${SubscriptionDelegationServiceErrorMessage.DelegationContractsNotFound}: ${chainId}`, + ); + } + + return { + valueLte: contracts.ValueLteEnforcer, + erc20TokenPeriodTransfer: contracts.ERC20PeriodTransferEnforcer, + redeemer: contracts.RedeemerEnforcer, + }; +} + +/** + * 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 + | MoneyAccountBalanceServiceFetchBalanceWithFallbackAction + | RemoteFeatureFlagControllerGetStateAction + | SubscriptionControllerGetPricingAction; + +/** + * 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; +}; + +type SubscriptionIntentParams = { + account: Hex; + chainId: Hex; + delegationHash: Hex; + allowance: Hex; + tokenSymbol: string; + tokenAddress: Hex; +}; + +type ResolvedSubscriptionDelegationConfig = { + chainId: Hex; + delegateAddress: Hex; + enforcers: SubscriptionDelegationEnforcers; + price: ProductPrice; + token: TokenPaymentInfo; +}; + +/** + * 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 + * `delegationHash` for `SubscriptionController.startSubscriptionWithCrypto`. + * + * 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` + * 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. + */ +export class SubscriptionDelegationService { + readonly name: typeof serviceName = serviceName; + + readonly #messenger: SubscriptionDelegationServiceMessenger; + + constructor(options: SubscriptionDelegationServiceOptions) { + this.#messenger = options.messenger; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * 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 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, unless + * `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 + * 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 + * was created or reused. + */ + async prepareDelegation( + request: PrepareSubscriptionDelegationRequest, + ): Promise { + if (request.product !== PRODUCT_TYPES.MONEY_ACCOUNT_PLUS) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.UnsupportedProduct, + ); + } + + const skipChomp = Boolean(request.skipChompInteractions); + + 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: price.unitAmount, + unitDecimals: price.unitDecimals, + 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 isTrialDeferred = startDate > nowSeconds; + + const matches = makeMatchesSubscriptionDelegation({ + delegatorAddress: request.payerAddress, + delegateAddress, + chainId, + tokenAddress: token.address, + periodAmount, + periodDuration, + nowSeconds, + isTrialDeferred, + enforcers, + }); + + const existingDelegations = await this.#messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + const reusable = existingDelegations.find(matches); + if (reusable) { + 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', + }; + } + + const unsigned = buildUnsignedSubscriptionDelegation({ + delegateAddress, + delegatorAddress: request.payerAddress, + enforcers, + tokenAddress: token.address, + periodAmount, + periodDuration, + startDate, + }); + + const signature = (await this.#messenger.call( + 'DelegationController:signDelegation', + { delegation: unsigned, chainId }, + )) as Hex; + + const signedDelegation = { ...unsigned, signature }; + + const delegationHash = hashDelegation({ + ...unsigned, + salt: BigInt(unsigned.salt), + signature, + }); + + 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)); + + await this.#messenger.call( + 'AuthenticatedUserStorageService:createDelegation', + { + signedDelegation, + metadata: { + delegationHash, + chainIdHex: chainId, + allowance, + tokenSymbol: token.symbol, + tokenAddress: token.address, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE, + }, + }, + ); + + if (!skipChomp) { + await this.#createIntent({ + account: request.payerAddress, + chainId, + delegationHash, + allowance, + tokenSymbol: token.symbol, + tokenAddress: token.address, + }); + } + + return { + delegationHash, + disposition: 'created', + }; + } + + async #resolveConfiguration( + product: ProductType, + recurringInterval: RecurringInterval, + ): 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 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 token = chain?.tokens[0]; + if (!price || !chain?.delegateAddress || !token) { + throw new Error( + SubscriptionDelegationServiceErrorMessage.PricingConfigurationNotFound, + ); + } + + return { + chainId, + delegateAddress: chain.delegateAddress, + enforcers, + price, + token, + }; + } + + /** + * 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 { + // 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, + delegationHash: params.delegationHash, + chainId: params.chainId, + metadata: { + allowance: params.allowance, + tokenSymbol: params.tokenSymbol, + tokenAddress: params.tokenAddress, + type: CASH_SUBSCRIPTION_DELEGATION_TYPE as + | 'cash-deposit' + | 'cash-withdrawal', + }, + }, + ]); + } +} 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..6f306a98b52 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/amount.test.ts @@ -0,0 +1,128 @@ +import { SubscriptionDelegationServiceErrorMessage } from '../constants.js'; +import { RECURRING_INTERVALS } from '../types.js'; +import { + calculatePeriodAmount, + getDelegationStartDate, + 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, + ); + }); +}); + +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 new file mode 100644 index 00000000000..fab914a34df --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/amount.ts @@ -0,0 +1,124 @@ +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, + ); +} + +/** + * 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. + */ +function assertNonNegativeInteger(value: number, message: string): void { + if (!Number.isInteger(value) || value < 0) { + 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 new file mode 100644 index 00000000000..c91783a7163 --- /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 { + buildSubscriptionCaveats, + buildUnsignedSubscriptionDelegation, +} from './caveats.js'; + +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('buildSubscriptionCaveats', () => { + it('builds ValueLte(0) and ERC20TokenPeriodTransfer caveats', () => { + const caveats = buildSubscriptionCaveats({ + 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?.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: ENFORCERS, + 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); + 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', () => { + const unsigned = buildUnsignedSubscriptionDelegation({ + delegateAddress: DELEGATE, + delegatorAddress: DELEGATOR, + enforcers: ENFORCERS, + 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..ac283f937ed --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/caveats.ts @@ -0,0 +1,101 @@ +import type { SignedDelegation } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TokenPeriodTransferTerms, + createValueLteTerms, +} from '@metamask/delegation-core'; +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 BuildSubscriptionCaveatsParams = { + enforcers: SubscriptionDelegationEnforcers; + delegateAddress: Hex; + tokenAddress: Hex; + periodAmount: bigint; + periodDuration: number; + startDate: number; +}; + +/** + * Builds the caveat list for a cash-subscription delegation: + * `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. + * @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 buildSubscriptionCaveats({ + enforcers, + tokenAddress, + periodAmount, + periodDuration, + startDate, +}: BuildSubscriptionCaveatsParams): SignedDelegation['caveats'] { + return [ + { + enforcer: enforcers.valueLte, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x', + }, + { + enforcer: enforcers.erc20TokenPeriodTransfer, + terms: createERC20TokenPeriodTransferTerms({ + tokenAddress, + periodAmount, + periodDuration, + startDate, + }), + args: '0x', + }, + // TODO: recheck with CHOMP team if we should set redeemer to subscription payment address + // or use allowed call data + // { + // enforcer: enforcers.redeemer, + // terms: createRedeemerTerms({ redeemers: [delegateAddress] }), + // args: '0x', + // }, + ]; +} + +export type BuildUnsignedSubscriptionDelegationParams = + BuildSubscriptionCaveatsParams & { + delegatorAddress: Hex; + /** + * Optional salt for tests. When omitted, a random 32-byte salt is generated. + */ + salt?: Hex; + }; + +/** + * Builds an unsigned root cash-subscription 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: 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 new file mode 100644 index 00000000000..d58a2e894af --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.test.ts @@ -0,0 +1,234 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + createERC20TokenPeriodTransferTerms, + createRedeemerTerms, + createValueLteTerms, + ROOT_AUTHORITY, +} from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +import { + equalsIgnoreCase, + makeMatchesSubscriptionDelegation, +} from './fingerprint.js'; +import { CASH_SUBSCRIPTION_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 CHAIN_ID = '0x1' as Hex; +const PERIOD_AMOUNT = 10n * 10n ** 18n; +const PERIOD_DURATION = 28 * 86_400; + +function buildEntry({ + type = CASH_SUBSCRIPTION_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, + redeemerEnforcer = REDEEMER, + redeemerAddress = DELEGATE, + includeRedeemer = false, + maxValue = 0n, +}: { + type?: string; + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + periodAmount?: bigint; + periodDuration?: number; + 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, + salt: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + signature: `0x${'bb'.repeat(65)}`, + }, + metadata: { + delegationHash: `0x${'cc'.repeat(32)}`, + chainIdHex, + allowance: `0x${periodAmount.toString(16)}`, + tokenSymbol: 'pvmUSD', + tokenAddress, + type, + }, + }; +} + +const NOW_SECONDS = 1_700_000_000; + +const expected = { + delegatorAddress: DELEGATOR, + delegateAddress: DELEGATE, + chainId: CHAIN_ID, + tokenAddress: TOKEN, + periodAmount: PERIOD_AMOUNT, + periodDuration: PERIOD_DURATION, + nowSeconds: NOW_SECONDS, + isTrialDeferred: false, + enforcers: { + valueLte: VALUE_LTE, + erc20TokenPeriodTransfer: PERIOD, + redeemer: REDEEMER, + }, +}; + +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 is earlier but still immediately redeemable', () => { + expect(matches(buildEntry({ startDate: NOW_SECONDS - 86_400 }))).toBe(true); + }); + + 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 start is trial-deferred', () => { + const matchesTrial = makeMatchesSubscriptionDelegation({ + ...expected, + isTrialDeferred: true, + }); + + expect(matchesTrial(buildEntry({ startDate: NOW_SECONDS + 86_400 }))).toBe( + true, + ); + }); + + it('rejects an immediately redeemable startDate when start is trial-deferred', () => { + const matchesTrial = makeMatchesSubscriptionDelegation({ + ...expected, + isTrialDeferred: true, + }); + + expect(matchesTrial(buildEntry({ startDate: NOW_SECONDS }))).toBe(false); + }); + + 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), + redeemerAddress: upper(DELEGATE), + }), + ), + ).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('still matches when the redeemer caveat is present', () => { + expect(matches(buildEntry({ includeRedeemer: true }))).toBe(true); + }); + + 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..7c1dfea5394 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/fingerprint.ts @@ -0,0 +1,136 @@ +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 { CASH_SUBSCRIPTION_DELEGATION_TYPE } from './types.js'; + +export type SubscriptionDelegationFingerprint = { + delegatorAddress: Hex; + delegateAddress: Hex; + chainId: Hex; + 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 (`> nowSeconds`) matches. + * When false, only an immediately redeemable start (`<= nowSeconds`) matches. + */ + isTrialDeferred: boolean; + 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 + * 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 positive-length trial cannot reuse a live permission and vice versa. + * + * @param expected - Semantic fields that must match. + * @returns Predicate over {@link DelegationResponse}. + */ +export function makeMatchesSubscriptionDelegation( + expected: SubscriptionDelegationFingerprint, +): (entry: DelegationResponse) => boolean { + // const expectedRedeemerTerms = createRedeemerTerms({ + // redeemers: [expected.delegateAddress], + // }); + + return (entry) => { + if (entry.metadata.type !== CASH_SUBSCRIPTION_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, + ), + ); + // 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); + if (valueTerms.maxValue !== 0n) { + return false; + } + + const periodTerms = decodeERC20TokenPeriodTransferTerms( + periodCaveat.terms, + ); + const storedStartDate = Number(periodTerms.startDate); + const isStoredDeferred = storedStartDate > expected.nowSeconds; + if (expected.isTrialDeferred !== isStoredDeferred) { + return false; + } + + 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..cc6fdae76b1 --- /dev/null +++ b/packages/subscription-controller/src/subscription-delegation/types.ts @@ -0,0 +1,82 @@ +import type { Hex } from '@metamask/utils'; + +import { PRODUCT_TYPES } from '../types.js'; +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 = 'cash-subscription' as const; + +/** + * Request to prepare a cash-subscription delegation. + * + * 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. + */ +export type PrepareSubscriptionDelegationRequest = { + product: typeof PRODUCT_TYPES.MONEY_ACCOUNT_PLUS; + recurringInterval: RecurringInterval; + payerAddress: Hex; + /** + * 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; + /** + * 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; +}; + +/** + * Result of {@link SubscriptionDelegationService.prepareDelegation}. + */ +export type PreparedSubscriptionDelegation = { + delegationHash: Hex; + 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 cash-subscription 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 abec5593e0f..170e2bf16a8 100644 --- a/packages/subscription-controller/tsconfig.build.json +++ b/packages/subscription-controller/tsconfig.build.json @@ -5,26 +5,44 @@ "rootDir": "./src" }, "references": [ + { + "path": "../authenticated-user-storage/tsconfig.build.json" + }, { "path": "../base-controller/tsconfig.build.json" }, + { + "path": "../base-data-service/tsconfig.build.json" + }, + { + "path": "../chomp-api-service/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../delegation-controller/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" }, { - "path": "../profile-sync-controller/tsconfig.build.json" + "path": "../money-account-balance-service/tsconfig.build.json" + }, + { + "path": "../money-account-utils/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, { - "path": "../transaction-controller/tsconfig.build.json" + "path": "../profile-sync-controller/tsconfig.build.json" }, { - "path": "../controller-utils/tsconfig.build.json" + "path": "../remote-feature-flag-controller/tsconfig.build.json" }, { - "path": "../base-data-service/tsconfig.build.json" + "path": "../transaction-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/subscription-controller/tsconfig.json b/packages/subscription-controller/tsconfig.json index 3c6f42c1fff..66fcc34e398 100644 --- a/packages/subscription-controller/tsconfig.json +++ b/packages/subscription-controller/tsconfig.json @@ -1,26 +1,44 @@ { "extends": "../../tsconfig.packages.json", "references": [ + { + "path": "../authenticated-user-storage" + }, { "path": "../base-controller" }, + { + "path": "../base-data-service" + }, + { + "path": "../chomp-api-service" + }, + { + "path": "../controller-utils" + }, + { + "path": "../delegation-controller" + }, { "path": "../messenger" }, { - "path": "../profile-sync-controller" + "path": "../money-account-balance-service" + }, + { + "path": "../money-account-utils" }, { "path": "../polling-controller" }, { - "path": "../transaction-controller" + "path": "../profile-sync-controller" }, { - "path": "../controller-utils" + "path": "../remote-feature-flag-controller" }, { - "path": "../base-data-service" + "path": "../transaction-controller" } ], "include": ["../../types", "./src", "./tests"] diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 84dd3d7d391..fd40adea6ed 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. ([#10130](https://github.com/MetaMask/core/pull/10130)) + - 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 - Bump `@metamask/claims-controller` from `^1.0.0` to `^1.0.1` ([#10166](https://github.com/MetaMask/core/pull/10166)) 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..7a7379d8a98 --- /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', + '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 new file mode 100644 index 00000000000..08de20520cb --- /dev/null +++ b/packages/wallet/src/initialization/instances/subscription-delegation-service/subscription-delegation-service.ts @@ -0,0 +1,39 @@ +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', + 'DelegationController:signDelegation', + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + 'RemoteFeatureFlagController:getState', + 'SubscriptionController:getPricing', + ], + }); + + return messenger; + }, +}; diff --git a/yarn.lock b/yarn.lock index be829586d59..fa0f4702a78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7857,7 +7857,7 @@ __metadata: languageName: unknown linkType: soft -"@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: @@ -9313,13 +9313,21 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: + "@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:^5.0.0" "@metamask/controller-utils": "npm:^13.0.0" + "@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:^3.0.0" + "@metamask/money-account-utils": "npm:^2.0.0" "@metamask/polling-controller": "npm:^17.0.0" "@metamask/profile-sync-controller": "npm:^32.0.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" @@ -9330,6 +9338,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" rimraf: "npm:^5.0.5" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5"