diff --git a/README.md b/README.md index 1ece233c59..5c5b24b8e8 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,10 @@ linkStyle default opacity:0.5 money_account_controller --> base_controller; money_account_controller --> keyring_controller; money_account_controller --> messenger; + money_account_upgrade_controller --> authenticated_user_storage; money_account_upgrade_controller --> base_controller; money_account_upgrade_controller --> chomp_api_service; + money_account_upgrade_controller --> delegation_controller; money_account_upgrade_controller --> keyring_controller; money_account_upgrade_controller --> messenger; money_account_upgrade_controller --> network_controller; diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 9506860146..64a5e945b7 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,10 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `build-delegation` step to the upgrade sequence ([#8621](https://github.com/MetaMask/core/pull/8621)) + - Builds two delegations per upgrade — one for deposits (mUSD) and one for withdrawals (vmUSD / Veda boring vault) — checking the storage service for an existing match per token before signing. The Veda boring vault address is supplied to `init()` by the consumer pending exposure via the CHOMP service-details API. + - After CHOMP verification succeeds, each signed delegation is persisted via `AuthenticatedUserStorageService:createDelegation`. The metadata records the per-token symbol (`mUSD` / `vmUSD`), the `cash-deposit` / `cash-withdrawal` intent type, and a `delegationHash` derived from `@metamask/delegation-core`'s `hashDelegation`. +- Add `register-intents` step to the upgrade sequence ([#8621](https://github.com/MetaMask/core/pull/8621)) + - Submits one intent per stored delegation to `POST /v1/intent` so CHOMP can begin monitoring the account, idempotently skipping any delegation that already has an active intent (revoked intents are re-registered). After this step succeeds, CHOMP re-fetches the delegation from Authenticated User Storage, re-validates it, and adds the account to its monitoring list. + ### Changed +- **BREAKING:** The controller messenger now requires access to six additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:getIntentsByAddress`, `ChompApiService:createIntents`, and `DelegationController:signDelegation`. Delegation signing is now delegated to `@metamask/delegation-controller` rather than calling `KeyringController:signTypedMessage` directly; consumers must instantiate `DelegationController` and update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `init()` now takes a `{ chainId, boringVaultAddress }` object instead of an `InitConfig`. The EIP-7702 delegator implementation and caveat enforcer addresses are resolved from `@metamask/delegation-deployments` for the target chain; `init()` throws if the chain is not supported by Delegation Framework 1.3.0. The `InitConfig` type is no longer exported. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `UpgradeConfig` no longer includes `musdTokenAddress` (now derived internally from the Veda protocol service details). ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Add `@metamask/authenticated-user-storage`, `@metamask/delegation-controller`, `@metamask/delegation-core`, and `@metamask/delegation-deployments` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) - Bump `@metamask/keyring-controller` from `^25.4.0` to `^25.5.0` ([#8722](https://github.com/MetaMask/core/pull/8722)) +### Fixed + +- Build-delegation step no longer emits a redundant duplicate `ValueLteEnforcer` caveat; the Delegation Framework treats both as equivalent, but the duplicate was inadvertently inherited from `@metamask/smart-accounts-kit`'s `erc20TransferAmount` scope helper. ([#8621](https://github.com/MetaMask/core/pull/8621)) + ## [1.3.1] ### Changed diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js index ca08413339..5d308fd932 100644 --- a/packages/money-account-upgrade-controller/jest.config.js +++ b/packages/money-account-upgrade-controller/jest.config.js @@ -3,18 +3,15 @@ * 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 +module.exports = { + ...baseConfig, displayName, - - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { branches: 100, @@ -23,4 +20,4 @@ module.exports = merge(baseConfig, { statements: 100, }, }, -}); +}; diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index a197967aee..f9e4c6943e 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -53,8 +53,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/authenticated-user-storage": "^1.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/chomp-api-service": "^3.0.0", + "@metamask/delegation-controller": "^3.0.0", + "@metamask/delegation-core": "^2.0.0", + "@metamask/delegation-deployments": "^1.3.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^30.1.0", diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index 8504e2f001..6ce78bc3c6 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -1,51 +1,52 @@ +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import { hexToNumber } from '@metamask/utils'; 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_CHAIN_ID = '0x1' as Hex; // mainnet, supported in delegation-deployments@1.3.0 +const UNSUPPORTED_CHAIN_ID = '0x539' as Hex; // 1337 — local dev, not in registry 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_BORING_VAULT_ADDRESS = + '0xA20f97813014129E7609171d2D3AA3da5206259e' as Hex; + +// CHOMP-API-derived values. +const MOCK_DELEGATE_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD_TOKEN_ADDRESS = + '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VEDA_VAULT_ADAPTER_ADDRESS = + '0x4444444444444444444444444444444444444444' as Hex; + +// Delegation Framework deployment for mainnet @ 1.3.0 — the controller resolves +// these from `@metamask/delegation-deployments` rather than accepting them via +// `init()`. We re-read from the same source here so the test does not drift if +// the deployment registry is bumped. +const MAINNET_CONTRACTS = + DELEGATOR_CONTRACTS['1.3.0'][hexToNumber(MOCK_CHAIN_ID)]; const MOCK_SERVICE_DETAILS_RESPONSE = { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: { vedaProtocol: { supportedTokens: [ { - tokenAddress: MOCK_CONFIG.erc20TransferAmountEnforcer, + tokenAddress: MOCK_MUSD_TOKEN_ADDRESS, tokenDecimals: 18, }, ], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'] as const, }, }, @@ -68,6 +69,12 @@ type Mocks = { findNetworkClientIdByChainId: jest.Mock; getNetworkClientById: jest.Mock; providerRequest: jest.Mock; + listDelegations: jest.Mock; + createDelegation: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; }; function setup(): { @@ -104,7 +111,7 @@ function setup(): { }), createUpgrade: jest.fn().mockResolvedValue({ signerAddress: MOCK_ACCOUNT_ADDRESS, - address: MOCK_CONFIG.delegatorImplAddress, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, chainId: MOCK_CHAIN_ID, nonce: '0x0', status: 'pending', @@ -118,6 +125,12 @@ function setup(): { provider: { request: providerRequest }, }), providerRequest, + listDelegations: jest.fn().mockResolvedValue([]), + createDelegation: jest.fn().mockResolvedValue(undefined), + signDelegation: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), }; const rootMessenger = new Messenger({ @@ -152,6 +165,30 @@ function setup(): { 'NetworkController:getNetworkClientById', mocks.getNetworkClientById, ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ namespace: 'MoneyAccountUpgradeController', @@ -167,6 +204,12 @@ function setup(): { 'KeyringController:signEip7702Authorization', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', ], events: [], messenger, @@ -192,11 +235,50 @@ describe('MoneyAccountUpgradeController', () => { it('fetches service details and builds config', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect(mocks.getServiceDetails).toHaveBeenCalledWith([MOCK_CHAIN_ID]); }); + it('throws when the chain has no Delegation Framework deployment', async () => { + const { controller, mocks } = setup(); + + await expect( + controller.init({ + chainId: UNSUPPORTED_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `Delegation Framework 1.3.0 is not deployed on chain ${UNSUPPORTED_CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + + it('uses the supplied boring vault address as the withdrawal-side delegation token', async () => { + const { controller, mocks } = setup(); + + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + // Both delegations were signed; the boring-vault address shows up in the + // ABI-encoded ERC20TransferAmount caveat terms of one of them. + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + const allCaveatTerms = mocks.verifyDelegation.mock.calls + .flatMap(([{ signedDelegation }]) => signedDelegation.caveats) + .map((caveat) => caveat.terms.toLowerCase()); + expect( + allCaveatTerms.some((terms) => + terms.includes(MOCK_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), + ), + ).toBe(true); + }); + it('throws when the chain is not found in service details', async () => { const { controller, mocks } = setup(); @@ -206,7 +288,10 @@ describe('MoneyAccountUpgradeController', () => { }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `Chain ${MOCK_CHAIN_ID} not found in service details response`, ); @@ -219,14 +304,17 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: {}, }, }, }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `vedaProtocol not found for chain ${MOCK_CHAIN_ID} in service details response`, ); @@ -239,11 +327,11 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: { vedaProtocol: { supportedTokens: [], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'], }, }, @@ -252,7 +340,10 @@ describe('MoneyAccountUpgradeController', () => { }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, ); @@ -277,7 +368,10 @@ describe('MoneyAccountUpgradeController', () => { chains: {}, }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow('Chain 0x1 not found in service details response'); await expect( @@ -287,9 +381,12 @@ describe('MoneyAccountUpgradeController', () => { ); }); - it('runs each step for the given address', async () => { + it('runs each step against the deployment-derived contract addresses', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); @@ -302,12 +399,12 @@ describe('MoneyAccountUpgradeController', () => { expect(mocks.signEip7702Authorization).toHaveBeenCalledWith( expect.objectContaining({ from: MOCK_ACCOUNT_ADDRESS, - contractAddress: MOCK_CONFIG.delegatorImplAddress, + contractAddress: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, }), ); expect(mocks.createUpgrade).toHaveBeenCalledWith( expect.objectContaining({ - address: MOCK_CONFIG.delegatorImplAddress, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, chainId: MOCK_CHAIN_ID, nonce: '0x0', }), @@ -316,7 +413,10 @@ describe('MoneyAccountUpgradeController', () => { it('is callable via the messenger', async () => { const { controller, rootMessenger } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect( await rootMessenger.call( @@ -328,7 +428,10 @@ describe('MoneyAccountUpgradeController', () => { it('propagates errors thrown by a step', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); await expect( diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 113fca4260..574a78f400 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -1,3 +1,7 @@ +import type { + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceListDelegationsAction, +} from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, ControllerStateChangedEvent, @@ -6,9 +10,14 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateIntentsAction, ChompApiServiceCreateUpgradeAction, + ChompApiServiceGetIntentsByAddressAction, ChompApiServiceGetServiceDetailsAction, + ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; +import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { KeyringControllerSignEip7702AuthorizationAction, KeyringControllerSignPersonalMessageAction, @@ -18,13 +27,22 @@ import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; +import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountUpgradeController-method-action-types'; import { associateAddressStep } from './steps/associate-address'; +import { buildDelegationStep } from './steps/build-delegations'; import { eip7702AuthorizationStep } from './steps/eip-7702-authorization'; +import { registerIntentsStep } from './steps/register-intents'; import type { Step } from './steps/step'; -import type { InitConfig } from './types'; +import type { UpgradeConfig } from './types'; + +/** + * The Delegation Framework deployment version we resolve contract addresses + * against in `@metamask/delegation-deployments`. + */ +const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; export const controllerName = 'MoneyAccountUpgradeController'; @@ -46,9 +64,15 @@ export type MoneyAccountUpgradeControllerActions = | MoneyAccountUpgradeControllerMethodActions; type AllowedActions = + | AuthenticatedUserStorageServiceCreateDelegationAction + | AuthenticatedUserStorageServiceListDelegationsAction | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateIntentsAction | ChompApiServiceCreateUpgradeAction + | ChompApiServiceGetIntentsByAddressAction | ChompApiServiceGetServiceDetailsAction + | ChompApiServiceVerifyDelegationAction + | DelegationControllerSignDelegationAction | KeyringControllerSignEip7702AuthorizationAction | KeyringControllerSignPersonalMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction @@ -79,9 +103,14 @@ export class MoneyAccountUpgradeController extends BaseController< MoneyAccountUpgradeControllerState, MoneyAccountUpgradeControllerMessenger > { - #config?: { chainId: Hex; delegatorImplAddress: Hex }; + #config?: UpgradeConfig & { chainId: Hex }; - readonly #steps: Step[] = [associateAddressStep, eip7702AuthorizationStep]; + readonly #steps: Step[] = [ + associateAddressStep, + eip7702AuthorizationStep, + buildDelegationStep, + registerIntentsStep, + ]; /** * Constructor for the MoneyAccountUpgradeController. @@ -109,12 +138,31 @@ export class MoneyAccountUpgradeController extends BaseController< /** * Fetches service details and validates the controller can operate on the - * given chain. + * given chain. Resolves the Delegation Framework contract addresses for the + * chain from `@metamask/delegation-deployments`. * - * @param chainId - The chain to initialize for. - * @param initConfig - Contract addresses not available from the service details API. + * @param params - The parameters for initialization. + * @param params.chainId - The chain to initialize for. + * @param params.boringVaultAddress - The Veda boring vault contract + * (vmUSD) for the given chain. Used as the withdrawal-side delegation + * token. Supplied by the consumer until the CHOMP service-details API + * exposes it. */ - async init(chainId: Hex, initConfig: InitConfig): Promise { + async init({ + chainId, + boringVaultAddress, + }: { + chainId: Hex; + boringVaultAddress: Hex; + }): Promise { + const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][hexToNumber(chainId)]; + if (!contracts) { + throw new Error( + `Delegation Framework ${DELEGATION_FRAMEWORK_VERSION} is not deployed on chain ${chainId}`, + ); + } + const response = await this.messenger.call( 'ChompApiService:getServiceDetails', [chainId], @@ -140,7 +188,14 @@ export class MoneyAccountUpgradeController extends BaseController< this.#config = { chainId, - delegatorImplAddress: initConfig.delegatorImplAddress, + delegateAddress: chain.autoDepositDelegate, + musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, + boringVaultAddress, + vedaVaultAdapterAddress: vedaProtocol.adapterAddress, + delegatorImplAddress: contracts.EIP7702StatelessDeleGatorImpl, + erc20TransferAmountEnforcer: contracts.ERC20TransferAmountEnforcer, + redeemerEnforcer: contracts.RedeemerEnforcer, + valueLteEnforcer: contracts.ValueLteEnforcer, }; } diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts index ffa40fc101..26d32b8711 100644 --- a/packages/money-account-upgrade-controller/src/index.ts +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -1,4 +1,4 @@ -export type { InitConfig, UpgradeConfig } from './types'; +export type { UpgradeConfig } from './types'; export { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController'; export type { MoneyAccountUpgradeControllerState, diff --git a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts index b141dc0ef4..da1b8491e3 100644 --- a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts @@ -11,7 +11,15 @@ import { associateAddressStep } from './associate-address'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); @@ -64,6 +72,24 @@ function setup(): { return { messenger, mocks }; } +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + describe('associateAddressStep', () => { beforeEach(() => { jest.useFakeTimers(); @@ -81,12 +107,7 @@ describe('associateAddressStep', () => { it('signs the CHOMP Authentication message with the given address', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + await run(messenger); expect(mocks.signPersonalMessage).toHaveBeenCalledWith({ data: `CHOMP Authentication ${MOCK_NOW}`, @@ -97,12 +118,7 @@ describe('associateAddressStep', () => { it('submits the signature, timestamp, and address to the CHOMP API', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + await run(messenger); expect(mocks.associateAddress).toHaveBeenCalledWith({ signature: MOCK_SIGNATURE, @@ -114,12 +130,7 @@ describe('associateAddressStep', () => { it('returns "completed" when CHOMP creates the association', async () => { const { messenger } = setup(); - const result = await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + const result = await run(messenger); expect(result).toBe('completed'); }); @@ -131,12 +142,7 @@ describe('associateAddressStep', () => { status: 'active', }); - const result = await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + const result = await run(messenger); expect(result).toBe('already-done'); }); @@ -145,14 +151,7 @@ describe('associateAddressStep', () => { const { messenger, mocks } = setup(); mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); - await expect( - associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }), - ).rejects.toThrow('signing failed'); + await expect(run(messenger)).rejects.toThrow('signing failed'); expect(mocks.associateAddress).not.toHaveBeenCalled(); }); @@ -160,13 +159,6 @@ describe('associateAddressStep', () => { const { messenger, mocks } = setup(); mocks.associateAddress.mockRejectedValue(new Error('api failed')); - await expect( - associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }), - ).rejects.toThrow('api failed'); + await expect(run(messenger)).rejects.toThrow('api failed'); }); }); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts new file mode 100644 index 0000000000..56ac59d731 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -0,0 +1,545 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +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 '../MoneyAccountUpgradeController'; +import { buildDelegationStep } from './build-delegations'; + +jest.mock('@metamask/delegation-core', () => ({ + ROOT_AUTHORITY: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + createERC20TransferAmountTerms: jest.fn(), + createRedeemerTerms: jest.fn(), + createValueLteTerms: jest.fn(), + hashDelegation: jest.fn(), +})); + +const mockCreateErc20Terms = jest.mocked(createERC20TransferAmountTerms); +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); +const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); +const mockHashDelegation = jest.mocked(hashDelegation); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; +const MOCK_SIGNATURE: Hex = `0x${'cd'.repeat(65)}`; + +const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; +const MOCK_MUSD_ERC20_TERMS: Hex = '0xa2'; +const MOCK_VMUSD_ERC20_TERMS: Hex = '0xa4'; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; + +type ExpectedCaveat = { enforcer: Hex; terms: Hex; args: '0x' }; +const expectedCaveats = (erc20Terms: Hex): ExpectedCaveat[] => [ + { + enforcer: MOCK_VALUE_LTE_ENFORCER, + terms: MOCK_VALUE_LTE_TERMS, + args: '0x', + }, + { enforcer: MOCK_ERC20_ENFORCER, terms: erc20Terms, args: '0x' }, + { enforcer: MOCK_REDEEMER_ENFORCER, terms: MOCK_REDEEMER_TERMS, args: '0x' }, +]; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry, + * defaulting every identifying field to the deposit-side delegation. Tests + * override one field at a time to probe the matcher. + * + * @param overrides - Identifying fields to override. + * @param overrides.delegator - The delegator address. + * @param overrides.delegate - The delegate address. + * @param overrides.chainIdHex - The chain ID in hex. + * @param overrides.tokenAddress - The token address. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: ROOT_AUTHORITY as Hex, + caveats: [], + salt: `0x${'42'.repeat(32)}`, + signature: '0x' as Hex, + }, + metadata: { + delegationHash: `0x${'ab'.repeat(32)}`, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: '0x00', + tokenSymbol: 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: 'lend', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + createDelegation: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest.fn().mockResolvedValue([]), + signDelegation: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + createDelegation: jest.fn().mockResolvedValue(undefined), + }; + + const rootMessenger = new Messenger({ + 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, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return buildDelegationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('buildDelegationStep', () => { + beforeEach(() => { + // The term creators are overloaded over output encoding; the runtime path + // picks the hex overload, but `jest.mocked()` picks the bytes overload, so + // cast through `never` to satisfy both. + mockCreateValueLteTerms.mockReturnValue(MOCK_VALUE_LTE_TERMS as never); + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + // Return a different ERC20 terms blob per token so tests can tell which + // delegation was signed when. + mockCreateErc20Terms.mockImplementation((({ + tokenAddress, + }: { + tokenAddress: Hex; + }) => + tokenAddress === MOCK_MUSD + ? MOCK_MUSD_ERC20_TERMS + : MOCK_VMUSD_ERC20_TERMS) as never); + // Distinguish the two delegations by call order — the run loop signs + // mUSD first, then vmUSD, so the first hashDelegation call corresponds to + // mUSD. + mockHashDelegation + .mockReturnValueOnce(MOCK_MUSD_DELEGATION_HASH as never) + .mockReturnValueOnce(MOCK_VMUSD_DELEGATION_HASH as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "build-delegation"', () => { + expect(buildDelegationStep.name).toBe('build-delegation'); + }); + + describe('when neither delegation exists in storage', () => { + it('signs and submits both delegations, deposit (mUSD) before withdrawal (vmUSD)', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(2); + + const signedTokens = mocks.signDelegation.mock.calls.map( + ([{ delegation }]) => delegation.caveats[1].terms, + ); + expect(signedTokens).toStrictEqual([ + MOCK_MUSD_ERC20_TERMS, + MOCK_VMUSD_ERC20_TERMS, + ]); + }); + + it('encodes each caveat against the right enforcer addresses for each token', async () => { + const { messenger } = setup(); + + await run(messenger); + + // valueLte and redeemer share configuration across both delegations. + expect(mockCreateValueLteTerms).toHaveBeenCalledWith({ maxValue: 0n }); + expect(mockCreateRedeemerTerms).toHaveBeenCalledWith({ + redeemers: [MOCK_VAULT_ADAPTER], + }); + // erc20TransferAmount is per-token. + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_MUSD, + maxAmount: 2n ** 256n - 1n, + }); + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_BORING_VAULT, + maxAmount: 2n ** 256n - 1n, + }); + }); + + it('hands each unsigned delegation to DelegationController:signDelegation, scoped to the chain, with a fresh 32-byte salt', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.signDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, delegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(delegation.delegate).toBe(MOCK_DELEGATE); + expect(delegation.delegator).toBe(MOCK_ADDRESS); + expect(delegation.authority).toBe(ROOT_AUTHORITY); + expect(delegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(delegation).not.toHaveProperty('signature'); + } + + expect(first.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + // Salts are independent per delegation. + expect(first.delegation.salt).not.toBe(second.delegation.salt); + }); + + it('submits each signed delegation to ChompApiService:verifyDelegation', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.verifyDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, signedDelegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(signedDelegation.delegate).toBe(MOCK_DELEGATE); + expect(signedDelegation.delegator).toBe(MOCK_ADDRESS); + expect(signedDelegation.authority).toBe(ROOT_AUTHORITY); + expect(signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(signedDelegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + } + + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + }); + + it('persists each delegation via AuthenticatedUserStorageService:createDelegation, with deposit/withdrawal metadata', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(2); + const [first, second] = mocks.createDelegation.mock.calls.map( + ([submission]) => submission, + ); + + // Each submission carries the same signed-delegation as the + // corresponding verifyDelegation call. + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(first.signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.signature).toBe(MOCK_SIGNATURE); + + expect(first.metadata).toStrictEqual({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }); + expect(second.metadata).toStrictEqual({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }); + }); + + it('hashes each signed delegation (with bigint salt) before persisting it', async () => { + const { messenger } = setup(); + + await run(messenger); + + expect(mockHashDelegation).toHaveBeenCalledTimes(2); + // Each hashDelegation call should receive a delegation whose salt is a + // bigint (delegation-core's expectation), not a hex string. + for (const [delegationStruct] of mockHashDelegation.mock.calls) { + expect(typeof delegationStruct.salt).toBe('bigint'); + expect(delegationStruct.signature).toBe(MOCK_SIGNATURE); + } + }); + }); + + describe('when only one delegation already exists', () => { + it('signs, submits, and persists only the missing withdrawal delegation when the deposit one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_VMUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_BORING_VAULT); + expect(submission.metadata.type).toBe('cash-withdrawal'); + }); + + it('signs, submits, and persists only the missing deposit delegation when the withdrawal one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_MUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_MUSD); + expect(submission.metadata.type).toBe('cash-deposit'); + }); + }); + + describe('when both delegations already exist', () => { + it('returns "already-done" without signing, submitting, or persisting', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('matches addresses, chainId, and tokenAddress case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD.toUpperCase() as Hex, + }), + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + + it('ignores entries that differ on any identifying field', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // Same token but wrong delegator/delegate/chain. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegator: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegate: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + chainIdHex: OTHER_CHAIN_ID, + }), + // Unrelated token. + makeDelegationResponse({ tokenAddress: OTHER_TOKEN }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + }); + + describe('when CHOMP rejects a delegation', () => { + it('throws with the joined error list', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ + valid: false, + errors: ['caveat mismatch', 'unknown enforcer'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: caveat mismatch, unknown enforcer', + ); + }); + + it('throws with a default message when CHOMP returns no errors', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ valid: false }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: unknown error', + ); + }); + + it('does not attempt the second delegation, and does not persist, if the first one is rejected', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValueOnce({ + valid: false, + errors: ['nope'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: nope', + ); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not sign or submit anything', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from signing and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.signDelegation.mockRejectedValue(new Error('signing failed')); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from verifyDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from createDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.createDelegation.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts new file mode 100644 index 0000000000..3a2cc05907 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -0,0 +1,197 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { add0x, bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import type { Step } from './step'; + +const MAX_UINT256 = 2n ** 256n - 1n; +const MAX_UINT256_HEX: Hex = add0x(MAX_UINT256.toString(16)); + +const equalsIgnoreCase = (a: Hex, b: Hex): boolean => + a.toLowerCase() === b.toLowerCase(); + +/** + * Builds, signs, verifies (with CHOMP), and persists a single auto-deposit + * delegation for the given token. Both the deposit (mUSD) and withdrawal + * (vmUSD / boring vault) delegations share this shape; only the token + * address, symbol, and metadata `type` differ. + * + * @param params - The parameters for building the delegation. + * @param params.messenger - The messenger to call signing/verifying actions on. + * @param params.address - The delegator (the Money Account being upgraded). + * @param params.chainId - The chain to scope the delegation to. + * @param params.delegateAddress - CHOMP's delegate. + * @param params.tokenAddress - The token the delegation authorises transfers of. + * @param params.tokenSymbol - Symbol stored in the delegation metadata (e.g. "mUSD"). + * @param params.delegationType - Storage metadata `type` field; matches CHOMP's intent type. + * @param params.vedaVaultAdapterAddress - The redeemer (Veda vault adapter). + * @param params.erc20TransferAmountEnforcer - The ERC20TransferAmountEnforcer contract. + * @param params.redeemerEnforcer - The RedeemerEnforcer contract. + * @param params.valueLteEnforcer - The ValueLteEnforcer contract. + */ +async function signAndStoreDelegation(params: { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; + chainId: Hex; + delegateAddress: Hex; + tokenAddress: Hex; + tokenSymbol: string; + delegationType: 'cash-deposit' | 'cash-withdrawal'; + vedaVaultAdapterAddress: Hex; + erc20TransferAmountEnforcer: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; +}): Promise { + const { + messenger, + address, + chainId, + delegateAddress, + tokenAddress, + tokenSymbol, + delegationType, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + } = params; + + const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const salt = bytesToHex(saltBytes); + + const delegation = { + delegate: delegateAddress, + delegator: address, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x' as Hex, + }, + { + enforcer: erc20TransferAmountEnforcer, + terms: createERC20TransferAmountTerms({ + tokenAddress, + maxAmount: MAX_UINT256, + }), + args: '0x' as Hex, + }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), + args: '0x' as Hex, + }, + ], + salt, + }; + + const signature = (await messenger.call( + 'DelegationController:signDelegation', + { delegation, chainId }, + )) as Hex; + + const signedDelegation = { ...delegation, signature }; + + const result = await messenger.call('ChompApiService:verifyDelegation', { + signedDelegation, + chainId, + }); + + if (!result.valid) { + throw new Error( + `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, + ); + } + + const delegationHash = hashDelegation({ + ...delegation, + salt: BigInt(salt), + signature, + }); + + await messenger.call('AuthenticatedUserStorageService:createDelegation', { + signedDelegation, + metadata: { + delegationHash, + chainIdHex: chainId, + allowance: MAX_UINT256_HEX, + tokenSymbol, + tokenAddress, + type: delegationType, + }, + }); +} + +export const buildDelegationStep: Step = { + name: 'build-delegation', + async run({ + messenger, + address, + chainId, + boringVaultAddress, + delegateAddress, + erc20TransferAmountEnforcer, + musdTokenAddress, + redeemerEnforcer, + valueLteEnforcer, + vedaVaultAdapterAddress, + }) { + const existingDelegations = await messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + + const matches = + (tokenAddress: Hex) => + (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + equalsIgnoreCase(entry.metadata.tokenAddress, tokenAddress); + + // The deposit delegation authorises transfers of mUSD (delegator → vault); + // the withdrawal delegation authorises transfers of vmUSD (vault share + // token → adapter, which redeems back to mUSD). + const delegations = [ + { + tokenAddress: musdTokenAddress, + tokenSymbol: 'mUSD', + delegationType: 'cash-deposit' as const, + }, + { + tokenAddress: boringVaultAddress, + tokenSymbol: 'vmUSD', + delegationType: 'cash-withdrawal' as const, + }, + ]; + + let didWork = false; + for (const config of delegations) { + if (existingDelegations.some(matches(config.tokenAddress))) { + continue; + } + await signAndStoreDelegation({ + messenger, + address, + chainId, + delegateAddress, + ...config, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + }); + didWork = true; + } + + return didWork ? 'completed' : 'already-done'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts index d11bf4548c..6046e8d213 100644 --- a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts @@ -12,7 +12,15 @@ import { eip7702AuthorizationStep } from './eip-7702-authorization'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) — non-trivial decimal const MOCK_CHAIN_ID_DECIMAL = parseInt(MOCK_CHAIN_ID, 16); +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; const MOCK_THIRD_PARTY_IMPL = '0x9999999999999999999999999999999999999999' as Hex; const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; @@ -142,7 +150,14 @@ async function run( messenger, address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, }); } diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts new file mode 100644 index 0000000000..c175108655 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts @@ -0,0 +1,449 @@ +import type { + DelegationResponse, + DelegationMetadata, +} from '@metamask/authenticated-user-storage'; +import type { IntentEntry } from '@metamask/chomp-api-service'; +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 '../MoneyAccountUpgradeController'; +import { registerIntentsStep } from './register-intents'; + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; + +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry. + * Defaults match the deposit-side delegation written by the build-delegation + * step; tests override identifying fields and metadata to probe the matcher. + * + * @param overrides - Identifying fields and metadata to override. + * @param overrides.delegator - The delegator address. + * @param overrides.delegate - The delegate address. + * @param overrides.chainIdHex - The chain ID in hex. + * @param overrides.tokenAddress - The token address. + * @param overrides.tokenSymbol - The token symbol. + * @param overrides.delegationHash - The delegation hash recorded in metadata. + * @param overrides.type - The metadata `type` field. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + tokenSymbol?: string; + delegationHash?: Hex; + type?: DelegationMetadata['type']; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: `0x${'ff'.repeat(32)}`, + caveats: [], + salt: `0x${'42'.repeat(32)}`, + signature: `0x${'cd'.repeat(65)}`, + }, + metadata: { + delegationHash: overrides.delegationHash ?? MOCK_MUSD_DELEGATION_HASH, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: overrides.tokenSymbol ?? 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: overrides.type ?? 'cash-deposit', + }, + }; +} + +const depositDelegation = (): DelegationResponse => + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'cash-deposit', + }); + +const withdrawalDelegation = (): DelegationResponse => + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + tokenSymbol: 'vmUSD', + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + type: 'cash-withdrawal', + }); + +/** + * Builds an `IntentEntry` for use as a mocked `getIntentsByAddress` entry. + * Defaults to an active deposit-side intent matching the deposit delegation. + * + * @param overrides - Fields to override. + * @param overrides.delegationHash - The delegationHash this intent points at. + * @param overrides.status - The intent status (active or revoked). + * @returns A complete `IntentEntry`. + */ +function makeIntentEntry( + overrides: { delegationHash?: Hex; status?: IntentEntry['status'] } = {}, +): IntentEntry { + return { + account: MOCK_ADDRESS, + delegationHash: overrides.delegationHash ?? MOCK_MUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + status: overrides.status ?? 'active', + metadata: { + allowance: MAX_UINT256_HEX, + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + type: 'cash-deposit', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest + .fn() + .mockResolvedValue([depositDelegation(), withdrawalDelegation()]), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return registerIntentsStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('registerIntentsStep', () => { + it('is named "register-intents"', () => { + expect(registerIntentsStep.name).toBe('register-intents'); + }); + + describe('when no intents exist for the account', () => { + it('submits an intent for each stored delegation and returns "completed"', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toStrictEqual([ + { + account: MOCK_ADDRESS, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + metadata: { + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }, + }, + { + account: MOCK_ADDRESS, + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + metadata: { + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }, + }, + ]); + }); + }); + + describe('when an active intent already exists for one delegation', () => { + it('submits only the missing intent', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ delegationHash: MOCK_MUSD_DELEGATION_HASH }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(1); + expect(submitted[0].delegationHash).toBe(MOCK_VMUSD_DELEGATION_HASH); + expect(submitted[0].metadata.type).toBe('cash-withdrawal'); + }); + + it('matches delegationHash case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ + delegationHash: MOCK_MUSD_DELEGATION_HASH.toUpperCase() as Hex, + }), + makeIntentEntry({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when active intents already exist for both delegations', () => { + it('returns "already-done" without calling createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ delegationHash: MOCK_MUSD_DELEGATION_HASH }), + makeIntentEntry({ delegationHash: MOCK_VMUSD_DELEGATION_HASH }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when an intent exists but is revoked', () => { + it('re-registers the revoked intent', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + status: 'revoked', + }), + makeIntentEntry({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + status: 'active', + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(1); + expect(submitted[0].delegationHash).toBe(MOCK_MUSD_DELEGATION_HASH); + }); + }); + + describe('filtering stored delegations', () => { + it('ignores delegations from a different delegator', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: OTHER_ADDRESS, + delegationHash: `0x${'01'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + expect( + submitted.map( + (intent: { delegationHash: Hex }) => intent.delegationHash, + ), + ).toStrictEqual([MOCK_MUSD_DELEGATION_HASH, MOCK_VMUSD_DELEGATION_HASH]); + }); + + it('ignores delegations to a different delegate', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegate: OTHER_ADDRESS, + delegationHash: `0x${'02'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('ignores delegations on a different chain', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + chainIdHex: OTHER_CHAIN_ID, + delegationHash: `0x${'03'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('matches identifying fields case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'cash-deposit', + }), + withdrawalDelegation(), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('returns "already-done" when no delegations match the filter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: OTHER_ADDRESS, + tokenAddress: OTHER_TOKEN, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when a stored delegation has an unrecognized metadata type', () => { + it('throws rather than coercing into a CHOMP intent', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'lend', + }), + ]); + + await expect(run(messenger)).rejects.toThrow( + 'Expected delegation type to be "cash-deposit" or "cash-withdrawal", got "lend"', + ); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not call createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('propagates errors from getIntentsByAddress and does not call createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('propagates errors from createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.createIntents.mockRejectedValue(new Error('submit failed')); + + await expect(run(messenger)).rejects.toThrow('submit failed'); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.ts new file mode 100644 index 0000000000..fe638ad7f3 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.ts @@ -0,0 +1,88 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import type { + IntentEntry, + SendIntentParams, +} from '@metamask/chomp-api-service'; +import type { Hex } from '@metamask/utils'; + +import type { Step } from './step'; + +const equalsIgnoreCase = (a: Hex, b: Hex): boolean => + a.toLowerCase() === b.toLowerCase(); + +type IntentMetadataType = SendIntentParams['metadata']['type']; + +/** + * Parses a delegation's metadata `type` field — typed as `string` in storage — + * into the narrow set of CHOMP intent types. Throws if the field carries any + * other value, since registering it as an intent would be a category error. + * + * @param type - The `type` field from `DelegationMetadata`. + * @returns The same value, narrowed to `IntentMetadataType`. + */ +function parseIntentMetadataType(type: string): IntentMetadataType { + if (type !== 'cash-deposit' && type !== 'cash-withdrawal') { + throw new Error( + `Expected delegation type to be "cash-deposit" or "cash-withdrawal", got "${type}"`, + ); + } + return type; +} + +/** + * Registers CHOMP intents for the auto-deposit / auto-withdrawal delegations + * persisted by the build-delegation step. + * + * For each stored delegation between this account and CHOMP's delegate on + * this chain, the step builds an intent referencing the stored + * `delegationHash` and submits the batch to `POST /v1/intent`. Delegations + * whose `delegationHash` already has an active intent on CHOMP are skipped + * (revoked intents are re-registered). Reports `'already-done'` when every + * eligible delegation already has an active intent. + * + * Once registered, CHOMP re-fetches the delegation from Authenticated User + * Storage, re-validates it, and adds the account to its monitoring list so + * subsequent eligible operations can be picked up automatically. + */ +export const registerIntentsStep: Step = { + name: 'register-intents', + async run({ messenger, address, chainId, delegateAddress }) { + const [delegations, existingIntents] = await Promise.all([ + messenger.call('AuthenticatedUserStorageService:listDelegations'), + messenger.call('ChompApiService:getIntentsByAddress', address), + ]); + + const activeIntentHashes = new Set( + existingIntents + .filter((intent: IntentEntry) => intent.status === 'active') + .map((intent: IntentEntry) => intent.delegationHash.toLowerCase()), + ); + + const needsIntent = (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + !activeIntentHashes.has(entry.metadata.delegationHash.toLowerCase()); + + const toIntent = (entry: DelegationResponse): SendIntentParams => ({ + account: address, + delegationHash: entry.metadata.delegationHash, + chainId, + metadata: { + allowance: entry.metadata.allowance, + tokenSymbol: entry.metadata.tokenSymbol, + tokenAddress: entry.metadata.tokenAddress, + type: parseIntentMetadataType(entry.metadata.type), + }, + }); + + const intents = delegations.filter(needsIntent).map(toIntent); + + if (intents.length === 0) { + return 'already-done'; + } + + await messenger.call('ChompApiService:createIntents', intents); + return 'completed'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index fa164d3354..9537119d8a 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -9,7 +9,14 @@ export type StepContext = { messenger: MoneyAccountUpgradeControllerMessenger; address: Hex; chainId: Hex; + boringVaultAddress: Hex; + delegateAddress: Hex; delegatorImplAddress: Hex; + erc20TransferAmountEnforcer: Hex; + musdTokenAddress: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; + vedaVaultAdapterAddress: Hex; }; /** diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts index c6a18dc179..db8db0ab26 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -1,18 +1,25 @@ import type { Hex } from '@metamask/utils'; /** - * Contract addresses and configuration required to perform the - * Money Account upgrade sequence. + * Configuration required to perform the Money Account upgrade sequence. + * + * `delegateAddress`, `musdTokenAddress`, and `vedaVaultAdapterAddress` come + * from the CHOMP service details API. `delegatorImplAddress` and the caveat + * enforcer addresses are resolved from `@metamask/delegation-deployments` for + * the target chain. (DelegationManager resolution is delegated to + * `@metamask/delegation-controller`, which handles delegation signing.) */ 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. */ + /** The mUSD token contract address (deposit-side delegation token). */ musdTokenAddress: Hex; + /** The Veda boring vault contract address (withdrawal-side delegation token, vmUSD). */ + boringVaultAddress: Hex; /** The Veda vault adapter contract address. */ vedaVaultAdapterAddress: Hex; + /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ + delegatorImplAddress: Hex; /** Address of the ERC20TransferAmountEnforcer caveat enforcer. */ erc20TransferAmountEnforcer: Hex; /** Address of the RedeemerEnforcer caveat enforcer. */ @@ -20,15 +27,3 @@ export type UpgradeConfig = { /** 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 index b69bb81cca..033cb7d8b0 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -6,8 +6,10 @@ "rootDir": "./src" }, "references": [ + { "path": "../authenticated-user-storage/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../chomp-api-service/tsconfig.build.json" }, + { "path": "../delegation-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json index ffcde5ec67..7993854f44 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -4,8 +4,10 @@ "baseUrl": "./" }, "references": [ + { "path": "../authenticated-user-storage" }, { "path": "../base-controller" }, { "path": "../chomp-api-service" }, + { "path": "../delegation-controller" }, { "path": "../keyring-controller" }, { "path": "../messenger" }, { "path": "../network-controller" }, diff --git a/yarn.lock b/yarn.lock index 3f95b15205..4a149ab454 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2926,7 +2926,7 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": +"@metamask/authenticated-user-storage@npm:^1.0.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": version: 0.0.0-use.local resolution: "@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage" dependencies: @@ -3459,7 +3459,7 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-controller@workspace:packages/delegation-controller": +"@metamask/delegation-controller@npm:^3.0.0, @metamask/delegation-controller@workspace:packages/delegation-controller": version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" dependencies: @@ -4518,9 +4518,13 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller" dependencies: + "@metamask/authenticated-user-storage": "npm:^1.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.0.0" + "@metamask/delegation-controller": "npm:^3.0.0" + "@metamask/delegation-core": "npm:^2.0.0" + "@metamask/delegation-deployments": "npm:^1.3.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^30.1.0" @@ -7418,7 +7422,7 @@ __metadata: languageName: node linkType: hard -"abitype@npm:1.2.3, abitype@npm:^1.2.3": +"abitype@npm:1.2.3": version: 1.2.3 resolution: "abitype@npm:1.2.3" peerDependencies: @@ -7433,6 +7437,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:^1.2.3": + version: 1.2.4 + resolution: "abitype@npm:1.2.4" + peerDependencies: + typescript: ">=5.0.4" + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + checksum: 10/500b317a53b34cb6ffe3e4f090e135972b43cd2fbdfebe64fc497dfd8515d9117919e5f88f0aaede332d29a21c1826be64a3ffa620b0b91c16e8b560b6635714 + languageName: node + linkType: hard + "abort-controller@npm:^3.0.0": version: 3.0.0 resolution: "abort-controller@npm:3.0.0" @@ -12854,9 +12873,9 @@ __metadata: languageName: node linkType: hard -"ox@npm:0.12.4": - version: 0.12.4 - resolution: "ox@npm:0.12.4" +"ox@npm:0.14.20": + version: 0.14.20 + resolution: "ox@npm:0.14.20" dependencies: "@adraffy/ens-normalize": "npm:^1.11.0" "@noble/ciphers": "npm:^1.3.0" @@ -12871,7 +12890,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/077509b841658693a411df505d0bdbbee2d68734aa19736ccff5a6087c119c4aebc1d8d8c2039ca9f16ae7430cb44812e4c182f858cab67c9a755dd0e9914178 + checksum: 10/96526073193f3a6dd2ccd21bcc255e82c7226d6de63fa17a2021c75232fdc9bc969e75e2cbc0c8d5163d88c575e08dc4c75dec7333b1727f080585f07fc6c1ed languageName: node linkType: hard @@ -15022,8 +15041,8 @@ __metadata: linkType: hard "viem@npm:^2.36.0": - version: 2.46.2 - resolution: "viem@npm:2.46.2" + version: 2.48.4 + resolution: "viem@npm:2.48.4" dependencies: "@noble/curves": "npm:1.9.1" "@noble/hashes": "npm:1.8.0" @@ -15031,14 +15050,14 @@ __metadata: "@scure/bip39": "npm:1.6.0" abitype: "npm:1.2.3" isows: "npm:1.0.7" - ox: "npm:0.12.4" + ox: "npm:0.14.20" ws: "npm:8.18.3" peerDependencies: typescript: ">=5.0.4" peerDependenciesMeta: typescript: optional: true - checksum: 10/dd763503c9fc7c3c2908f8cd403f375a0c313d0ded7aeeef87e1672553fc75cca070ed02e2d811ccc5d3cfb7a589be23e45cb147a556a0a0751adbb3f77be265 + checksum: 10/79ab1c8941013e1b4d12ef0bd7fcca6108cfc078b669cc02ae5a08c94d4e3b6de182071cfb40fb4e33ddc40b3aa997f3ebb50d269c85512cefcefdce49b193a0 languageName: node linkType: hard