diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1819430016d..fe3fd8d6775 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -41,6 +41,7 @@ /packages/earn-controller @MetaMask/earn /packages/money-account-balance-service @MetaMask/earn /packages/chomp-api-service @MetaMask/earn @MetaMask/delegation +/packages/money-account-upgrade-controller @MetaMask/earn @MetaMask/delegation ## Social AI Team /packages/ai-controllers @MetaMask/social-ai @@ -232,3 +233,5 @@ /packages/money-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform /packages/chomp-api-service/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform /packages/chomp-api-service/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform diff --git a/README.md b/README.md index 98c5599b03d..5fd249dfb88 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/messenger-cli`](packages/messenger-cli) - [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) +- [`@metamask/money-account-upgrade-controller`](packages/money-account-upgrade-controller) - [`@metamask/multichain-account-service`](packages/multichain-account-service) - [`@metamask/multichain-api-middleware`](packages/multichain-api-middleware) - [`@metamask/multichain-network-controller`](packages/multichain-network-controller) @@ -156,6 +157,7 @@ linkStyle default opacity:0.5 messenger_cli(["@metamask/messenger-cli"]); money_account_balance_service(["@metamask/money-account-balance-service"]); money_account_controller(["@metamask/money-account-controller"]); + money_account_upgrade_controller(["@metamask/money-account-upgrade-controller"]); multichain_account_service(["@metamask/multichain-account-service"]); multichain_api_middleware(["@metamask/multichain-api-middleware"]); multichain_network_controller(["@metamask/multichain-network-controller"]); @@ -367,6 +369,10 @@ linkStyle default opacity:0.5 money_account_controller --> base_controller; money_account_controller --> keyring_controller; money_account_controller --> messenger; + money_account_upgrade_controller --> base_controller; + money_account_upgrade_controller --> chomp_api_service; + money_account_upgrade_controller --> keyring_controller; + money_account_upgrade_controller --> messenger; multichain_account_service --> accounts_controller; multichain_account_service --> base_controller; multichain_account_service --> keyring_controller; diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md new file mode 100644 index 00000000000..05cd7b12065 --- /dev/null +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add `MoneyAccountUpgradeController` with `upgradeAccount` method ([#8426](https://github.com/MetaMask/core/pull/8426)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-upgrade-controller/LICENSE b/packages/money-account-upgrade-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-upgrade-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-upgrade-controller/README.md b/packages/money-account-upgrade-controller/README.md new file mode 100644 index 00000000000..dde58824dd1 --- /dev/null +++ b/packages/money-account-upgrade-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-upgrade-controller` + +MetaMask Money account upgrade controller. + +## Installation + +`yarn add @metamask/money-account-upgrade-controller` + +or + +`npm install @metamask/money-account-upgrade-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/money-account-upgrade-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json new file mode 100644 index 00000000000..31a0d25ac94 --- /dev/null +++ b/packages/money-account-upgrade-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/money-account-upgrade-controller", + "version": "0.0.0", + "description": "MetaMask Money account upgrade controller", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-upgrade-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-upgrade-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-upgrade-controller", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.1.0", + "@metamask/chomp-api-service": "^1.0.0", + "@metamask/keyring-controller": "^25.2.0", + "@metamask/messenger": "^1.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@metamask/utils": "^11.9.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController-method-action-types.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController-method-action-types.ts new file mode 100644 index 00000000000..ab45da1779d --- /dev/null +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController-method-action-types.ts @@ -0,0 +1,25 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController'; + +/** + * Runs each step in the upgrade sequence in order. A step that reports + * `'already-done'` is skipped without performing any action; a step that + * reports `'completed'` has performed its action. An error thrown by any + * step propagates and halts the sequence. + * + * @param address - The Money Account address to upgrade. + */ +export type MoneyAccountUpgradeControllerUpgradeAccountAction = { + type: `MoneyAccountUpgradeController:upgradeAccount`; + handler: MoneyAccountUpgradeController['upgradeAccount']; +}; + +/** + * Union of all MoneyAccountUpgradeController action types. + */ +export type MoneyAccountUpgradeControllerMethodActions = + MoneyAccountUpgradeControllerUpgradeAccountAction; diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts new file mode 100644 index 00000000000..b4133ef8318 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -0,0 +1,269 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '.'; +import { MoneyAccountUpgradeController } from '.'; +import type { UpgradeConfig } from './types'; + +const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_ACCOUNT_ADDRESS = + '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; + +const MOCK_CONFIG: UpgradeConfig = { + delegateAddress: '0x1111111111111111111111111111111111111111' as Hex, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + musdTokenAddress: '0x3333333333333333333333333333333333333333' as Hex, + vedaVaultAdapterAddress: '0x4444444444444444444444444444444444444444' as Hex, + erc20TransferAmountEnforcer: + '0x5555555555555555555555555555555555555555' as Hex, + redeemerEnforcer: '0x6666666666666666666666666666666666666666' as Hex, + valueLteEnforcer: '0x7777777777777777777777777777777777777777' as Hex, +}; + +const MOCK_INIT_CONFIG = { + delegatorImplAddress: MOCK_CONFIG.delegatorImplAddress, + musdTokenAddress: MOCK_CONFIG.musdTokenAddress, + redeemerEnforcer: MOCK_CONFIG.redeemerEnforcer, + valueLteEnforcer: MOCK_CONFIG.valueLteEnforcer, +}; + +const MOCK_SERVICE_DETAILS_RESPONSE = { + auth: { message: 'CHOMP Authentication' }, + chains: { + [MOCK_CHAIN_ID]: { + autoDepositDelegate: MOCK_CONFIG.delegateAddress, + protocol: { + vedaProtocol: { + supportedTokens: [ + { + tokenAddress: MOCK_CONFIG.erc20TransferAmountEnforcer, + tokenDecimals: 18, + }, + ], + adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + intentTypes: ['cash-deposit', 'cash-withdrawal'] as const, + }, + }, + }, + }, +}; + +type AllActions = MessengerActions; + +type AllEvents = MessengerEvents; + +type RootMessenger = Messenger; + +type Mocks = { + getServiceDetails: jest.Mock; + signPersonalMessage: jest.Mock; + associateAddress: jest.Mock; +}; + +function setup(): { + controller: MoneyAccountUpgradeController; + rootMessenger: RootMessenger; + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + getServiceDetails: jest + .fn() + .mockResolvedValue(MOCK_SERVICE_DETAILS_RESPONSE), + signPersonalMessage: jest.fn().mockResolvedValue('0xdeadbeef'), + associateAddress: jest.fn().mockResolvedValue({ + profileId: 'profile-1', + address: MOCK_ACCOUNT_ADDRESS, + status: 'CREATED', + }), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler( + 'ChompApiService:getServiceDetails', + mocks.getServiceDetails, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + mocks.signPersonalMessage, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:associateAddress', + mocks.associateAddress, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'ChompApiService:getServiceDetails', + 'KeyringController:signPersonalMessage', + 'ChompApiService:associateAddress', + ], + events: [], + messenger, + }); + + const controller = new MoneyAccountUpgradeController({ + messenger, + }); + + return { controller, rootMessenger, messenger, mocks }; +} + +describe('MoneyAccountUpgradeController', () => { + describe('constructor', () => { + it('does not make async init calls when constructed', () => { + const { mocks } = setup(); + + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + }); + + describe('init', () => { + it('fetches service details and builds config', async () => { + const { controller, mocks } = setup(); + + await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + + expect(mocks.getServiceDetails).toHaveBeenCalledWith([MOCK_CHAIN_ID]); + }); + + it('throws when the chain is not found in service details', async () => { + const { controller, mocks } = setup(); + + mocks.getServiceDetails.mockResolvedValue({ + auth: { message: 'CHOMP Authentication' }, + chains: {}, + }); + + await expect( + controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + ).rejects.toThrow( + `Chain ${MOCK_CHAIN_ID} not found in service details response`, + ); + }); + + it('throws when vedaProtocol is not found', async () => { + const { controller, mocks } = setup(); + + mocks.getServiceDetails.mockResolvedValue({ + auth: { message: 'CHOMP Authentication' }, + chains: { + [MOCK_CHAIN_ID]: { + autoDepositDelegate: MOCK_CONFIG.delegateAddress, + protocol: {}, + }, + }, + }); + + await expect( + controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + ).rejects.toThrow( + `vedaProtocol not found for chain ${MOCK_CHAIN_ID} in service details response`, + ); + }); + + it('throws when supportedTokens is empty', async () => { + const { controller, mocks } = setup(); + + mocks.getServiceDetails.mockResolvedValue({ + auth: { message: 'CHOMP Authentication' }, + chains: { + [MOCK_CHAIN_ID]: { + autoDepositDelegate: MOCK_CONFIG.delegateAddress, + protocol: { + vedaProtocol: { + supportedTokens: [], + adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + intentTypes: ['cash-deposit', 'cash-withdrawal'], + }, + }, + }, + }, + }); + + await expect( + controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + ).rejects.toThrow( + `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, + ); + }); + }); + + describe('upgradeAccount', () => { + it('throws when called before init', async () => { + const { controller } = setup(); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow( + 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', + ); + }); + + it('throws when a previous init attempt failed', async () => { + const { controller, mocks } = setup(); + mocks.getServiceDetails.mockResolvedValueOnce({ + auth: { message: 'CHOMP Authentication' }, + chains: {}, + }); + await expect( + controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + ).rejects.toThrow('Chain 0x1 not found in service details response'); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow( + 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', + ); + }); + + it('runs each step for the given address', async () => { + const { controller, mocks } = setup(); + await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + expect(mocks.signPersonalMessage).toHaveBeenCalledWith( + expect.objectContaining({ from: MOCK_ACCOUNT_ADDRESS }), + ); + expect(mocks.associateAddress).toHaveBeenCalledWith( + expect.objectContaining({ address: MOCK_ACCOUNT_ADDRESS }), + ); + }); + + it('is callable via the messenger', async () => { + const { controller, rootMessenger } = setup(); + await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + + expect( + await rootMessenger.call( + 'MoneyAccountUpgradeController:upgradeAccount', + MOCK_ACCOUNT_ADDRESS, + ), + ).toBeUndefined(); + }); + + it('propagates errors thrown by a step', async () => { + const { controller, mocks } = setup(); + await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); + + await expect( + controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('signing failed'); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts new file mode 100644 index 00000000000..516104b249d --- /dev/null +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -0,0 +1,152 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { + ChompApiServiceAssociateAddressAction, + ChompApiServiceGetServiceDetailsAction, +} from '@metamask/chomp-api-service'; +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import { associateAddressStep } from './associate-address'; +import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountUpgradeController-method-action-types'; +import type { Step } from './step'; +import type { InitConfig } from './types'; + +export const controllerName = 'MoneyAccountUpgradeController'; + +export type MoneyAccountUpgradeControllerState = Record; + +const moneyAccountUpgradeControllerMetadata = + {} satisfies StateMetadata; + +const MESSENGER_EXPOSED_METHODS = ['upgradeAccount'] as const; + +export type MoneyAccountUpgradeControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MoneyAccountUpgradeControllerState + >; + +export type MoneyAccountUpgradeControllerActions = + | MoneyAccountUpgradeControllerGetStateAction + | MoneyAccountUpgradeControllerMethodActions; + +type AllowedActions = + | ChompApiServiceAssociateAddressAction + | ChompApiServiceGetServiceDetailsAction + | KeyringControllerSignPersonalMessageAction; + +export type MoneyAccountUpgradeControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + MoneyAccountUpgradeControllerState + >; + +export type MoneyAccountUpgradeControllerEvents = + MoneyAccountUpgradeControllerStateChangedEvent; + +type AllowedEvents = never; + +export type MoneyAccountUpgradeControllerMessenger = Messenger< + typeof controllerName, + MoneyAccountUpgradeControllerActions | AllowedActions, + MoneyAccountUpgradeControllerEvents | AllowedEvents +>; + +/** + * Controller that orchestrates the Money Account upgrade sequence. + */ +export class MoneyAccountUpgradeController extends BaseController< + typeof controllerName, + MoneyAccountUpgradeControllerState, + MoneyAccountUpgradeControllerMessenger +> { + #initialized: boolean; + + readonly #steps: Step[] = [associateAddressStep]; + + /** + * Constructor for the MoneyAccountUpgradeController. + * + * @param options - The options for constructing the controller. + * @param options.messenger - The messenger to use for inter-controller communication. + */ + constructor({ + messenger, + }: { + messenger: MoneyAccountUpgradeControllerMessenger; + }) { + super({ + messenger, + metadata: moneyAccountUpgradeControllerMetadata, + name: controllerName, + state: {}, + }); + + this.#initialized = false; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Fetches service details and validates the controller can operate on the + * given chain. + * + * @param chainId - The chain to initialize for. + * @param _initConfig - Contract addresses not available from the service details API. + */ + async init(chainId: Hex, _initConfig: InitConfig): Promise { + const response = await this.messenger.call( + 'ChompApiService:getServiceDetails', + [chainId], + ); + + const chain = response.chains[chainId]; + if (!chain) { + throw new Error(`Chain ${chainId} not found in service details response`); + } + + const { vedaProtocol } = chain.protocol; + if (!vedaProtocol) { + throw new Error( + `vedaProtocol not found for chain ${chainId} in service details response`, + ); + } + + if (vedaProtocol.supportedTokens.length === 0) { + throw new Error( + `No supported tokens found for vedaProtocol on chain ${chainId}`, + ); + } + + this.#initialized = true; + } + + /** + * Runs each step in the upgrade sequence in order. A step that reports + * `'already-done'` is skipped without performing any action; a step that + * reports `'completed'` has performed its action. An error thrown by any + * step propagates and halts the sequence. + * + * @param address - The Money Account address to upgrade. + */ + async upgradeAccount(address: Hex): Promise { + if (!this.#initialized) { + throw new Error( + 'MoneyAccountUpgradeController must be initialized via init() before upgradeAccount() can be called', + ); + } + + for (const step of this.#steps) { + await step.run({ messenger: this.messenger, address }); + } + } +} diff --git a/packages/money-account-upgrade-controller/src/associate-address.test.ts b/packages/money-account-upgrade-controller/src/associate-address.test.ts new file mode 100644 index 00000000000..2b58e41a91e --- /dev/null +++ b/packages/money-account-upgrade-controller/src/associate-address.test.ts @@ -0,0 +1,147 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import { associateAddressStep } from './associate-address'; +import type { MoneyAccountUpgradeControllerMessenger } from './MoneyAccountUpgradeController'; + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; +const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + signPersonalMessage: jest.Mock; + associateAddress: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + signPersonalMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + associateAddress: jest.fn().mockResolvedValue({ + profileId: 'profile-1', + address: MOCK_ADDRESS, + status: 'created', + }), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + mocks.signPersonalMessage, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:associateAddress', + mocks.associateAddress, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'KeyringController:signPersonalMessage', + 'ChompApiService:associateAddress', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +describe('associateAddressStep', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(MOCK_NOW); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('is named "associate-address"', () => { + expect(associateAddressStep.name).toBe('associate-address'); + }); + + it('signs the CHOMP Authentication message with the given address', async () => { + const { messenger, mocks } = setup(); + + await associateAddressStep.run({ messenger, address: MOCK_ADDRESS }); + + expect(mocks.signPersonalMessage).toHaveBeenCalledWith({ + data: `CHOMP Authentication ${MOCK_NOW}`, + from: MOCK_ADDRESS, + }); + }); + + it('submits the signature, timestamp, and address to the CHOMP API', async () => { + const { messenger, mocks } = setup(); + + await associateAddressStep.run({ messenger, address: MOCK_ADDRESS }); + + expect(mocks.associateAddress).toHaveBeenCalledWith({ + signature: MOCK_SIGNATURE, + timestamp: MOCK_NOW.toString(), + address: MOCK_ADDRESS, + }); + }); + + it('returns "completed" when CHOMP creates the association', async () => { + const { messenger } = setup(); + + const result = await associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + }); + + expect(result).toBe('completed'); + }); + + it('returns "already-done" when CHOMP responds with already_associated (409)', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockResolvedValue({ + profileId: 'profile-1', + address: MOCK_ADDRESS, + status: 'already_associated', + }); + + const result = await associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + }); + + expect(result).toBe('already-done'); + }); + + it('propagates errors from signing and does not submit to the API', async () => { + const { messenger, mocks } = setup(); + mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); + + await expect( + associateAddressStep.run({ messenger, address: MOCK_ADDRESS }), + ).rejects.toThrow('signing failed'); + expect(mocks.associateAddress).not.toHaveBeenCalled(); + }); + + it('propagates errors from the CHOMP API', async () => { + const { messenger, mocks } = setup(); + mocks.associateAddress.mockRejectedValue(new Error('api failed')); + + await expect( + associateAddressStep.run({ messenger, address: MOCK_ADDRESS }), + ).rejects.toThrow('api failed'); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/associate-address.ts b/packages/money-account-upgrade-controller/src/associate-address.ts new file mode 100644 index 00000000000..3161fee73fd --- /dev/null +++ b/packages/money-account-upgrade-controller/src/associate-address.ts @@ -0,0 +1,42 @@ +import type { Hex } from '@metamask/utils'; + +import type { Step } from './step'; + +const ALREADY_ASSOCIATED_STATUS = 'already_associated'; + +/** + * Associates the Money Account address with the user's CHOMP profile. + * + * Signs `CHOMP Authentication {timestamp}` (EIP-191) with the account's key + * and submits the signature to CHOMP, which verifies the timestamp is fresh, + * recovers the signer, and records the profile–address mapping. + * + * CHOMP responds with 201 and `status: 'created'` when the association is + * made, and 409 with `status: 'already_associated'` when the address is + * already linked to a profile. The service surfaces both responses, so this + * step reports `'already-done'` for the 409 case and `'completed'` otherwise. + */ +export const associateAddressStep: Step = { + name: 'associate-address', + async run({ messenger, address }) { + const timestamp = Date.now().toString(); + const message = `CHOMP Authentication ${timestamp}`; + + const signature = (await messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + )) as Hex; + + const response = await messenger.call('ChompApiService:associateAddress', { + signature, + timestamp, + address, + }); + + if (response.status === ALREADY_ASSOCIATED_STATUS) { + return 'already-done'; + } + + return 'completed'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts new file mode 100644 index 00000000000..8c3d79a6d18 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -0,0 +1,12 @@ +export type { Step, StepResult } from './step'; +export type { InitConfig, UpgradeConfig } from './types'; +export { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController'; +export type { + MoneyAccountUpgradeControllerState, + MoneyAccountUpgradeControllerGetStateAction, + MoneyAccountUpgradeControllerActions, + MoneyAccountUpgradeControllerStateChangedEvent, + MoneyAccountUpgradeControllerEvents, + MoneyAccountUpgradeControllerMessenger, +} from './MoneyAccountUpgradeController'; +export type { MoneyAccountUpgradeControllerUpgradeAccountAction } from './MoneyAccountUpgradeController-method-action-types'; diff --git a/packages/money-account-upgrade-controller/src/step.ts b/packages/money-account-upgrade-controller/src/step.ts new file mode 100644 index 00000000000..588dbae59c8 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/step.ts @@ -0,0 +1,32 @@ +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from './MoneyAccountUpgradeController'; + +/** + * Context supplied to each step when it is run. + */ +export type StepContext = { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; +}; + +/** + * The outcome of running a single step in the Money Account upgrade sequence. + * + * - `'already-done'` — the step's remote check determined that no work was + * required; no action was taken. + * - `'completed'` — the step performed its action and is now done. + */ +export type StepResult = 'already-done' | 'completed'; + +/** + * A single step in the Money Account upgrade sequence. + * + * Each step is responsible for checking whether its action has already been + * applied (returning `'already-done'` if so) and otherwise performing the + * action and returning `'completed'`. + */ +export type Step = { + name: string; + run: (context: StepContext) => Promise; +}; diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts new file mode 100644 index 00000000000..c6a18dc179d --- /dev/null +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -0,0 +1,34 @@ +import type { Hex } from '@metamask/utils'; + +/** + * Contract addresses and configuration required to perform the + * Money Account upgrade sequence. + */ +export type UpgradeConfig = { + /** CHOMP's delegate address — receives the delegation. */ + delegateAddress: Hex; + /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ + delegatorImplAddress: Hex; + /** The mUSD token contract address. */ + musdTokenAddress: Hex; + /** The Veda vault adapter contract address. */ + vedaVaultAdapterAddress: Hex; + /** Address of the ERC20TransferAmountEnforcer caveat enforcer. */ + erc20TransferAmountEnforcer: Hex; + /** Address of the RedeemerEnforcer caveat enforcer. */ + redeemerEnforcer: Hex; + /** Address of the ValueLteEnforcer caveat enforcer. */ + valueLteEnforcer: Hex; +}; + +/** + * Configuration values passed to {@link MoneyAccountUpgradeController.init} + * that cannot be derived from the service details API. + */ +export type InitConfig = Pick< + UpgradeConfig, + | 'delegatorImplAddress' + | 'musdTokenAddress' + | 'redeemerEnforcer' + | 'valueLteEnforcer' +>; diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json new file mode 100644 index 00000000000..acb5f8ad014 --- /dev/null +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../chomp-api-service/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json new file mode 100644 index 00000000000..9bd846f6b48 --- /dev/null +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../chomp-api-service" }, + { "path": "../keyring-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-upgrade-controller/typedoc.json b/packages/money-account-upgrade-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-upgrade-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 7ea11e2eab9..3146f994433 100644 --- a/teams.json +++ b/teams.json @@ -77,5 +77,6 @@ "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform", "metamask/config-registry-controller": "team-networks", - "metamask/money-account-controller": "team-accounts-framework" + "metamask/money-account-controller": "team-accounts-framework", + "metamask/money-account-upgrade-controller": "team-earn" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 0abc4163fbc..2555a029cde 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -151,6 +151,9 @@ { "path": "./packages/money-account-controller/tsconfig.build.json" }, + { + "path": "./packages/money-account-upgrade-controller/tsconfig.build.json" + }, { "path": "./packages/multichain-account-service/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 9311cfcfeb8..641b9b3c7ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -143,6 +143,9 @@ { "path": "./packages/money-account-controller" }, + { + "path": "./packages/money-account-upgrade-controller" + }, { "path": "./packages/multichain-account-service" }, diff --git a/yarn.lock b/yarn.lock index 2e975dfceac..cb79307000b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3119,7 +3119,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chomp-api-service@workspace:packages/chomp-api-service": +"@metamask/chomp-api-service@npm:^1.0.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": version: 0.0.0-use.local resolution: "@metamask/chomp-api-service@workspace:packages/chomp-api-service" dependencies: @@ -4484,6 +4484,28 @@ __metadata: languageName: unknown linkType: soft +"@metamask/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller": + version: 0.0.0-use.local + resolution: "@metamask/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/chomp-api-service": "npm:^1.0.0" + "@metamask/keyring-controller": "npm:^25.2.0" + "@metamask/messenger": "npm:^1.1.1" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/multichain-account-service@npm:^8.0.1, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service"