From 37c1743ff6413392132b951306acfb86f5b7d29f Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 13:08:41 +0100 Subject: [PATCH 01/15] feat: add build-delegation step to MoneyAccountUpgradeController Adds a third step to the upgrade sequence that builds, signs, and submits the auto-deposit delegation that authorises CHOMP's delegate to move mUSD into the Veda vault on the user's behalf. The step: - Looks up existing delegations via AuthenticatedUserStorageService:listDelegations and skips when one matches the configured (delegator, delegate, chain, token). - Builds a per-call 32-byte salt and constructs the delegation with redeemer + valueLte + erc20TransferAmount caveats. - Signs as EIP-712 V4 typed data via KeyringController:signTypedMessage. - Submits to ChompApiService:verifyDelegation; throws on rejection. The `InitConfig` passed to `init()` carries the delegator-impl and caveat-enforcer addresses; the messenger gains the three new allowed actions. --- README.md | 1 + .../CHANGELOG.md | 8 + .../jest.config.js | 25 +- .../package.json | 6 +- .../src/MoneyAccountUpgradeController.test.ts | 25 +- .../src/MoneyAccountUpgradeController.ts | 34 +- .../src/steps/associate-address.test.ts | 71 ++- .../src/steps/build-delegations.test.ts | 411 ++++++++++++++++++ .../src/steps/build-delegations.ts | 110 +++++ .../src/steps/eip-7702-authorization.test.ts | 14 + .../src/steps/step.ts | 6 + .../src/types.ts | 2 +- .../tsconfig.build.json | 1 + .../tsconfig.json | 1 + yarn.lock | 128 +++++- 15 files changed, 777 insertions(+), 66 deletions(-) create mode 100644 packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts create mode 100644 packages/money-account-upgrade-controller/src/steps/build-delegations.ts diff --git a/README.md b/README.md index b163294787..0ecb2bff93 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,7 @@ 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 --> keyring_controller; diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index d3b4943766..7f1b1be1b0 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,8 +7,16 @@ 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)) + ### Changed +- **BREAKING:** The controller messenger now requires access to three additional allowed actions: `ChompApiService:verifyDelegation`, `KeyringController:signTypedMessage`, and `AuthenticatedUserStorageService:listDelegations`. Consumers must update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `InitConfig` no longer includes `musdTokenAddress`; it is now derived internally from the Veda protocol service details. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `InitConfig` now also requires `erc20TransferAmountEnforcer`; the build-delegation step uses it (along with `redeemerEnforcer` and `valueLteEnforcer`) to pin caveat enforcer deployments rather than relying on `@metamask/smart-accounts-kit`'s registry. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Add `@metamask/authenticated-user-storage`, `@metamask/smart-accounts-kit`, `uuid`, and `viem` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) ## [1.3.2] diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js index ca08413339..a8f9bb5d0b 100644 --- a/packages/money-account-upgrade-controller/jest.config.js +++ b/packages/money-account-upgrade-controller/jest.config.js @@ -3,18 +3,21 @@ * https://jestjs.io/docs/configuration */ -const merge = require('deepmerge'); const path = require('path'); const baseConfig = require('../../jest.config.packages'); +// Transitive dep of `@metamask/smart-accounts-kit`; resolved up-front so the +// base config's `^@metamask/(.+)$` mapper doesn't rewrite it to a missing path. +const delegationAbisBytecodePath = + // eslint-disable-next-line n/no-extraneous-require + require.resolve('@metamask/delegation-abis/bytecode'); + 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 +26,14 @@ module.exports = merge(baseConfig, { statements: 100, }, }, -}); + // The base config's `^@metamask/(.+)$` mapper rewrites every `@metamask/*` + // import without honouring the package.json `exports` field, which breaks + // subpath imports like `@metamask/smart-accounts-kit/utils`. Resolve those + // explicitly here, before falling through to the base mapper. + moduleNameMapper: { + '^@metamask/smart-accounts-kit/utils$': + require.resolve('@metamask/smart-accounts-kit/utils'), + '^@metamask/delegation-abis/bytecode$': delegationAbisBytecodePath, + ...baseConfig.moduleNameMapper, + }, +}; diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 0e3b4c1fe4..2361700702 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -53,12 +53,16 @@ "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.1", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^31.1.0", - "@metamask/utils": "^11.9.0" + "@metamask/smart-accounts-kit": "^1.3.0", + "@metamask/utils": "^11.9.0", + "uuid": "^8.3.2", + "viem": "^2.46.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.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..47df80e07e 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -27,7 +27,7 @@ const MOCK_CONFIG: UpgradeConfig = { const MOCK_INIT_CONFIG = { delegatorImplAddress: MOCK_CONFIG.delegatorImplAddress, - musdTokenAddress: MOCK_CONFIG.musdTokenAddress, + erc20TransferAmountEnforcer: MOCK_CONFIG.erc20TransferAmountEnforcer, redeemerEnforcer: MOCK_CONFIG.redeemerEnforcer, valueLteEnforcer: MOCK_CONFIG.valueLteEnforcer, }; @@ -41,7 +41,7 @@ const MOCK_SERVICE_DETAILS_RESPONSE = { vedaProtocol: { supportedTokens: [ { - tokenAddress: MOCK_CONFIG.erc20TransferAmountEnforcer, + tokenAddress: MOCK_CONFIG.musdTokenAddress, tokenDecimals: 18, }, ], @@ -68,6 +68,9 @@ type Mocks = { findNetworkClientIdByChainId: jest.Mock; getNetworkClientById: jest.Mock; providerRequest: jest.Mock; + listDelegations: jest.Mock; + signTypedMessage: jest.Mock; + verifyDelegation: jest.Mock; }; function setup(): { @@ -118,6 +121,9 @@ function setup(): { provider: { request: providerRequest }, }), providerRequest, + listDelegations: jest.fn().mockResolvedValue([]), + signTypedMessage: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), }; const rootMessenger = new Messenger({ @@ -152,6 +158,18 @@ function setup(): { 'NetworkController:getNetworkClientById', mocks.getNetworkClientById, ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signTypedMessage', + mocks.signTypedMessage, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ namespace: 'MoneyAccountUpgradeController', @@ -167,6 +185,9 @@ function setup(): { 'KeyringController:signEip7702Authorization', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', + 'AuthenticatedUserStorageService:listDelegations', + 'KeyringController:signTypedMessage', + 'ChompApiService:verifyDelegation', ], events: [], messenger, diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 113fca4260..92c273bac3 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -1,3 +1,4 @@ +import type { AuthenticatedUserStorageServiceListDelegationsAction } from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, ControllerStateChangedEvent, @@ -8,10 +9,12 @@ import type { ChompApiServiceAssociateAddressAction, ChompApiServiceCreateUpgradeAction, ChompApiServiceGetServiceDetailsAction, + ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; import type { KeyringControllerSignEip7702AuthorizationAction, KeyringControllerSignPersonalMessageAction, + KeyringControllerSignTypedMessageAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { @@ -22,6 +25,7 @@ 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 type { Step } from './steps/step'; import type { InitConfig } from './types'; @@ -49,10 +53,13 @@ type AllowedActions = | ChompApiServiceAssociateAddressAction | ChompApiServiceCreateUpgradeAction | ChompApiServiceGetServiceDetailsAction + | ChompApiServiceVerifyDelegationAction | KeyringControllerSignEip7702AuthorizationAction | KeyringControllerSignPersonalMessageAction + | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction - | NetworkControllerGetNetworkClientByIdAction; + | NetworkControllerGetNetworkClientByIdAction + | AuthenticatedUserStorageServiceListDelegationsAction; export type MoneyAccountUpgradeControllerStateChangedEvent = ControllerStateChangedEvent< @@ -79,9 +86,22 @@ export class MoneyAccountUpgradeController extends BaseController< MoneyAccountUpgradeControllerState, MoneyAccountUpgradeControllerMessenger > { - #config?: { chainId: Hex; delegatorImplAddress: Hex }; - - readonly #steps: Step[] = [associateAddressStep, eip7702AuthorizationStep]; + #config?: { + chainId: Hex; + delegateAddress: Hex; + delegatorImplAddress: Hex; + erc20TransferAmountEnforcer: Hex; + musdTokenAddress: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; + vedaVaultAdapterAddress: Hex; + }; + + readonly #steps: Step[] = [ + associateAddressStep, + eip7702AuthorizationStep, + buildDelegationStep, + ]; /** * Constructor for the MoneyAccountUpgradeController. @@ -140,7 +160,13 @@ export class MoneyAccountUpgradeController extends BaseController< this.#config = { chainId, + delegateAddress: chain.autoDepositDelegate, delegatorImplAddress: initConfig.delegatorImplAddress, + erc20TransferAmountEnforcer: initConfig.erc20TransferAmountEnforcer, + musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, + redeemerEnforcer: initConfig.redeemerEnforcer, + valueLteEnforcer: initConfig.valueLteEnforcer, + vedaVaultAdapterAddress: vedaProtocol.adapterAddress, }; } 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..3201932436 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,23 @@ function setup(): { return { messenger, mocks }; } +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + 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 +106,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 +117,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 +129,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 +141,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 +150,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 +158,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..165d53c311 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -0,0 +1,411 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { + createDelegation, + getSmartAccountsEnvironment, +} from '@metamask/smart-accounts-kit'; +import { + SIGNABLE_DELEGATION_TYPED_DATA, + toDelegationStruct, +} from '@metamask/smart-accounts-kit/utils'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { buildDelegationStep } from './build-delegations'; + +jest.mock('@metamask/smart-accounts-kit', () => ({ + createDelegation: jest.fn(), + getSmartAccountsEnvironment: jest.fn(), +})); + +jest.mock( + '@metamask/smart-accounts-kit/utils', + () => ({ + SIGNABLE_DELEGATION_TYPED_DATA: { Delegation: [] }, + toDelegationStruct: jest.fn(), + }), + { virtual: true }, +); + +const mockCreateDelegation = jest.mocked(createDelegation); +const mockGetEnvironment = jest.mocked(getSmartAccountsEnvironment); +const mockToDelegationStruct = jest.mocked(toDelegationStruct); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_CHAIN_ID_DECIMAL = 11155111; +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_DELEGATION_MANAGER = + '0x5555555555555555555555555555555555555555' 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 MAX_UINT256 = 2n ** 256n - 1n; + +// SDK-derived environment, with deliberately distinct enforcer addresses so +// the test can prove the step overrides them with the configured values. +const MOCK_ENVIRONMENT = { + DelegationManager: MOCK_DELEGATION_MANAGER, + caveatEnforcers: { + ERC20TransferAmountEnforcer: + '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead' as Hex, + RedeemerEnforcer: '0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef' as Hex, + ValueLteEnforcer: '0xfacefacefacefacefacefacefacefacefaceface' as Hex, + UnrelatedEnforcer: '0x1234123412341234123412341234123412341234' as Hex, + }, +} as unknown as ReturnType; + +const EXPECTED_ENVIRONMENT = { + ...MOCK_ENVIRONMENT, + caveatEnforcers: { + ...MOCK_ENVIRONMENT.caveatEnforcers, + ERC20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + RedeemerEnforcer: MOCK_REDEEMER_ENFORCER, + ValueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + }, +}; + +const MOCK_DELEGATION: ReturnType = { + delegate: MOCK_DELEGATE, + delegator: MOCK_ADDRESS, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + caveats: [ + { + enforcer: '0x6666666666666666666666666666666666666666' as Hex, + terms: '0x' as Hex, + args: '0x' as Hex, + }, + ], + salt: `0x${'42'.repeat(32)}`, + signature: '0x' as Hex, +}; + +const MOCK_DELEGATION_STRUCT = { + ...MOCK_DELEGATION, + salt: BigInt(MOCK_DELEGATION.salt), +}; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry, + * defaulting every identifying field to one that matches our run() config. + * 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: { + ...MOCK_DELEGATION, + delegator: overrides.delegator ?? MOCK_ADDRESS, + delegate: overrides.delegate ?? MOCK_DELEGATE, + signature: '0x' as Hex, + }, + metadata: { + delegationHash: `0x${'ab'.repeat(32)}`, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: '0x00', + tokenSymbol: 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_TOKEN, + type: 'lend', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + signTypedMessage: jest.Mock; + verifyDelegation: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest.fn().mockResolvedValue([]), + signTypedMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signTypedMessage', + mocks.signTypedMessage, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'KeyringController:signTypedMessage', + 'ChompApiService:verifyDelegation', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return buildDelegationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + 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('buildDelegationStep', () => { + beforeEach(() => { + mockGetEnvironment.mockReturnValue(MOCK_ENVIRONMENT); + mockCreateDelegation.mockReturnValue(MOCK_DELEGATION); + mockToDelegationStruct.mockReturnValue(MOCK_DELEGATION_STRUCT); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "build-delegation"', () => { + expect(buildDelegationStep.name).toBe('build-delegation'); + }); + + it('builds the delegation against the chain-specific environment with config-pinned enforcer addresses and a fresh 32-byte salt', async () => { + const { messenger } = setup(); + + await run(messenger); + + expect(mockGetEnvironment).toHaveBeenCalledWith(MOCK_CHAIN_ID_DECIMAL); + expect(mockCreateDelegation).toHaveBeenCalledWith({ + environment: EXPECTED_ENVIRONMENT, + scope: { + type: 'erc20TransferAmount', + tokenAddress: MOCK_TOKEN, + maxAmount: MAX_UINT256, + }, + from: MOCK_ADDRESS, + to: MOCK_DELEGATE, + caveats: [ + { type: 'redeemer', redeemers: [MOCK_VAULT_ADAPTER] }, + { type: 'valueLte', maxValue: 0n }, + ], + // 32-byte 0x-prefixed hex string. + salt: expect.stringMatching(/^0x[0-9a-f]{64}$/u) as Hex, + }); + }); + + describe('when listDelegations returns a delegation matching the config', () => { + it('returns "already-done" without building, signing, or submitting', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([makeDelegationResponse()]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mockCreateDelegation).not.toHaveBeenCalled(); + expect(mocks.signTypedMessage).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('matches addresses and chainId 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_TOKEN.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signTypedMessage).not.toHaveBeenCalled(); + }); + + it('returns "already-done" when the matching entry is one of several', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ chainIdHex: OTHER_CHAIN_ID }), + makeDelegationResponse(), + makeDelegationResponse({ tokenAddress: OTHER_TOKEN }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + }); + + describe('when no listed delegation matches the config', () => { + it.each([ + ['delegator differs', { delegator: OTHER_ADDRESS }], + ['delegate differs', { delegate: OTHER_ADDRESS }], + ['chainIdHex differs', { chainIdHex: OTHER_CHAIN_ID }], + ['tokenAddress differs', { tokenAddress: OTHER_TOKEN }], + ])('proceeds when %s', async (_label, override) => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse(override), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mockCreateDelegation).toHaveBeenCalledTimes(1); + expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + }); + + it('proceeds when listDelegations is empty', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + }); + + it('signs the delegation as EIP-712 V4 typed data scoped to the DelegationManager', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.signTypedMessage).toHaveBeenCalledWith( + { + from: MOCK_ADDRESS, + data: { + domain: { + name: 'DelegationManager', + version: '1', + chainId: MOCK_CHAIN_ID_DECIMAL, + verifyingContract: MOCK_DELEGATION_MANAGER, + }, + types: SIGNABLE_DELEGATION_TYPED_DATA, + primaryType: 'Delegation', + message: MOCK_DELEGATION_STRUCT, + }, + }, + SignTypedDataVersion.V4, + ); + }); + + it('submits the signed delegation to ChompApiService:verifyDelegation', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.verifyDelegation).toHaveBeenCalledWith({ + signedDelegation: { + ...MOCK_DELEGATION, + signature: MOCK_SIGNATURE, + }, + chainId: MOCK_CHAIN_ID, + }); + }); + + it('throws when CHOMP rejects the delegation', 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 rejects without errors', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ valid: false }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: unknown error', + ); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not build, sign, or submit', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mockCreateDelegation).not.toHaveBeenCalled(); + expect(mocks.signTypedMessage).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from signing and does not submit to CHOMP', async () => { + const { messenger, mocks } = setup(); + mocks.signTypedMessage.mockRejectedValue(new Error('signing failed')); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from verifyDelegation', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + }); + }); +}); 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..2e35b2d7a0 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -0,0 +1,110 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { + createDelegation, + getSmartAccountsEnvironment, +} from '@metamask/smart-accounts-kit'; +import { + SIGNABLE_DELEGATION_TYPED_DATA, + toDelegationStruct, +} from '@metamask/smart-accounts-kit/utils'; +import { hexToNumber, bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { webcrypto } from 'node:crypto'; + +import type { Step } from './step'; + +const MAX_UINT256 = 2n ** 256n - 1n; + +const equalsIgnoreCase = (a: Hex, b: Hex): boolean => + a.toLowerCase() === b.toLowerCase(); + +export const buildDelegationStep: Step = { + name: 'build-delegation', + async run({ + messenger, + address, + chainId, + delegateAddress, + erc20TransferAmountEnforcer, + musdTokenAddress, + redeemerEnforcer, + valueLteEnforcer, + vedaVaultAdapterAddress, + }) { + const existingDelegations = await messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + const matchesConfig = (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + equalsIgnoreCase(entry.metadata.tokenAddress, musdTokenAddress); + if (existingDelegations.some(matchesConfig)) { + return 'already-done'; + } + + const saltBytes = webcrypto.getRandomValues(new Uint8Array(32)); + const salt = bytesToHex(saltBytes); + const chainIdDecimal = hexToNumber(chainId); + const baseEnvironment = getSmartAccountsEnvironment(chainIdDecimal); + // Pin enforcer addresses to the values supplied at init() rather than + // letting the SDK resolve them from its own deployment registry. + const environment = { + ...baseEnvironment, + caveatEnforcers: { + ...baseEnvironment.caveatEnforcers, + ERC20TransferAmountEnforcer: erc20TransferAmountEnforcer, + RedeemerEnforcer: redeemerEnforcer, + ValueLteEnforcer: valueLteEnforcer, + }, + }; + + const delegation = createDelegation({ + environment, + scope: { + type: 'erc20TransferAmount', + tokenAddress: musdTokenAddress, + maxAmount: MAX_UINT256, + }, + from: address, + to: delegateAddress, + caveats: [ + { type: 'redeemer', redeemers: [vedaVaultAdapterAddress] }, + { type: 'valueLte', maxValue: 0n }, + ], + salt, + }); + + const typedData = { + domain: { + name: 'DelegationManager', + version: '1', + chainId: chainIdDecimal, + verifyingContract: environment.DelegationManager, + }, + types: SIGNABLE_DELEGATION_TYPED_DATA, + primaryType: 'Delegation' as const, + message: toDelegationStruct({ ...delegation, signature: '0x' }), + }; + + const signature = (await messenger.call( + 'KeyringController:signTypedMessage', + { from: address, data: typedData }, + SignTypedDataVersion.V4, + )) as Hex; + + const result = await messenger.call('ChompApiService:verifyDelegation', { + signedDelegation: { ...delegation, signature }, + chainId, + }); + + if (!result.valid) { + throw new Error( + `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, + ); + } + + return 'completed'; + }, +}; 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..e0b1ed3142 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,13 @@ async function run( messenger, address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, + 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/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index fa164d3354..5d41e34710 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -9,7 +9,13 @@ export type StepContext = { messenger: MoneyAccountUpgradeControllerMessenger; address: Hex; chainId: 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..4db8c0e90e 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -28,7 +28,7 @@ export type UpgradeConfig = { export type InitConfig = Pick< UpgradeConfig, | 'delegatorImplAddress' - | 'musdTokenAddress' + | 'erc20TransferAmountEnforcer' | 'redeemerEnforcer' | 'valueLteEnforcer' >; diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json index b69bb81cca..4f9b9a48ca 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -6,6 +6,7 @@ "rootDir": "./src" }, "references": [ + { "path": "../authenticated-user-storage/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../chomp-api-service/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json index ffcde5ec67..fb9c90b9e5 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -4,6 +4,7 @@ "baseUrl": "./" }, "references": [ + { "path": "../authenticated-user-storage" }, { "path": "../base-controller" }, { "path": "../chomp-api-service" }, { "path": "../keyring-controller" }, diff --git a/yarn.lock b/yarn.lock index a825f4d86b..a658f2ee7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2492,6 +2492,13 @@ __metadata: languageName: node linkType: hard +"@metamask/7715-permission-types@npm:^0.5.0": + version: 0.5.0 + resolution: "@metamask/7715-permission-types@npm:0.5.0" + checksum: 10/f01dcf7ffc3e39536f7cc4d54c088ea659c392de5bdfcaafb9f4d67bbe6b56010358ed2a2ba3adba4e454af51412a2fd5be377cac5c7ab101b032d30711e0b37 + languageName: node + linkType: hard + "@metamask/7715-permission-types@npm:^0.6.0": version: 0.6.0 resolution: "@metamask/7715-permission-types@npm:0.6.0" @@ -2926,7 +2933,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: @@ -3480,6 +3487,13 @@ __metadata: languageName: node linkType: hard +"@metamask/delegation-abis@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/delegation-abis@npm:1.0.0" + checksum: 10/31b58d3080710cd48fbf471a1cb94fc4cae705f2472d392a3daaa7c73603ad6d0b1c9068e3b71b10f9c39bfe19c1fad90ecf3115e20a00b2d39f5c7789c5b932 + languageName: node + linkType: hard + "@metamask/delegation-controller@workspace:packages/delegation-controller": version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" @@ -3501,6 +3515,17 @@ __metadata: languageName: unknown linkType: soft +"@metamask/delegation-core@npm:^1.1.0": + version: 1.1.0 + resolution: "@metamask/delegation-core@npm:1.1.0" + dependencies: + "@metamask/abi-utils": "npm:^3.0.0" + "@metamask/utils": "npm:^11.4.0" + "@noble/hashes": "npm:^1.8.0" + checksum: 10/672f9e2e2b4e8c312f2cd2ff166bbc508fbdb6e141fe92e678abc9993b9ccbdd17db711477a9b97b6ce3919fa6d51d759c16f6c6fda3f89cb95e303b8aa76f7d + languageName: node + linkType: hard + "@metamask/delegation-core@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/delegation-core@npm:2.0.0" @@ -3512,6 +3537,13 @@ __metadata: languageName: node linkType: hard +"@metamask/delegation-deployments@npm:^1.2.0": + version: 1.2.0 + resolution: "@metamask/delegation-deployments@npm:1.2.0" + checksum: 10/f9cd63d05dd9e9627696fd9fea9c32937839fab5e5f4a6ee15a4d32887ec58069aec1626608cfa8b4b5d39f75070c0329347e72d968fa9e3902932da8365d5c1 + languageName: node + linkType: hard + "@metamask/delegation-deployments@npm:^1.3.0": version: 1.3.0 resolution: "@metamask/delegation-deployments@npm:1.3.0" @@ -4540,12 +4572,14 @@ __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.1" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^31.1.0" + "@metamask/smart-accounts-kit": "npm:^1.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -4556,6 +4590,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + uuid: "npm:^8.3.2" + viem: "npm:^2.46.2" languageName: unknown linkType: soft @@ -5449,6 +5485,22 @@ __metadata: languageName: node linkType: hard +"@metamask/smart-accounts-kit@npm:^1.3.0": + version: 1.3.0 + resolution: "@metamask/smart-accounts-kit@npm:1.3.0" + dependencies: + "@metamask/7715-permission-types": "npm:^0.5.0" + "@metamask/delegation-abis": "npm:^1.0.0" + "@metamask/delegation-core": "npm:^1.1.0" + "@metamask/delegation-deployments": "npm:^1.2.0" + openapi-fetch: "npm:^0.13.5" + ox: "npm:0.8.1" + peerDependencies: + viem: ^2.31.4 + checksum: 10/45da6ddafcdef5c3c0247efa1d2a431349b6bd2684b6b92cc69c3f38eab5816385cbb197c52093811b588b80f4b71c57517861c76cd906c000906d814514430d + languageName: node + linkType: hard + "@metamask/snap-account-service@npm:^0.0.0, @metamask/snap-account-service@workspace:packages/snap-account-service": version: 0.0.0-use.local resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" @@ -5937,7 +5989,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -7440,7 +7492,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: @@ -7455,6 +7507,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:^1.0.8, 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" @@ -12862,6 +12929,22 @@ __metadata: languageName: node linkType: hard +"openapi-fetch@npm:^0.13.5": + version: 0.13.8 + resolution: "openapi-fetch@npm:0.13.8" + dependencies: + openapi-typescript-helpers: "npm:^0.0.15" + checksum: 10/fed630452ac2d6abc680402651d848b7377b651164ca2be61a8c5e1fc89e41b09c928ba9dc92cf7c7ad2d400b3fbe5af380165303f293501dc08cefa4c0f92fd + languageName: node + linkType: hard + +"openapi-typescript-helpers@npm:^0.0.15": + version: 0.0.15 + resolution: "openapi-typescript-helpers@npm:0.0.15" + checksum: 10/63f8f0b8464aed3e5c6910428bd14839bd5c1dd6ddf841bcea9d5f536a6e03e942a028202920da1a8b1ed9e4304c6fca14943d01a8adff2942d1254a229b8c70 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -12876,9 +12959,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" @@ -12893,7 +12976,28 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/077509b841658693a411df505d0bdbbee2d68734aa19736ccff5a6087c119c4aebc1d8d8c2039ca9f16ae7430cb44812e4c182f858cab67c9a755dd0e9914178 + checksum: 10/96526073193f3a6dd2ccd21bcc255e82c7226d6de63fa17a2021c75232fdc9bc969e75e2cbc0c8d5163d88c575e08dc4c75dec7333b1727f080585f07fc6c1ed + languageName: node + linkType: hard + +"ox@npm:0.8.1": + version: 0.8.1 + resolution: "ox@npm:0.8.1" + dependencies: + "@adraffy/ens-normalize": "npm:^1.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.1" + "@noble/hashes": "npm:^1.8.0" + "@scure/bip32": "npm:^1.7.0" + "@scure/bip39": "npm:^1.6.0" + abitype: "npm:^1.0.8" + eventemitter3: "npm:5.0.1" + peerDependencies: + typescript: ">=5.4.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/a3c967e5b30792d89e7ecbdf976c00c625738e96263e1f0a95ad43c27b57ac18f21357eb7a651ce3c0ff0dc54b3ed071516c9804bc48fa2134262a5066b62fcc languageName: node linkType: hard @@ -15043,9 +15147,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.36.0": - version: 2.46.2 - resolution: "viem@npm:2.46.2" +"viem@npm:^2.36.0, 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" @@ -15053,14 +15157,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 From 8d11fa4deea8e6fc459a0464efc5dc15013527b0 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 12:48:34 +0100 Subject: [PATCH 02/15] refactor: build delegations with delegation-core and delegation-deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the @metamask/smart-accounts-kit dependency with the lower-level @metamask/delegation-core (caveat-term encoders) and @metamask/delegation-deployments (Delegation Framework contract registry). - `init()` now takes only a chainId; the EIP-7702 delegator-impl and caveat-enforcer addresses are resolved from `DELEGATOR_CONTRACTS['1.3.0'][chainId]` rather than being passed in. `InitConfig` is no longer exported. - The build-delegation step builds the three caveats directly with delegation-core's `createERC20TransferAmountTerms` / `createValueLteTerms` / `createRedeemerTerms`, and constructs the EIP-712 typed-data message inline (13 lines). - Drops a duplicate `valueLteEnforcer` caveat that the smart-accounts-kit `erc20TransferAmount` scope helper was inadvertently appending on top of the explicit one we passed in. Net dependency size: ~3 MB → ~650 kB. No behaviour change beyond the duplicate-caveat fix. --- .../CHANGELOG.md | 10 +- .../jest.config.js | 16 -- .../package.json | 7 +- .../src/MoneyAccountUpgradeController.test.ts | 92 ++++---- .../src/MoneyAccountUpgradeController.ts | 44 ++-- .../src/index.ts | 2 +- .../src/steps/associate-address.test.ts | 3 + .../src/steps/build-delegations.test.ts | 214 ++++++++---------- .../src/steps/build-delegations.ts | 93 ++++---- .../src/steps/eip-7702-authorization.test.ts | 3 + .../src/steps/step.ts | 1 + .../src/types.ts | 26 +-- yarn.lock | 96 +------- 13 files changed, 257 insertions(+), 350 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 7f1b1be1b0..b9fd0144b9 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -14,11 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** The controller messenger now requires access to three additional allowed actions: `ChompApiService:verifyDelegation`, `KeyringController:signTypedMessage`, and `AuthenticatedUserStorageService:listDelegations`. Consumers must update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) -- **BREAKING:** `InitConfig` no longer includes `musdTokenAddress`; it is now derived internally from the Veda protocol service details. ([#8621](https://github.com/MetaMask/core/pull/8621)) -- **BREAKING:** `InitConfig` now also requires `erc20TransferAmountEnforcer`; the build-delegation step uses it (along with `redeemerEnforcer` and `valueLteEnforcer`) to pin caveat enforcer deployments rather than relying on `@metamask/smart-accounts-kit`'s registry. ([#8621](https://github.com/MetaMask/core/pull/8621)) -- Add `@metamask/authenticated-user-storage`, `@metamask/smart-accounts-kit`, `uuid`, and `viem` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `init()` now takes only a `chainId` and no longer accepts an `InitConfig`. Contract addresses (DelegationManager, the EIP-7702 delegator implementation, and the caveat enforcers) 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) and gains a `delegationManager` field. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Add `@metamask/authenticated-user-storage`, `@metamask/delegation-core`, and `@metamask/delegation-deployments` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +### 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.2] ### Changed diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js index a8f9bb5d0b..5d308fd932 100644 --- a/packages/money-account-upgrade-controller/jest.config.js +++ b/packages/money-account-upgrade-controller/jest.config.js @@ -7,12 +7,6 @@ const path = require('path'); const baseConfig = require('../../jest.config.packages'); -// Transitive dep of `@metamask/smart-accounts-kit`; resolved up-front so the -// base config's `^@metamask/(.+)$` mapper doesn't rewrite it to a missing path. -const delegationAbisBytecodePath = - // eslint-disable-next-line n/no-extraneous-require - require.resolve('@metamask/delegation-abis/bytecode'); - const displayName = path.basename(__dirname); module.exports = { @@ -26,14 +20,4 @@ module.exports = { statements: 100, }, }, - // The base config's `^@metamask/(.+)$` mapper rewrites every `@metamask/*` - // import without honouring the package.json `exports` field, which breaks - // subpath imports like `@metamask/smart-accounts-kit/utils`. Resolve those - // explicitly here, before falling through to the base mapper. - moduleNameMapper: { - '^@metamask/smart-accounts-kit/utils$': - require.resolve('@metamask/smart-accounts-kit/utils'), - '^@metamask/delegation-abis/bytecode$': delegationAbisBytecodePath, - ...baseConfig.moduleNameMapper, - }, }; diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 2361700702..0a01848728 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -56,13 +56,12 @@ "@metamask/authenticated-user-storage": "^1.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/chomp-api-service": "^3.0.1", + "@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": "^31.1.0", - "@metamask/smart-accounts-kit": "^1.3.0", - "@metamask/utils": "^11.9.0", - "uuid": "^8.3.2", - "viem": "^2.46.2" + "@metamask/utils": "^11.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.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 47df80e07e..d0a56664a9 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -1,51 +1,50 @@ +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, - erc20TransferAmountEnforcer: MOCK_CONFIG.erc20TransferAmountEnforcer, - redeemerEnforcer: MOCK_CONFIG.redeemerEnforcer, - valueLteEnforcer: MOCK_CONFIG.valueLteEnforcer, -}; +// 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.musdTokenAddress, + tokenAddress: MOCK_MUSD_TOKEN_ADDRESS, tokenDecimals: 18, }, ], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'] as const, }, }, @@ -107,7 +106,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', @@ -213,11 +212,20 @@ 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(MOCK_CHAIN_ID); 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(UNSUPPORTED_CHAIN_ID)).rejects.toThrow( + `Delegation Framework 1.3.0 is not deployed on chain ${UNSUPPORTED_CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + it('throws when the chain is not found in service details', async () => { const { controller, mocks } = setup(); @@ -226,9 +234,7 @@ describe('MoneyAccountUpgradeController', () => { chains: {}, }); - await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), - ).rejects.toThrow( + await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( `Chain ${MOCK_CHAIN_ID} not found in service details response`, ); }); @@ -240,15 +246,13 @@ 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), - ).rejects.toThrow( + await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( `vedaProtocol not found for chain ${MOCK_CHAIN_ID} in service details response`, ); }); @@ -260,11 +264,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'], }, }, @@ -272,9 +276,7 @@ describe('MoneyAccountUpgradeController', () => { }, }); - await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), - ).rejects.toThrow( + await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, ); }); @@ -297,9 +299,9 @@ describe('MoneyAccountUpgradeController', () => { 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.init(MOCK_CHAIN_ID)).rejects.toThrow( + 'Chain 0x1 not found in service details response', + ); await expect( controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), @@ -308,9 +310,9 @@ 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(MOCK_CHAIN_ID); await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); @@ -323,12 +325,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', }), @@ -337,7 +339,7 @@ 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(MOCK_CHAIN_ID); expect( await rootMessenger.call( @@ -349,7 +351,7 @@ 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(MOCK_CHAIN_ID); 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 92c273bac3..29e94f287d 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -11,6 +11,7 @@ import type { ChompApiServiceGetServiceDetailsAction, ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { KeyringControllerSignEip7702AuthorizationAction, KeyringControllerSignPersonalMessageAction, @@ -21,6 +22,7 @@ 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'; @@ -28,7 +30,13 @@ import { associateAddressStep } from './steps/associate-address'; import { buildDelegationStep } from './steps/build-delegations'; import { eip7702AuthorizationStep } from './steps/eip-7702-authorization'; 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'; @@ -86,16 +94,7 @@ export class MoneyAccountUpgradeController extends BaseController< MoneyAccountUpgradeControllerState, MoneyAccountUpgradeControllerMessenger > { - #config?: { - chainId: Hex; - delegateAddress: Hex; - delegatorImplAddress: Hex; - erc20TransferAmountEnforcer: Hex; - musdTokenAddress: Hex; - redeemerEnforcer: Hex; - valueLteEnforcer: Hex; - vedaVaultAdapterAddress: Hex; - }; + #config?: UpgradeConfig & { chainId: Hex }; readonly #steps: Step[] = [ associateAddressStep, @@ -129,12 +128,20 @@ 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. */ - async init(chainId: Hex, initConfig: InitConfig): Promise { + async init(chainId: 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], @@ -161,12 +168,13 @@ export class MoneyAccountUpgradeController extends BaseController< this.#config = { chainId, delegateAddress: chain.autoDepositDelegate, - delegatorImplAddress: initConfig.delegatorImplAddress, - erc20TransferAmountEnforcer: initConfig.erc20TransferAmountEnforcer, musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, - redeemerEnforcer: initConfig.redeemerEnforcer, - valueLteEnforcer: initConfig.valueLteEnforcer, vedaVaultAdapterAddress: vedaProtocol.adapterAddress, + delegationManager: contracts.DelegationManager, + 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 3201932436..79dbfd2205 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 @@ -20,6 +20,8 @@ const MOCK_REDEEMER_ENFORCER = '0x6666666666666666666666666666666666666666' as Hex; const MOCK_VALUE_LTE_ENFORCER = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_DELEGATION_MANAGER = + '0x8888888888888888888888888888888888888888' as Hex; const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); @@ -80,6 +82,7 @@ async function run( address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, delegateAddress: MOCK_DELEGATE, + delegationManager: MOCK_DELEGATION_MANAGER, delegatorImplAddress: MOCK_DELEGATOR_IMPL, erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, musdTokenAddress: MOCK_TOKEN, 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 index 165d53c311..990244ee15 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -1,4 +1,10 @@ import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, +} from '@metamask/delegation-core'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -6,42 +12,27 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; -import { - createDelegation, - getSmartAccountsEnvironment, -} from '@metamask/smart-accounts-kit'; -import { - SIGNABLE_DELEGATION_TYPED_DATA, - toDelegationStruct, -} from '@metamask/smart-accounts-kit/utils'; import type { Hex } from '@metamask/utils'; import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; import { buildDelegationStep } from './build-delegations'; -jest.mock('@metamask/smart-accounts-kit', () => ({ - createDelegation: jest.fn(), - getSmartAccountsEnvironment: jest.fn(), +jest.mock('@metamask/delegation-core', () => ({ + ROOT_AUTHORITY: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + createERC20TransferAmountTerms: jest.fn(), + createRedeemerTerms: jest.fn(), + createValueLteTerms: jest.fn(), })); -jest.mock( - '@metamask/smart-accounts-kit/utils', - () => ({ - SIGNABLE_DELEGATION_TYPED_DATA: { Delegation: [] }, - toDelegationStruct: jest.fn(), - }), - { virtual: true }, -); - -const mockCreateDelegation = jest.mocked(createDelegation); -const mockGetEnvironment = jest.mocked(getSmartAccountsEnvironment); -const mockToDelegationStruct = jest.mocked(toDelegationStruct); +const mockCreateErc20Terms = jest.mocked(createERC20TransferAmountTerms); +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); +const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) const MOCK_CHAIN_ID_DECIMAL = 11155111; 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_DELEGATION_MANAGER = @@ -56,51 +47,29 @@ const OTHER_CHAIN_ID = '0x1' as Hex; const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; const MOCK_SIGNATURE: Hex = `0x${'cd'.repeat(65)}`; -const MAX_UINT256 = 2n ** 256n - 1n; - -// SDK-derived environment, with deliberately distinct enforcer addresses so -// the test can prove the step overrides them with the configured values. -const MOCK_ENVIRONMENT = { - DelegationManager: MOCK_DELEGATION_MANAGER, - caveatEnforcers: { - ERC20TransferAmountEnforcer: - '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead' as Hex, - RedeemerEnforcer: '0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef' as Hex, - ValueLteEnforcer: '0xfacefacefacefacefacefacefacefacefaceface' as Hex, - UnrelatedEnforcer: '0x1234123412341234123412341234123412341234' as Hex, - }, -} as unknown as ReturnType; - -const EXPECTED_ENVIRONMENT = { - ...MOCK_ENVIRONMENT, - caveatEnforcers: { - ...MOCK_ENVIRONMENT.caveatEnforcers, - ERC20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, - RedeemerEnforcer: MOCK_REDEEMER_ENFORCER, - ValueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, - }, -}; +const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; +const MOCK_ERC20_TERMS: Hex = '0xa2'; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; -const MOCK_DELEGATION: ReturnType = { - delegate: MOCK_DELEGATE, - delegator: MOCK_ADDRESS, - authority: - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, - caveats: [ - { - enforcer: '0x6666666666666666666666666666666666666666' as Hex, - terms: '0x' as Hex, - args: '0x' as Hex, - }, +const SIGNABLE_DELEGATION_TYPED_DATA = { + Caveat: [ + { name: 'enforcer', type: 'address' }, + { name: 'terms', type: 'bytes' }, ], - salt: `0x${'42'.repeat(32)}`, - signature: '0x' as Hex, -}; + Delegation: [ + { name: 'delegate', type: 'address' }, + { name: 'delegator', type: 'address' }, + { name: 'authority', type: 'bytes32' }, + { name: 'caveats', type: 'Caveat[]' }, + { name: 'salt', type: 'uint256' }, + ], +} as const; -const MOCK_DELEGATION_STRUCT = { - ...MOCK_DELEGATION, - salt: BigInt(MOCK_DELEGATION.salt), -}; +const EXPECTED_CAVEATS = [ + { enforcer: MOCK_VALUE_LTE_ENFORCER, terms: MOCK_VALUE_LTE_TERMS, args: '0x' }, + { enforcer: MOCK_ERC20_ENFORCER, terms: MOCK_ERC20_TERMS, args: '0x' }, + { enforcer: MOCK_REDEEMER_ENFORCER, terms: MOCK_REDEEMER_TERMS, args: '0x' }, +]; /** * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry, @@ -124,9 +93,11 @@ function makeDelegationResponse( ): DelegationResponse { return { signedDelegation: { - ...MOCK_DELEGATION, - delegator: overrides.delegator ?? MOCK_ADDRESS, 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: { @@ -200,7 +171,8 @@ async function run( address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, delegateAddress: MOCK_DELEGATE, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, + delegationManager: MOCK_DELEGATION_MANAGER, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, musdTokenAddress: MOCK_TOKEN, redeemerEnforcer: MOCK_REDEEMER_ENFORCER, @@ -211,9 +183,12 @@ async function run( describe('buildDelegationStep', () => { beforeEach(() => { - mockGetEnvironment.mockReturnValue(MOCK_ENVIRONMENT); - mockCreateDelegation.mockReturnValue(MOCK_DELEGATION); - mockToDelegationStruct.mockReturnValue(MOCK_DELEGATION_STRUCT); + // 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); + mockCreateErc20Terms.mockReturnValue(MOCK_ERC20_TERMS as never); + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); }); afterEach(() => { @@ -224,27 +199,18 @@ describe('buildDelegationStep', () => { expect(buildDelegationStep.name).toBe('build-delegation'); }); - it('builds the delegation against the chain-specific environment with config-pinned enforcer addresses and a fresh 32-byte salt', async () => { + it('encodes each caveat with the configured enforcer addresses', async () => { const { messenger } = setup(); await run(messenger); - expect(mockGetEnvironment).toHaveBeenCalledWith(MOCK_CHAIN_ID_DECIMAL); - expect(mockCreateDelegation).toHaveBeenCalledWith({ - environment: EXPECTED_ENVIRONMENT, - scope: { - type: 'erc20TransferAmount', - tokenAddress: MOCK_TOKEN, - maxAmount: MAX_UINT256, - }, - from: MOCK_ADDRESS, - to: MOCK_DELEGATE, - caveats: [ - { type: 'redeemer', redeemers: [MOCK_VAULT_ADAPTER] }, - { type: 'valueLte', maxValue: 0n }, - ], - // 32-byte 0x-prefixed hex string. - salt: expect.stringMatching(/^0x[0-9a-f]{64}$/u) as Hex, + expect(mockCreateValueLteTerms).toHaveBeenCalledWith({ maxValue: 0n }); + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_TOKEN, + maxAmount: 2n ** 256n - 1n, + }); + expect(mockCreateRedeemerTerms).toHaveBeenCalledWith({ + redeemers: [MOCK_VAULT_ADAPTER], }); }); @@ -256,7 +222,7 @@ describe('buildDelegationStep', () => { const result = await run(messenger); expect(result).toBe('already-done'); - expect(mockCreateDelegation).not.toHaveBeenCalled(); + expect(mockCreateErc20Terms).not.toHaveBeenCalled(); expect(mocks.signTypedMessage).not.toHaveBeenCalled(); expect(mocks.verifyDelegation).not.toHaveBeenCalled(); }); @@ -307,7 +273,6 @@ describe('buildDelegationStep', () => { const result = await run(messenger); expect(result).toBe('completed'); - expect(mockCreateDelegation).toHaveBeenCalledTimes(1); expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); }); @@ -322,42 +287,55 @@ describe('buildDelegationStep', () => { expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); }); - it('signs the delegation as EIP-712 V4 typed data scoped to the DelegationManager', async () => { + it('signs the delegation as EIP-712 V4 typed data scoped to the DelegationManager, with a fresh 32-byte salt', async () => { const { messenger, mocks } = setup(); await run(messenger); - expect(mocks.signTypedMessage).toHaveBeenCalledWith( - { - from: MOCK_ADDRESS, - data: { - domain: { - name: 'DelegationManager', - version: '1', - chainId: MOCK_CHAIN_ID_DECIMAL, - verifyingContract: MOCK_DELEGATION_MANAGER, - }, - types: SIGNABLE_DELEGATION_TYPED_DATA, - primaryType: 'Delegation', - message: MOCK_DELEGATION_STRUCT, - }, - }, - SignTypedDataVersion.V4, - ); + expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); + const [params, version] = mocks.signTypedMessage.mock.calls[0]; + expect(version).toBe(SignTypedDataVersion.V4); + expect(params.from).toBe(MOCK_ADDRESS); + expect(params.data.domain).toStrictEqual({ + name: 'DelegationManager', + version: '1', + chainId: MOCK_CHAIN_ID_DECIMAL, + verifyingContract: MOCK_DELEGATION_MANAGER, + }); + expect(params.data.types).toStrictEqual(SIGNABLE_DELEGATION_TYPED_DATA); + expect(params.data.primaryType).toBe('Delegation'); + expect(params.data.message.delegate).toBe(MOCK_DELEGATE); + expect(params.data.message.delegator).toBe(MOCK_ADDRESS); + expect(params.data.message.authority).toBe(ROOT_AUTHORITY); + expect(params.data.message.caveats).toStrictEqual(EXPECTED_CAVEATS); + expect(typeof params.data.message.salt).toBe('bigint'); }); - it('submits the signed delegation to ChompApiService:verifyDelegation', async () => { + it('uses a fresh 32-byte salt on each call', async () => { const { messenger, mocks } = setup(); + await run(messenger); await run(messenger); - expect(mocks.verifyDelegation).toHaveBeenCalledWith({ - signedDelegation: { - ...MOCK_DELEGATION, - signature: MOCK_SIGNATURE, - }, - chainId: MOCK_CHAIN_ID, - }); + const saltA = mocks.signTypedMessage.mock.calls[0][0].data.message.salt; + const saltB = mocks.signTypedMessage.mock.calls[1][0].data.message.salt; + expect(saltA).not.toBe(saltB); + }); + + it('submits the signed delegation to ChompApiService:verifyDelegation, with hex salt', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + const submitted = mocks.verifyDelegation.mock.calls[0][0]; + expect(submitted.chainId).toBe(MOCK_CHAIN_ID); + expect(submitted.signedDelegation.delegate).toBe(MOCK_DELEGATE); + expect(submitted.signedDelegation.delegator).toBe(MOCK_ADDRESS); + expect(submitted.signedDelegation.authority).toBe(ROOT_AUTHORITY); + expect(submitted.signedDelegation.caveats).toStrictEqual(EXPECTED_CAVEATS); + expect(submitted.signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(submitted.signedDelegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); }); it('throws when CHOMP rejects the delegation', async () => { @@ -388,7 +366,7 @@ describe('buildDelegationStep', () => { mocks.listDelegations.mockRejectedValue(new Error('storage failed')); await expect(run(messenger)).rejects.toThrow('storage failed'); - expect(mockCreateDelegation).not.toHaveBeenCalled(); + expect(mockCreateErc20Terms).not.toHaveBeenCalled(); expect(mocks.signTypedMessage).not.toHaveBeenCalled(); expect(mocks.verifyDelegation).not.toHaveBeenCalled(); }); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts index 2e35b2d7a0..d1437fe4c5 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -1,21 +1,35 @@ import type { DelegationResponse } from '@metamask/authenticated-user-storage'; -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import { - createDelegation, - getSmartAccountsEnvironment, -} from '@metamask/smart-accounts-kit'; import { - SIGNABLE_DELEGATION_TYPED_DATA, - toDelegationStruct, -} from '@metamask/smart-accounts-kit/utils'; -import { hexToNumber, bytesToHex } from '@metamask/utils'; + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, +} from '@metamask/delegation-core'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; +import { bytesToHex, hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import { webcrypto } from 'node:crypto'; import type { Step } from './step'; const MAX_UINT256 = 2n ** 256n - 1n; +// EIP-712 typed-data shape expected by the on-chain DelegationManager. Values +// are hardcoded into the contract; reproduced here verbatim from the +// Delegation Framework reference implementation. +const SIGNABLE_DELEGATION_TYPED_DATA = { + Caveat: [ + { name: 'enforcer', type: 'address' }, + { name: 'terms', type: 'bytes' }, + ], + Delegation: [ + { name: 'delegate', type: 'address' }, + { name: 'delegator', type: 'address' }, + { name: 'authority', type: 'bytes32' }, + { name: 'caveats', type: 'Caveat[]' }, + { name: 'salt', type: 'uint256' }, + ], +} as const; + const equalsIgnoreCase = (a: Hex, b: Hex): boolean => a.toLowerCase() === b.toLowerCase(); @@ -26,6 +40,7 @@ export const buildDelegationStep: Step = { address, chainId, delegateAddress, + delegationManager, erc20TransferAmountEnforcer, musdTokenAddress, redeemerEnforcer, @@ -44,48 +59,50 @@ export const buildDelegationStep: Step = { return 'already-done'; } - const saltBytes = webcrypto.getRandomValues(new Uint8Array(32)); + const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); const salt = bytesToHex(saltBytes); const chainIdDecimal = hexToNumber(chainId); - const baseEnvironment = getSmartAccountsEnvironment(chainIdDecimal); - // Pin enforcer addresses to the values supplied at init() rather than - // letting the SDK resolve them from its own deployment registry. - const environment = { - ...baseEnvironment, - caveatEnforcers: { - ...baseEnvironment.caveatEnforcers, - ERC20TransferAmountEnforcer: erc20TransferAmountEnforcer, - RedeemerEnforcer: redeemerEnforcer, - ValueLteEnforcer: valueLteEnforcer, - }, - }; - const delegation = createDelegation({ - environment, - scope: { - type: 'erc20TransferAmount', - tokenAddress: musdTokenAddress, - maxAmount: MAX_UINT256, + const caveats = [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x' as Hex, }, - from: address, - to: delegateAddress, - caveats: [ - { type: 'redeemer', redeemers: [vedaVaultAdapterAddress] }, - { type: 'valueLte', maxValue: 0n }, - ], + { + enforcer: erc20TransferAmountEnforcer, + terms: createERC20TransferAmountTerms({ + tokenAddress: musdTokenAddress, + maxAmount: MAX_UINT256, + }), + args: '0x' as Hex, + }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), + args: '0x' as Hex, + }, + ]; + + const delegation = { + delegate: delegateAddress, + delegator: address, + authority: ROOT_AUTHORITY, + caveats, salt, - }); + signature: '0x' as Hex, + }; const typedData = { domain: { name: 'DelegationManager', version: '1', chainId: chainIdDecimal, - verifyingContract: environment.DelegationManager, + verifyingContract: delegationManager, }, types: SIGNABLE_DELEGATION_TYPED_DATA, primaryType: 'Delegation' as const, - message: toDelegationStruct({ ...delegation, signature: '0x' }), + message: { ...delegation, salt: BigInt(salt) }, }; const signature = (await messenger.call( 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 e0b1ed3142..49083f4494 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 @@ -21,6 +21,8 @@ const MOCK_REDEEMER_ENFORCER = '0x6666666666666666666666666666666666666666' as Hex; const MOCK_VALUE_LTE_ENFORCER = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_DELEGATION_MANAGER = + '0x8888888888888888888888888888888888888888' as Hex; const MOCK_THIRD_PARTY_IMPL = '0x9999999999999999999999999999999999999999' as Hex; const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; @@ -151,6 +153,7 @@ async function run( address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, delegateAddress: MOCK_DELEGATE, + delegationManager: MOCK_DELEGATION_MANAGER, delegatorImplAddress: MOCK_DELEGATOR_IMPL, erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, musdTokenAddress: MOCK_TOKEN, diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index 5d41e34710..69b166d5a1 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -10,6 +10,7 @@ export type StepContext = { address: Hex; chainId: Hex; delegateAddress: Hex; + delegationManager: Hex; delegatorImplAddress: Hex; erc20TransferAmountEnforcer: Hex; musdTokenAddress: Hex; diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts index 4db8c0e90e..8f91966d6a 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -1,18 +1,24 @@ 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. The remaining contract addresses + * (`delegationManager`, `delegatorImplAddress`, and the caveat enforcers) are + * resolved from `@metamask/delegation-deployments` for the target chain. */ 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 DelegationManager contract (EIP-712 verifying contract). */ + delegationManager: 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 +26,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' - | 'erc20TransferAmountEnforcer' - | 'redeemerEnforcer' - | 'valueLteEnforcer' ->; diff --git a/yarn.lock b/yarn.lock index a658f2ee7d..0226b5c087 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2492,13 +2492,6 @@ __metadata: languageName: node linkType: hard -"@metamask/7715-permission-types@npm:^0.5.0": - version: 0.5.0 - resolution: "@metamask/7715-permission-types@npm:0.5.0" - checksum: 10/f01dcf7ffc3e39536f7cc4d54c088ea659c392de5bdfcaafb9f4d67bbe6b56010358ed2a2ba3adba4e454af51412a2fd5be377cac5c7ab101b032d30711e0b37 - languageName: node - linkType: hard - "@metamask/7715-permission-types@npm:^0.6.0": version: 0.6.0 resolution: "@metamask/7715-permission-types@npm:0.6.0" @@ -3487,13 +3480,6 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-abis@npm:^1.0.0": - version: 1.0.0 - resolution: "@metamask/delegation-abis@npm:1.0.0" - checksum: 10/31b58d3080710cd48fbf471a1cb94fc4cae705f2472d392a3daaa7c73603ad6d0b1c9068e3b71b10f9c39bfe19c1fad90ecf3115e20a00b2d39f5c7789c5b932 - languageName: node - linkType: hard - "@metamask/delegation-controller@workspace:packages/delegation-controller": version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" @@ -3515,17 +3501,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/delegation-core@npm:^1.1.0": - version: 1.1.0 - resolution: "@metamask/delegation-core@npm:1.1.0" - dependencies: - "@metamask/abi-utils": "npm:^3.0.0" - "@metamask/utils": "npm:^11.4.0" - "@noble/hashes": "npm:^1.8.0" - checksum: 10/672f9e2e2b4e8c312f2cd2ff166bbc508fbdb6e141fe92e678abc9993b9ccbdd17db711477a9b97b6ce3919fa6d51d759c16f6c6fda3f89cb95e303b8aa76f7d - languageName: node - linkType: hard - "@metamask/delegation-core@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/delegation-core@npm:2.0.0" @@ -3537,13 +3512,6 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-deployments@npm:^1.2.0": - version: 1.2.0 - resolution: "@metamask/delegation-deployments@npm:1.2.0" - checksum: 10/f9cd63d05dd9e9627696fd9fea9c32937839fab5e5f4a6ee15a4d32887ec58069aec1626608cfa8b4b5d39f75070c0329347e72d968fa9e3902932da8365d5c1 - languageName: node - linkType: hard - "@metamask/delegation-deployments@npm:^1.3.0": version: 1.3.0 resolution: "@metamask/delegation-deployments@npm:1.3.0" @@ -4576,10 +4544,11 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.0.1" + "@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:^31.1.0" - "@metamask/smart-accounts-kit": "npm:^1.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -4590,8 +4559,6 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" - uuid: "npm:^8.3.2" - viem: "npm:^2.46.2" languageName: unknown linkType: soft @@ -5485,22 +5452,6 @@ __metadata: languageName: node linkType: hard -"@metamask/smart-accounts-kit@npm:^1.3.0": - version: 1.3.0 - resolution: "@metamask/smart-accounts-kit@npm:1.3.0" - dependencies: - "@metamask/7715-permission-types": "npm:^0.5.0" - "@metamask/delegation-abis": "npm:^1.0.0" - "@metamask/delegation-core": "npm:^1.1.0" - "@metamask/delegation-deployments": "npm:^1.2.0" - openapi-fetch: "npm:^0.13.5" - ox: "npm:0.8.1" - peerDependencies: - viem: ^2.31.4 - checksum: 10/45da6ddafcdef5c3c0247efa1d2a431349b6bd2684b6b92cc69c3f38eab5816385cbb197c52093811b588b80f4b71c57517861c76cd906c000906d814514430d - languageName: node - linkType: hard - "@metamask/snap-account-service@npm:^0.0.0, @metamask/snap-account-service@workspace:packages/snap-account-service": version: 0.0.0-use.local resolution: "@metamask/snap-account-service@workspace:packages/snap-account-service" @@ -5989,7 +5940,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -7507,7 +7458,7 @@ __metadata: languageName: node linkType: hard -"abitype@npm:^1.0.8, abitype@npm:^1.2.3": +"abitype@npm:^1.2.3": version: 1.2.4 resolution: "abitype@npm:1.2.4" peerDependencies: @@ -12929,22 +12880,6 @@ __metadata: languageName: node linkType: hard -"openapi-fetch@npm:^0.13.5": - version: 0.13.8 - resolution: "openapi-fetch@npm:0.13.8" - dependencies: - openapi-typescript-helpers: "npm:^0.0.15" - checksum: 10/fed630452ac2d6abc680402651d848b7377b651164ca2be61a8c5e1fc89e41b09c928ba9dc92cf7c7ad2d400b3fbe5af380165303f293501dc08cefa4c0f92fd - languageName: node - linkType: hard - -"openapi-typescript-helpers@npm:^0.0.15": - version: 0.0.15 - resolution: "openapi-typescript-helpers@npm:0.0.15" - checksum: 10/63f8f0b8464aed3e5c6910428bd14839bd5c1dd6ddf841bcea9d5f536a6e03e942a028202920da1a8b1ed9e4304c6fca14943d01a8adff2942d1254a229b8c70 - languageName: node - linkType: hard - "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -12980,27 +12915,6 @@ __metadata: languageName: node linkType: hard -"ox@npm:0.8.1": - version: 0.8.1 - resolution: "ox@npm:0.8.1" - dependencies: - "@adraffy/ens-normalize": "npm:^1.11.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.1" - "@noble/hashes": "npm:^1.8.0" - "@scure/bip32": "npm:^1.7.0" - "@scure/bip39": "npm:^1.6.0" - abitype: "npm:^1.0.8" - eventemitter3: "npm:5.0.1" - peerDependencies: - typescript: ">=5.4.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/a3c967e5b30792d89e7ecbdf976c00c625738e96263e1f0a95ad43c27b57ac18f21357eb7a651ce3c0ff0dc54b3ed071516c9804bc48fa2134262a5066b62fcc - languageName: node - linkType: hard - "oxfmt@npm:^0.44.0": version: 0.44.0 resolution: "oxfmt@npm:0.44.0" @@ -15147,7 +15061,7 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.36.0, viem@npm:^2.46.2": +"viem@npm:^2.36.0": version: 2.48.4 resolution: "viem@npm:2.48.4" dependencies: From 68ce880a4d373ca3931c572cb43cd2622f9d1196 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 13:02:14 +0100 Subject: [PATCH 03/15] refactor: sign delegations via DelegationController:signDelegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hands off delegation signing (and DelegationManager address resolution) to @metamask/delegation-controller, which the wallet client already wires up globally with a `getDelegationEnvironment` callback. - Adds `@metamask/delegation-controller` as a dependency. - Swaps `KeyringController:signTypedMessage` for `DelegationController:signDelegation` in the messenger allowlist. - Drops `delegationManager` from `UpgradeConfig` / `StepContext`; this controller no longer needs to know the DelegationManager address — DelegationController resolves it. - Removes the inlined `SIGNABLE_DELEGATION_TYPED_DATA` and salt hex→bigint conversion from build-delegations (~25 lines). The build-delegation step still resolves enforcer + EIP-7702 impl addresses from `@metamask/delegation-deployments` directly, since those are statically typed and DelegationController only exposes them via a string-keyed bag. --- .../CHANGELOG.md | 8 +- .../package.json | 1 + .../src/MoneyAccountUpgradeController.test.ts | 10 +-- .../src/MoneyAccountUpgradeController.ts | 9 +- .../src/steps/associate-address.test.ts | 3 - .../src/steps/build-delegations.test.ts | 73 ++++++----------- .../src/steps/build-delegations.ts | 82 ++++++------------- .../src/steps/eip-7702-authorization.test.ts | 3 - .../src/steps/step.ts | 1 - .../src/types.ts | 9 +- .../tsconfig.build.json | 1 + .../tsconfig.json | 1 + yarn.lock | 3 +- 13 files changed, 69 insertions(+), 135 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index b9fd0144b9..871b0aea62 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -13,10 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** The controller messenger now requires access to three additional allowed actions: `ChompApiService:verifyDelegation`, `KeyringController:signTypedMessage`, and `AuthenticatedUserStorageService:listDelegations`. Consumers must update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) -- **BREAKING:** `init()` now takes only a `chainId` and no longer accepts an `InitConfig`. Contract addresses (DelegationManager, the EIP-7702 delegator implementation, and the caveat enforcers) 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) and gains a `delegationManager` field. ([#8621](https://github.com/MetaMask/core/pull/8621)) -- Add `@metamask/authenticated-user-storage`, `@metamask/delegation-core`, and `@metamask/delegation-deployments` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** The controller messenger now requires access to three additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `ChompApiService:verifyDelegation`, 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 only a `chainId` and no longer accepts 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/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) ### Fixed diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 0a01848728..4b7851f698 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -56,6 +56,7 @@ "@metamask/authenticated-user-storage": "^1.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/chomp-api-service": "^3.0.1", + "@metamask/delegation-controller": "^3.0.0", "@metamask/delegation-core": "^2.0.0", "@metamask/delegation-deployments": "^1.3.0", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index d0a56664a9..90ba79c5b7 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -68,7 +68,7 @@ type Mocks = { getNetworkClientById: jest.Mock; providerRequest: jest.Mock; listDelegations: jest.Mock; - signTypedMessage: jest.Mock; + signDelegation: jest.Mock; verifyDelegation: jest.Mock; }; @@ -121,7 +121,7 @@ function setup(): { }), providerRequest, listDelegations: jest.fn().mockResolvedValue([]), - signTypedMessage: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), + signDelegation: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), }; @@ -162,8 +162,8 @@ function setup(): { mocks.listDelegations, ); rootMessenger.registerActionHandler( - 'KeyringController:signTypedMessage', - mocks.signTypedMessage, + 'DelegationController:signDelegation', + mocks.signDelegation, ); rootMessenger.registerActionHandler( 'ChompApiService:verifyDelegation', @@ -185,7 +185,7 @@ function setup(): { 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', 'AuthenticatedUserStorageService:listDelegations', - 'KeyringController:signTypedMessage', + 'DelegationController:signDelegation', 'ChompApiService:verifyDelegation', ], events: [], diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 29e94f287d..d4f112b0cb 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -11,11 +11,11 @@ import type { 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, - KeyringControllerSignTypedMessageAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { @@ -58,16 +58,16 @@ export type MoneyAccountUpgradeControllerActions = | MoneyAccountUpgradeControllerMethodActions; type AllowedActions = + | AuthenticatedUserStorageServiceListDelegationsAction | ChompApiServiceAssociateAddressAction | ChompApiServiceCreateUpgradeAction | ChompApiServiceGetServiceDetailsAction | ChompApiServiceVerifyDelegationAction + | DelegationControllerSignDelegationAction | KeyringControllerSignEip7702AuthorizationAction | KeyringControllerSignPersonalMessageAction - | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction - | NetworkControllerGetNetworkClientByIdAction - | AuthenticatedUserStorageServiceListDelegationsAction; + | NetworkControllerGetNetworkClientByIdAction; export type MoneyAccountUpgradeControllerStateChangedEvent = ControllerStateChangedEvent< @@ -170,7 +170,6 @@ export class MoneyAccountUpgradeController extends BaseController< delegateAddress: chain.autoDepositDelegate, musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, vedaVaultAdapterAddress: vedaProtocol.adapterAddress, - delegationManager: contracts.DelegationManager, delegatorImplAddress: contracts.EIP7702StatelessDeleGatorImpl, erc20TransferAmountEnforcer: contracts.ERC20TransferAmountEnforcer, redeemerEnforcer: contracts.RedeemerEnforcer, 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 79dbfd2205..3201932436 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 @@ -20,8 +20,6 @@ const MOCK_REDEEMER_ENFORCER = '0x6666666666666666666666666666666666666666' as Hex; const MOCK_VALUE_LTE_ENFORCER = '0x7777777777777777777777777777777777777777' as Hex; -const MOCK_DELEGATION_MANAGER = - '0x8888888888888888888888888888888888888888' as Hex; const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); @@ -82,7 +80,6 @@ async function run( address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, delegateAddress: MOCK_DELEGATE, - delegationManager: MOCK_DELEGATION_MANAGER, delegatorImplAddress: MOCK_DELEGATOR_IMPL, erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, musdTokenAddress: MOCK_TOKEN, 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 index 990244ee15..33660f792e 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -5,7 +5,6 @@ import { createRedeemerTerms, createValueLteTerms, } from '@metamask/delegation-core'; -import { SignTypedDataVersion } from '@metamask/keyring-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -31,12 +30,9 @@ const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) -const MOCK_CHAIN_ID_DECIMAL = 11155111; const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; -const MOCK_DELEGATION_MANAGER = - '0x5555555555555555555555555555555555555555' as Hex; const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const MOCK_REDEEMER_ENFORCER = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; @@ -51,20 +47,6 @@ const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; const MOCK_ERC20_TERMS: Hex = '0xa2'; const MOCK_REDEEMER_TERMS: Hex = '0xa3'; -const SIGNABLE_DELEGATION_TYPED_DATA = { - Caveat: [ - { name: 'enforcer', type: 'address' }, - { name: 'terms', type: 'bytes' }, - ], - Delegation: [ - { name: 'delegate', type: 'address' }, - { name: 'delegator', type: 'address' }, - { name: 'authority', type: 'bytes32' }, - { name: 'caveats', type: 'Caveat[]' }, - { name: 'salt', type: 'uint256' }, - ], -} as const; - const EXPECTED_CAVEATS = [ { enforcer: MOCK_VALUE_LTE_ENFORCER, terms: MOCK_VALUE_LTE_TERMS, args: '0x' }, { enforcer: MOCK_ERC20_ENFORCER, terms: MOCK_ERC20_TERMS, args: '0x' }, @@ -116,7 +98,7 @@ type AllEvents = MessengerEvents; type Mocks = { listDelegations: jest.Mock; - signTypedMessage: jest.Mock; + signDelegation: jest.Mock; verifyDelegation: jest.Mock; }; @@ -126,7 +108,7 @@ function setup(): { } { const mocks: Mocks = { listDelegations: jest.fn().mockResolvedValue([]), - signTypedMessage: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + signDelegation: jest.fn().mockResolvedValue(MOCK_SIGNATURE), verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), }; @@ -138,8 +120,8 @@ function setup(): { mocks.listDelegations, ); rootMessenger.registerActionHandler( - 'KeyringController:signTypedMessage', - mocks.signTypedMessage, + 'DelegationController:signDelegation', + mocks.signDelegation, ); rootMessenger.registerActionHandler( 'ChompApiService:verifyDelegation', @@ -153,7 +135,7 @@ function setup(): { rootMessenger.delegate({ actions: [ 'AuthenticatedUserStorageService:listDelegations', - 'KeyringController:signTypedMessage', + 'DelegationController:signDelegation', 'ChompApiService:verifyDelegation', ], events: [], @@ -171,7 +153,6 @@ async function run( address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, delegateAddress: MOCK_DELEGATE, - delegationManager: MOCK_DELEGATION_MANAGER, delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, musdTokenAddress: MOCK_TOKEN, @@ -223,7 +204,7 @@ describe('buildDelegationStep', () => { expect(result).toBe('already-done'); expect(mockCreateErc20Terms).not.toHaveBeenCalled(); - expect(mocks.signTypedMessage).not.toHaveBeenCalled(); + expect(mocks.signDelegation).not.toHaveBeenCalled(); expect(mocks.verifyDelegation).not.toHaveBeenCalled(); }); @@ -241,7 +222,7 @@ describe('buildDelegationStep', () => { const result = await run(messenger); expect(result).toBe('already-done'); - expect(mocks.signTypedMessage).not.toHaveBeenCalled(); + expect(mocks.signDelegation).not.toHaveBeenCalled(); }); it('returns "already-done" when the matching entry is one of several', async () => { @@ -273,7 +254,7 @@ describe('buildDelegationStep', () => { const result = await run(messenger); expect(result).toBe('completed'); - expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); }); @@ -283,32 +264,26 @@ describe('buildDelegationStep', () => { const result = await run(messenger); expect(result).toBe('completed'); - expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); }); - it('signs the delegation as EIP-712 V4 typed data scoped to the DelegationManager, with a fresh 32-byte salt', async () => { + it('hands the unsigned delegation to DelegationController:signDelegation, scoped to the target chain, with a fresh 32-byte salt', async () => { const { messenger, mocks } = setup(); await run(messenger); - expect(mocks.signTypedMessage).toHaveBeenCalledTimes(1); - const [params, version] = mocks.signTypedMessage.mock.calls[0]; - expect(version).toBe(SignTypedDataVersion.V4); - expect(params.from).toBe(MOCK_ADDRESS); - expect(params.data.domain).toStrictEqual({ - name: 'DelegationManager', - version: '1', - chainId: MOCK_CHAIN_ID_DECIMAL, - verifyingContract: MOCK_DELEGATION_MANAGER, + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const params = mocks.signDelegation.mock.calls[0][0]; + expect(params.chainId).toBe(MOCK_CHAIN_ID); + expect(params.delegation).toStrictEqual({ + delegate: MOCK_DELEGATE, + delegator: MOCK_ADDRESS, + authority: ROOT_AUTHORITY, + caveats: EXPECTED_CAVEATS, + salt: expect.stringMatching(/^0x[0-9a-f]{64}$/u) as Hex, }); - expect(params.data.types).toStrictEqual(SIGNABLE_DELEGATION_TYPED_DATA); - expect(params.data.primaryType).toBe('Delegation'); - expect(params.data.message.delegate).toBe(MOCK_DELEGATE); - expect(params.data.message.delegator).toBe(MOCK_ADDRESS); - expect(params.data.message.authority).toBe(ROOT_AUTHORITY); - expect(params.data.message.caveats).toStrictEqual(EXPECTED_CAVEATS); - expect(typeof params.data.message.salt).toBe('bigint'); + expect(params.delegation).not.toHaveProperty('signature'); }); it('uses a fresh 32-byte salt on each call', async () => { @@ -317,8 +292,8 @@ describe('buildDelegationStep', () => { await run(messenger); await run(messenger); - const saltA = mocks.signTypedMessage.mock.calls[0][0].data.message.salt; - const saltB = mocks.signTypedMessage.mock.calls[1][0].data.message.salt; + const saltA = mocks.signDelegation.mock.calls[0][0].delegation.salt; + const saltB = mocks.signDelegation.mock.calls[1][0].delegation.salt; expect(saltA).not.toBe(saltB); }); @@ -367,13 +342,13 @@ describe('buildDelegationStep', () => { await expect(run(messenger)).rejects.toThrow('storage failed'); expect(mockCreateErc20Terms).not.toHaveBeenCalled(); - expect(mocks.signTypedMessage).not.toHaveBeenCalled(); + expect(mocks.signDelegation).not.toHaveBeenCalled(); expect(mocks.verifyDelegation).not.toHaveBeenCalled(); }); it('propagates errors from signing and does not submit to CHOMP', async () => { const { messenger, mocks } = setup(); - mocks.signTypedMessage.mockRejectedValue(new Error('signing failed')); + mocks.signDelegation.mockRejectedValue(new Error('signing failed')); await expect(run(messenger)).rejects.toThrow('signing failed'); expect(mocks.verifyDelegation).not.toHaveBeenCalled(); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts index d1437fe4c5..4c07eb87cc 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -5,31 +5,13 @@ import { createRedeemerTerms, createValueLteTerms, } from '@metamask/delegation-core'; -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import { bytesToHex, hexToNumber } from '@metamask/utils'; +import { bytesToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { Step } from './step'; const MAX_UINT256 = 2n ** 256n - 1n; -// EIP-712 typed-data shape expected by the on-chain DelegationManager. Values -// are hardcoded into the contract; reproduced here verbatim from the -// Delegation Framework reference implementation. -const SIGNABLE_DELEGATION_TYPED_DATA = { - Caveat: [ - { name: 'enforcer', type: 'address' }, - { name: 'terms', type: 'bytes' }, - ], - Delegation: [ - { name: 'delegate', type: 'address' }, - { name: 'delegator', type: 'address' }, - { name: 'authority', type: 'bytes32' }, - { name: 'caveats', type: 'Caveat[]' }, - { name: 'salt', type: 'uint256' }, - ], -} as const; - const equalsIgnoreCase = (a: Hex, b: Hex): boolean => a.toLowerCase() === b.toLowerCase(); @@ -40,7 +22,6 @@ export const buildDelegationStep: Step = { address, chainId, delegateAddress, - delegationManager, erc20TransferAmountEnforcer, musdTokenAddress, redeemerEnforcer, @@ -61,54 +42,37 @@ export const buildDelegationStep: Step = { const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); const salt = bytesToHex(saltBytes); - const chainIdDecimal = hexToNumber(chainId); - - const caveats = [ - { - enforcer: valueLteEnforcer, - terms: createValueLteTerms({ maxValue: 0n }), - args: '0x' as Hex, - }, - { - enforcer: erc20TransferAmountEnforcer, - terms: createERC20TransferAmountTerms({ - tokenAddress: musdTokenAddress, - maxAmount: MAX_UINT256, - }), - args: '0x' as Hex, - }, - { - enforcer: redeemerEnforcer, - terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), - args: '0x' as Hex, - }, - ]; const delegation = { delegate: delegateAddress, delegator: address, authority: ROOT_AUTHORITY, - caveats, + caveats: [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x' as Hex, + }, + { + enforcer: erc20TransferAmountEnforcer, + terms: createERC20TransferAmountTerms({ + tokenAddress: musdTokenAddress, + maxAmount: MAX_UINT256, + }), + args: '0x' as Hex, + }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), + args: '0x' as Hex, + }, + ], salt, - signature: '0x' as Hex, - }; - - const typedData = { - domain: { - name: 'DelegationManager', - version: '1', - chainId: chainIdDecimal, - verifyingContract: delegationManager, - }, - types: SIGNABLE_DELEGATION_TYPED_DATA, - primaryType: 'Delegation' as const, - message: { ...delegation, salt: BigInt(salt) }, }; const signature = (await messenger.call( - 'KeyringController:signTypedMessage', - { from: address, data: typedData }, - SignTypedDataVersion.V4, + 'DelegationController:signDelegation', + { delegation, chainId }, )) as Hex; const result = await messenger.call('ChompApiService:verifyDelegation', { 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 49083f4494..e0b1ed3142 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 @@ -21,8 +21,6 @@ const MOCK_REDEEMER_ENFORCER = '0x6666666666666666666666666666666666666666' as Hex; const MOCK_VALUE_LTE_ENFORCER = '0x7777777777777777777777777777777777777777' as Hex; -const MOCK_DELEGATION_MANAGER = - '0x8888888888888888888888888888888888888888' as Hex; const MOCK_THIRD_PARTY_IMPL = '0x9999999999999999999999999999999999999999' as Hex; const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; @@ -153,7 +151,6 @@ async function run( address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, delegateAddress: MOCK_DELEGATE, - delegationManager: MOCK_DELEGATION_MANAGER, delegatorImplAddress: MOCK_DELEGATOR_IMPL, erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, musdTokenAddress: MOCK_TOKEN, diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index 69b166d5a1..5d41e34710 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -10,7 +10,6 @@ export type StepContext = { address: Hex; chainId: Hex; delegateAddress: Hex; - delegationManager: Hex; delegatorImplAddress: Hex; erc20TransferAmountEnforcer: Hex; musdTokenAddress: Hex; diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts index 8f91966d6a..cd1c302a84 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -4,9 +4,10 @@ import type { Hex } from '@metamask/utils'; * Configuration required to perform the Money Account upgrade sequence. * * `delegateAddress`, `musdTokenAddress`, and `vedaVaultAdapterAddress` come - * from the CHOMP service details API. The remaining contract addresses - * (`delegationManager`, `delegatorImplAddress`, and the caveat enforcers) are - * resolved from `@metamask/delegation-deployments` for the target chain. + * 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. */ @@ -15,8 +16,6 @@ export type UpgradeConfig = { musdTokenAddress: Hex; /** The Veda vault adapter contract address. */ vedaVaultAdapterAddress: Hex; - /** Address of the DelegationManager contract (EIP-712 verifying contract). */ - delegationManager: Hex; /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ delegatorImplAddress: Hex; /** Address of the ERC20TransferAmountEnforcer caveat enforcer. */ diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json index 4f9b9a48ca..033cb7d8b0 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -9,6 +9,7 @@ { "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 fb9c90b9e5..7993854f44 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -7,6 +7,7 @@ { "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 0226b5c087..27a7e2db35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3480,7 +3480,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: @@ -4544,6 +4544,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.0.1" + "@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" From d2c920046e739a1603e611025cf96f70fa647257 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 13:30:25 +0100 Subject: [PATCH 04/15] feat: build deposit and withdrawal delegations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build-delegation step now signs two delegations per upgrade — one authorising transfers of mUSD (deposit-side) and one authorising transfers of the Veda boring vault share token vmUSD (withdrawal-side). Both delegations share delegator, delegate, and redeemer (the Veda vault adapter); only the ERC20TransferAmount caveat's token differs. The "already-done" check runs per-token, so re-running the upgrade after a partial failure only re-signs the missing delegation. Signing is sequential, deposit before withdrawal, so the user sees one prompt at a time. The withdrawal-side token is the Veda boring vault contract address. This is hardcoded per chain in the controller (mainnet only) until the CHOMP service-details API exposes it; misconfigured chains throw at init() time. --- .../CHANGELOG.md | 1 + .../src/MoneyAccountUpgradeController.test.ts | 31 ++ .../src/MoneyAccountUpgradeController.ts | 20 ++ .../src/steps/associate-address.test.ts | 1 + .../src/steps/build-delegations.test.ts | 273 ++++++++++++------ .../src/steps/build-delegations.ts | 168 +++++++---- .../src/steps/eip-7702-authorization.test.ts | 1 + .../src/steps/step.ts | 1 + .../src/types.ts | 4 +- 9 files changed, 352 insertions(+), 148 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 871b0aea62..757249ebbe 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 currently hardcoded per chain in the controller (mainnet only) pending exposure via the CHOMP service-details API. ### Changed diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index 90ba79c5b7..c3a03603cf 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -13,8 +13,11 @@ import { MoneyAccountUpgradeController } from '.'; 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 SEPOLIA_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 — in DF 1.3.0 but no boring vault configured const MOCK_ACCOUNT_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MAINNET_BORING_VAULT_ADDRESS = + '0xA20f97813014129E7609171d2D3AA3da5206259e'; // CHOMP-API-derived values. const MOCK_DELEGATE_ADDRESS = @@ -226,6 +229,34 @@ describe('MoneyAccountUpgradeController', () => { expect(mocks.getServiceDetails).not.toHaveBeenCalled(); }); + it('throws when the chain has no configured Veda boring vault address', async () => { + const { controller, mocks } = setup(); + + await expect(controller.init(SEPOLIA_CHAIN_ID)).rejects.toThrow( + `No Veda boring vault address configured for chain ${SEPOLIA_CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + + it('uses the hardcoded mainnet Veda boring vault address for the withdrawal delegation', async () => { + const { controller, mocks } = setup(); + + await controller.init(MOCK_CHAIN_ID); + 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(MAINNET_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), + ), + ).toBe(true); + }); + it('throws when the chain is not found in service details', async () => { const { controller, mocks } = setup(); diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index d4f112b0cb..295d1f3d9f 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -38,6 +38,18 @@ import type { UpgradeConfig } from './types'; */ const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; +/** + * Per-chain Veda boring vault addresses (vmUSD). Source of truth for the + * withdrawal-side delegation token. + * + * TODO: Move this into the CHOMP service-details API once it exposes a + * dedicated `boringVaultAddress` (or extends `supportedTokens` to cover + * vmUSD). Hardcoding here is a temporary measure. + */ +const BORING_VAULT_ADDRESSES: Record = { + '0x1': '0xA20f97813014129E7609171d2D3AA3da5206259e', +}; + export const controllerName = 'MoneyAccountUpgradeController'; export type MoneyAccountUpgradeControllerState = Record; @@ -142,6 +154,13 @@ export class MoneyAccountUpgradeController extends BaseController< ); } + const boringVaultAddress = BORING_VAULT_ADDRESSES[chainId]; + if (!boringVaultAddress) { + throw new Error( + `No Veda boring vault address configured for chain ${chainId}`, + ); + } + const response = await this.messenger.call( 'ChompApiService:getServiceDetails', [chainId], @@ -169,6 +188,7 @@ export class MoneyAccountUpgradeController extends BaseController< chainId, delegateAddress: chain.autoDepositDelegate, musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, + boringVaultAddress, vedaVaultAdapterAddress: vedaProtocol.adapterAddress, delegatorImplAddress: contracts.EIP7702StatelessDeleGatorImpl, erc20TransferAmountEnforcer: contracts.ERC20TransferAmountEnforcer, 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 3201932436..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 @@ -79,6 +79,7 @@ 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, 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 index 33660f792e..b9a708940f 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -31,7 +31,9 @@ const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; -const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' 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 = @@ -44,19 +46,21 @@ const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; const MOCK_SIGNATURE: Hex = `0x${'cd'.repeat(65)}`; const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; -const MOCK_ERC20_TERMS: Hex = '0xa2'; +const MOCK_MUSD_ERC20_TERMS: Hex = '0xa2'; +const MOCK_VMUSD_ERC20_TERMS: Hex = '0xa4'; const MOCK_REDEEMER_TERMS: Hex = '0xa3'; -const EXPECTED_CAVEATS = [ +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: MOCK_ERC20_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 one that matches our run() config. - * Tests override one field at a time to probe the matcher. + * 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. @@ -87,7 +91,7 @@ function makeDelegationResponse( chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, allowance: '0x00', tokenSymbol: 'mUSD', - tokenAddress: overrides.tokenAddress ?? MOCK_TOKEN, + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, type: 'lend', }, }; @@ -152,10 +156,11 @@ async function 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_TOKEN, + musdTokenAddress: MOCK_MUSD, redeemerEnforcer: MOCK_REDEEMER_ENFORCER, valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, @@ -168,8 +173,15 @@ describe('buildDelegationStep', () => { // 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); - mockCreateErc20Terms.mockReturnValue(MOCK_ERC20_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, + ); }); afterEach(() => { @@ -180,140 +192,194 @@ describe('buildDelegationStep', () => { expect(buildDelegationStep.name).toBe('build-delegation'); }); - it('encodes each caveat with the configured enforcer addresses', async () => { - const { messenger } = setup(); + describe('when neither delegation exists in storage', () => { + it('signs and submits both delegations, deposit (mUSD) before withdrawal (vmUSD)', async () => { + const { messenger, mocks } = setup(); - await run(messenger); + const result = await run(messenger); - expect(mockCreateValueLteTerms).toHaveBeenCalledWith({ maxValue: 0n }); - expect(mockCreateErc20Terms).toHaveBeenCalledWith({ - tokenAddress: MOCK_TOKEN, - maxAmount: 2n ** 256n - 1n, - }); - expect(mockCreateRedeemerTerms).toHaveBeenCalledWith({ - redeemers: [MOCK_VAULT_ADAPTER], + 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, + ]); }); - }); - describe('when listDelegations returns a delegation matching the config', () => { - it('returns "already-done" without building, signing, or submitting', async () => { - const { messenger, mocks } = setup(); - mocks.listDelegations.mockResolvedValue([makeDelegationResponse()]); + it('encodes each caveat against the right enforcer addresses for each token', async () => { + const { messenger } = setup(); - const result = await run(messenger); + await run(messenger); - expect(result).toBe('already-done'); - expect(mockCreateErc20Terms).not.toHaveBeenCalled(); - expect(mocks.signDelegation).not.toHaveBeenCalled(); - expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + // 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('matches addresses and chainId case-insensitively', async () => { + it('hands each unsigned delegation to DelegationController:signDelegation, scoped to the chain, with a fresh 32-byte salt', 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_TOKEN.toUpperCase() as Hex, - }), - ]); - const result = await run(messenger); + await run(messenger); - expect(result).toBe('already-done'); - expect(mocks.signDelegation).not.toHaveBeenCalled(); + 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('returns "already-done" when the matching entry is one of several', async () => { + it('submits each signed delegation to ChompApiService:verifyDelegation', async () => { const { messenger, mocks } = setup(); - mocks.listDelegations.mockResolvedValue([ - makeDelegationResponse({ chainIdHex: OTHER_CHAIN_ID }), - makeDelegationResponse(), - makeDelegationResponse({ tokenAddress: OTHER_TOKEN }), - ]); - const result = await run(messenger); + await run(messenger); - expect(result).toBe('already-done'); + 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), + ); }); }); - describe('when no listed delegation matches the config', () => { - it.each([ - ['delegator differs', { delegator: OTHER_ADDRESS }], - ['delegate differs', { delegate: OTHER_ADDRESS }], - ['chainIdHex differs', { chainIdHex: OTHER_CHAIN_ID }], - ['tokenAddress differs', { tokenAddress: OTHER_TOKEN }], - ])('proceeds when %s', async (_label, override) => { + describe('when only one delegation already exists', () => { + it('signs and submits the missing withdrawal delegation when the deposit one already exists', async () => { const { messenger, mocks } = setup(); mocks.listDelegations.mockResolvedValue([ - makeDelegationResponse(override), + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), ]); const result = await run(messenger); expect(result).toBe('completed'); expect(mocks.signDelegation).toHaveBeenCalledTimes(1); - expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_VMUSD_ERC20_TERMS); }); - it('proceeds when listDelegations is empty', async () => { + it('signs and submits 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); - expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_MUSD_ERC20_TERMS); }); + }); - it('hands the unsigned delegation to DelegationController:signDelegation, scoped to the target chain, with a fresh 32-byte salt', async () => { + describe('when both delegations already exist', () => { + it('returns "already-done" without signing or submitting', async () => { const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); - await run(messenger); + const result = await run(messenger); - expect(mocks.signDelegation).toHaveBeenCalledTimes(1); - const params = mocks.signDelegation.mock.calls[0][0]; - expect(params.chainId).toBe(MOCK_CHAIN_ID); - expect(params.delegation).toStrictEqual({ - delegate: MOCK_DELEGATE, - delegator: MOCK_ADDRESS, - authority: ROOT_AUTHORITY, - caveats: EXPECTED_CAVEATS, - salt: expect.stringMatching(/^0x[0-9a-f]{64}$/u) as Hex, - }); - expect(params.delegation).not.toHaveProperty('signature'); + expect(result).toBe('already-done'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); }); - it('uses a fresh 32-byte salt on each call', async () => { + 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, + }), + ]); - await run(messenger); - await run(messenger); + const result = await run(messenger); - const saltA = mocks.signDelegation.mock.calls[0][0].delegation.salt; - const saltB = mocks.signDelegation.mock.calls[1][0].delegation.salt; - expect(saltA).not.toBe(saltB); + expect(result).toBe('already-done'); }); - it('submits the signed delegation to ChompApiService:verifyDelegation, with hex salt', async () => { + 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 }), + ]); - await run(messenger); + const result = await run(messenger); - expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); - const submitted = mocks.verifyDelegation.mock.calls[0][0]; - expect(submitted.chainId).toBe(MOCK_CHAIN_ID); - expect(submitted.signedDelegation.delegate).toBe(MOCK_DELEGATE); - expect(submitted.signedDelegation.delegator).toBe(MOCK_ADDRESS); - expect(submitted.signedDelegation.authority).toBe(ROOT_AUTHORITY); - expect(submitted.signedDelegation.caveats).toStrictEqual(EXPECTED_CAVEATS); - expect(submitted.signedDelegation.signature).toBe(MOCK_SIGNATURE); - expect(submitted.signedDelegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); }); + }); - it('throws when CHOMP rejects the delegation', async () => { + describe('when CHOMP rejects a delegation', () => { + it('throws with the joined error list', async () => { const { messenger, mocks } = setup(); mocks.verifyDelegation.mockResolvedValue({ valid: false, @@ -325,7 +391,7 @@ describe('buildDelegationStep', () => { ); }); - it('throws with a default message when CHOMP rejects without errors', async () => { + it('throws with a default message when CHOMP returns no errors', async () => { const { messenger, mocks } = setup(); mocks.verifyDelegation.mockResolvedValue({ valid: false }); @@ -333,32 +399,47 @@ describe('buildDelegationStep', () => { 'CHOMP rejected delegation: unknown error', ); }); + + it('does not attempt the second delegation 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); + }); }); describe('error propagation', () => { - it('propagates errors from listDelegations and does not build, sign, or submit', async () => { + 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(mockCreateErc20Terms).not.toHaveBeenCalled(); expect(mocks.signDelegation).not.toHaveBeenCalled(); expect(mocks.verifyDelegation).not.toHaveBeenCalled(); }); - it('propagates errors from signing and does not submit to CHOMP', async () => { + 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', async () => { + 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); }); }); }); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts index 4c07eb87cc..fa742ea994 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -8,6 +8,7 @@ import { import { 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; @@ -15,12 +16,99 @@ const MAX_UINT256 = 2n ** 256n - 1n; const equalsIgnoreCase = (a: Hex, b: Hex): boolean => a.toLowerCase() === b.toLowerCase(); +/** + * Builds, signs, and submits 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 differs. + * + * @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.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 signAndSubmitDelegation(params: { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; + chainId: Hex; + delegateAddress: Hex; + tokenAddress: Hex; + vedaVaultAdapterAddress: Hex; + erc20TransferAmountEnforcer: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; +}): Promise { + const { + messenger, + address, + chainId, + delegateAddress, + tokenAddress, + 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 result = await messenger.call('ChompApiService:verifyDelegation', { + signedDelegation: { ...delegation, signature }, + chainId, + }); + + if (!result.valid) { + throw new Error( + `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, + ); + } +} + export const buildDelegationStep: Step = { name: 'build-delegation', async run({ messenger, address, chainId, + boringVaultAddress, delegateAddress, erc20TransferAmountEnforcer, musdTokenAddress, @@ -31,61 +119,39 @@ export const buildDelegationStep: Step = { const existingDelegations = await messenger.call( 'AuthenticatedUserStorageService:listDelegations', ); - const matchesConfig = (entry: DelegationResponse): boolean => - equalsIgnoreCase(entry.signedDelegation.delegator, address) && - equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && - equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && - equalsIgnoreCase(entry.metadata.tokenAddress, musdTokenAddress); - if (existingDelegations.some(matchesConfig)) { - return 'already-done'; - } - - 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: musdTokenAddress, - 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 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); - const result = await messenger.call('ChompApiService:verifyDelegation', { - signedDelegation: { ...delegation, signature }, - chainId, - }); + // 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 tokens: Hex[] = [musdTokenAddress, boringVaultAddress]; - if (!result.valid) { - throw new Error( - `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, - ); + let didWork = false; + for (const tokenAddress of tokens) { + if (existingDelegations.some(matches(tokenAddress))) { + continue; + } + await signAndSubmitDelegation({ + messenger, + address, + chainId, + delegateAddress, + tokenAddress, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + }); + didWork = true; } - return 'completed'; + 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 e0b1ed3142..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 @@ -150,6 +150,7 @@ 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, diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index 5d41e34710..9537119d8a 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -9,6 +9,7 @@ export type StepContext = { messenger: MoneyAccountUpgradeControllerMessenger; address: Hex; chainId: Hex; + boringVaultAddress: Hex; delegateAddress: Hex; delegatorImplAddress: Hex; erc20TransferAmountEnforcer: Hex; diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts index cd1c302a84..db8db0ab26 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -12,8 +12,10 @@ import type { Hex } from '@metamask/utils'; export type UpgradeConfig = { /** CHOMP's delegate address — receives the delegation. */ delegateAddress: 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). */ From 557568ff250bf362e5b434ec18ad8eadc1d22c96 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 14:36:33 +0100 Subject: [PATCH 05/15] feat: persist signed delegations via AuthenticatedUserStorageService After CHOMP verifies a delegation, the build-delegation step now also calls AuthenticatedUserStorageService:createDelegation so the signed delegation is stored against the user's profile. Without this the listDelegations matcher on the next run would never find a stored record and we'd re-sign on every upgrade attempt. Order is verify-then-store: if storage fails after CHOMP verification, nothing is persisted and the next run rebuilds from scratch with a fresh salt. The inverse (store-then-verify) would risk persisting a delegation CHOMP later rejects. Metadata records the per-token symbol (mUSD / vmUSD), the cash-deposit / cash-withdrawal intent type, MAX_UINT256 as the allowance, and a delegationHash derived from @metamask/delegation-core's hashDelegation. --- .../CHANGELOG.md | 3 +- .../src/MoneyAccountUpgradeController.test.ts | 7 ++ .../src/MoneyAccountUpgradeController.ts | 6 +- .../src/steps/build-delegations.test.ts | 103 +++++++++++++++++- .../src/steps/build-delegations.ts | 62 +++++++++-- 5 files changed, 164 insertions(+), 17 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 757249ebbe..65b542841b 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -11,10 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 currently hardcoded per chain in the controller (mainnet only) 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`. ### Changed -- **BREAKING:** The controller messenger now requires access to three additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `ChompApiService:verifyDelegation`, 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:** The controller messenger now requires access to four additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, 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 only a `chainId` and no longer accepts 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)) diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index c3a03603cf..856bf9a433 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -71,6 +71,7 @@ type Mocks = { getNetworkClientById: jest.Mock; providerRequest: jest.Mock; listDelegations: jest.Mock; + createDelegation: jest.Mock; signDelegation: jest.Mock; verifyDelegation: jest.Mock; }; @@ -124,6 +125,7 @@ function setup(): { }), providerRequest, listDelegations: jest.fn().mockResolvedValue([]), + createDelegation: jest.fn().mockResolvedValue(undefined), signDelegation: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), }; @@ -164,6 +166,10 @@ function setup(): { 'AuthenticatedUserStorageService:listDelegations', mocks.listDelegations, ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); rootMessenger.registerActionHandler( 'DelegationController:signDelegation', mocks.signDelegation, @@ -188,6 +194,7 @@ function setup(): { 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', 'DelegationController:signDelegation', 'ChompApiService:verifyDelegation', ], diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 295d1f3d9f..adaf9786b5 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -1,4 +1,7 @@ -import type { AuthenticatedUserStorageServiceListDelegationsAction } from '@metamask/authenticated-user-storage'; +import type { + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceListDelegationsAction, +} from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, ControllerStateChangedEvent, @@ -70,6 +73,7 @@ export type MoneyAccountUpgradeControllerActions = | MoneyAccountUpgradeControllerMethodActions; type AllowedActions = + | AuthenticatedUserStorageServiceCreateDelegationAction | AuthenticatedUserStorageServiceListDelegationsAction | ChompApiServiceAssociateAddressAction | ChompApiServiceCreateUpgradeAction 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 index b9a708940f..901fbd391d 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -4,6 +4,7 @@ import { createERC20TransferAmountTerms, createRedeemerTerms, createValueLteTerms, + hashDelegation, } from '@metamask/delegation-core'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -22,11 +23,13 @@ jest.mock('@metamask/delegation-core', () => ({ 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) @@ -49,6 +52,9 @@ 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[] => [ @@ -104,6 +110,7 @@ type Mocks = { listDelegations: jest.Mock; signDelegation: jest.Mock; verifyDelegation: jest.Mock; + createDelegation: jest.Mock; }; function setup(): { @@ -114,6 +121,7 @@ function setup(): { 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({ @@ -123,6 +131,10 @@ function setup(): { 'AuthenticatedUserStorageService:listDelegations', mocks.listDelegations, ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); rootMessenger.registerActionHandler( 'DelegationController:signDelegation', mocks.signDelegation, @@ -139,6 +151,7 @@ function setup(): { rootMessenger.delegate({ actions: [ 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', 'DelegationController:signDelegation', 'ChompApiService:verifyDelegation', ], @@ -182,6 +195,12 @@ describe('buildDelegationStep', () => { ? 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(() => { @@ -285,10 +304,63 @@ describe('buildDelegationStep', () => { 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 and submits the missing withdrawal delegation when the deposit one already exists', async () => { + 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 }), @@ -300,9 +372,14 @@ describe('buildDelegationStep', () => { 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 and submits the missing deposit delegation when the withdrawal one already exists', async () => { + 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 }), @@ -314,11 +391,16 @@ describe('buildDelegationStep', () => { 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 or submitting', async () => { + it('returns "already-done" without signing, submitting, or persisting', async () => { const { messenger, mocks } = setup(); mocks.listDelegations.mockResolvedValue([ makeDelegationResponse({ tokenAddress: MOCK_MUSD }), @@ -330,6 +412,7 @@ describe('buildDelegationStep', () => { 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 () => { @@ -400,7 +483,7 @@ describe('buildDelegationStep', () => { ); }); - it('does not attempt the second delegation if the first one is rejected', async () => { + 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, @@ -411,6 +494,7 @@ describe('buildDelegationStep', () => { 'CHOMP rejected delegation: nope', ); expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); }); }); @@ -440,6 +524,17 @@ describe('buildDelegationStep', () => { 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 index fa742ea994..3a2cc05907 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -4,22 +4,25 @@ import { createERC20TransferAmountTerms, createRedeemerTerms, createValueLteTerms, + hashDelegation, } from '@metamask/delegation-core'; -import { bytesToHex } from '@metamask/utils'; +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, and submits 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 differs. + * 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. @@ -27,17 +30,21 @@ const equalsIgnoreCase = (a: Hex, b: Hex): boolean => * @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 signAndSubmitDelegation(params: { +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; @@ -49,6 +56,8 @@ async function signAndSubmitDelegation(params: { chainId, delegateAddress, tokenAddress, + tokenSymbol, + delegationType, vedaVaultAdapterAddress, erc20TransferAmountEnforcer, redeemerEnforcer, @@ -90,8 +99,10 @@ async function signAndSubmitDelegation(params: { { delegation, chainId }, )) as Hex; + const signedDelegation = { ...delegation, signature }; + const result = await messenger.call('ChompApiService:verifyDelegation', { - signedDelegation: { ...delegation, signature }, + signedDelegation, chainId, }); @@ -100,6 +111,24 @@ async function signAndSubmitDelegation(params: { `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 = { @@ -131,19 +160,30 @@ export const buildDelegationStep: Step = { // 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 tokens: Hex[] = [musdTokenAddress, boringVaultAddress]; + 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 tokenAddress of tokens) { - if (existingDelegations.some(matches(tokenAddress))) { + for (const config of delegations) { + if (existingDelegations.some(matches(config.tokenAddress))) { continue; } - await signAndSubmitDelegation({ + await signAndStoreDelegation({ messenger, address, chainId, delegateAddress, - tokenAddress, + ...config, vedaVaultAdapterAddress, erc20TransferAmountEnforcer, redeemerEnforcer, From 7772db7686ea836e175acd96f98de5c231c1dea3 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 16:05:21 +0100 Subject: [PATCH 06/15] feat: add register intents step to account upgrade controller --- .../CHANGELOG.md | 4 +- .../src/MoneyAccountUpgradeController.test.ts | 14 + .../src/MoneyAccountUpgradeController.ts | 6 + .../src/steps/register-intents.test.ts | 447 ++++++++++++++++++ .../src/steps/register-intents.ts | 87 ++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 packages/money-account-upgrade-controller/src/steps/register-intents.test.ts create mode 100644 packages/money-account-upgrade-controller/src/steps/register-intents.ts diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 65b542841b..6cbaf5db36 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -12,10 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 currently hardcoded per chain in the controller (mainnet only) 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 four additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, 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:** 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 only a `chainId` and no longer accepts 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)) diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index 856bf9a433..cd438c7af1 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -74,6 +74,8 @@ type Mocks = { createDelegation: jest.Mock; signDelegation: jest.Mock; verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; }; function setup(): { @@ -128,6 +130,8 @@ function setup(): { 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({ @@ -178,6 +182,14 @@ function setup(): { 'ChompApiService:verifyDelegation', mocks.verifyDelegation, ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ namespace: 'MoneyAccountUpgradeController', @@ -197,6 +209,8 @@ function setup(): { 'AuthenticatedUserStorageService:createDelegation', 'DelegationController:signDelegation', 'ChompApiService:verifyDelegation', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', ], events: [], messenger, diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index adaf9786b5..8e148831dd 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -10,7 +10,9 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateIntentsAction, ChompApiServiceCreateUpgradeAction, + ChompApiServiceGetIntentsByAddressAction, ChompApiServiceGetServiceDetailsAction, ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; @@ -32,6 +34,7 @@ import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountU 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 { UpgradeConfig } from './types'; @@ -76,7 +79,9 @@ type AllowedActions = | AuthenticatedUserStorageServiceCreateDelegationAction | AuthenticatedUserStorageServiceListDelegationsAction | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateIntentsAction | ChompApiServiceCreateUpgradeAction + | ChompApiServiceGetIntentsByAddressAction | ChompApiServiceGetServiceDetailsAction | ChompApiServiceVerifyDelegationAction | DelegationControllerSignDelegationAction @@ -116,6 +121,7 @@ export class MoneyAccountUpgradeController extends BaseController< associateAddressStep, eip7702AuthorizationStep, buildDelegationStep, + registerIntentsStep, ]; /** 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..8522f1beef --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts @@ -0,0 +1,447 @@ +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..1ba6d5dd92 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.ts @@ -0,0 +1,87 @@ +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'; + }, +}; From d9219b7f38c4575174ca4f65a139135b5e748c15 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 16:44:32 +0100 Subject: [PATCH 07/15] make boring vault address an init arg --- .../CHANGELOG.md | 4 +- .../src/MoneyAccountUpgradeController.test.ts | 80 +++++++++++++------ .../src/MoneyAccountUpgradeController.ts | 34 +++----- 3 files changed, 69 insertions(+), 49 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 6cbaf5db36..8d24215fb6 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 currently hardcoded per chain in the controller (mainnet only) pending exposure via the CHOMP service-details API. + - 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. @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 only a `chainId` and no longer accepts 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:** `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/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index cd438c7af1..6ce78bc3c6 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -13,11 +13,10 @@ import { MoneyAccountUpgradeController } from '.'; 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 SEPOLIA_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 — in DF 1.3.0 but no boring vault configured const MOCK_ACCOUNT_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; -const MAINNET_BORING_VAULT_ADDRESS = - '0xA20f97813014129E7609171d2D3AA3da5206259e'; +const MOCK_BORING_VAULT_ADDRESS = + '0xA20f97813014129E7609171d2D3AA3da5206259e' as Hex; // CHOMP-API-derived values. const MOCK_DELEGATE_ADDRESS = @@ -236,7 +235,10 @@ describe('MoneyAccountUpgradeController', () => { it('fetches service details and builds config', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect(mocks.getServiceDetails).toHaveBeenCalledWith([MOCK_CHAIN_ID]); }); @@ -244,25 +246,24 @@ describe('MoneyAccountUpgradeController', () => { it('throws when the chain has no Delegation Framework deployment', async () => { const { controller, mocks } = setup(); - await expect(controller.init(UNSUPPORTED_CHAIN_ID)).rejects.toThrow( + 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('throws when the chain has no configured Veda boring vault address', async () => { + it('uses the supplied boring vault address as the withdrawal-side delegation token', async () => { const { controller, mocks } = setup(); - await expect(controller.init(SEPOLIA_CHAIN_ID)).rejects.toThrow( - `No Veda boring vault address configured for chain ${SEPOLIA_CHAIN_ID}`, - ); - expect(mocks.getServiceDetails).not.toHaveBeenCalled(); - }); - - it('uses the hardcoded mainnet Veda boring vault address for the withdrawal delegation', async () => { - const { controller, mocks } = setup(); - - await controller.init(MOCK_CHAIN_ID); + 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 @@ -273,7 +274,7 @@ describe('MoneyAccountUpgradeController', () => { .map((caveat) => caveat.terms.toLowerCase()); expect( allCaveatTerms.some((terms) => - terms.includes(MAINNET_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), + terms.includes(MOCK_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), ), ).toBe(true); }); @@ -286,7 +287,12 @@ describe('MoneyAccountUpgradeController', () => { chains: {}, }); - await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( `Chain ${MOCK_CHAIN_ID} not found in service details response`, ); }); @@ -304,7 +310,12 @@ describe('MoneyAccountUpgradeController', () => { }, }); - await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( + await expect( + 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`, ); }); @@ -328,7 +339,12 @@ describe('MoneyAccountUpgradeController', () => { }, }); - await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, ); }); @@ -351,9 +367,12 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: {}, }); - await expect(controller.init(MOCK_CHAIN_ID)).rejects.toThrow( - 'Chain 0x1 not found in service details response', - ); + await expect( + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow('Chain 0x1 not found in service details response'); await expect( controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS), @@ -364,7 +383,10 @@ describe('MoneyAccountUpgradeController', () => { it('runs each step against the deployment-derived contract addresses', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); @@ -391,7 +413,10 @@ describe('MoneyAccountUpgradeController', () => { it('is callable via the messenger', async () => { const { controller, rootMessenger } = setup(); - await controller.init(MOCK_CHAIN_ID); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect( await rootMessenger.call( @@ -403,7 +428,10 @@ describe('MoneyAccountUpgradeController', () => { it('propagates errors thrown by a step', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID); + 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 8e148831dd..574a78f400 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -44,18 +44,6 @@ import type { UpgradeConfig } from './types'; */ const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; -/** - * Per-chain Veda boring vault addresses (vmUSD). Source of truth for the - * withdrawal-side delegation token. - * - * TODO: Move this into the CHOMP service-details API once it exposes a - * dedicated `boringVaultAddress` (or extends `supportedTokens` to cover - * vmUSD). Hardcoding here is a temporary measure. - */ -const BORING_VAULT_ADDRESSES: Record = { - '0x1': '0xA20f97813014129E7609171d2D3AA3da5206259e', -}; - export const controllerName = 'MoneyAccountUpgradeController'; export type MoneyAccountUpgradeControllerState = Record; @@ -153,9 +141,20 @@ export class MoneyAccountUpgradeController extends BaseController< * given chain. Resolves the Delegation Framework contract addresses for the * chain from `@metamask/delegation-deployments`. * - * @param chainId - The chain to initialize for. + * @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): Promise { + async init({ + chainId, + boringVaultAddress, + }: { + chainId: Hex; + boringVaultAddress: Hex; + }): Promise { const contracts = DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][hexToNumber(chainId)]; if (!contracts) { @@ -164,13 +163,6 @@ export class MoneyAccountUpgradeController extends BaseController< ); } - const boringVaultAddress = BORING_VAULT_ADDRESSES[chainId]; - if (!boringVaultAddress) { - throw new Error( - `No Veda boring vault address configured for chain ${chainId}`, - ); - } - const response = await this.messenger.call( 'ChompApiService:getServiceDetails', [chainId], From 6225dada8c3acdb08c2c32798836252520249a3b Mon Sep 17 00:00:00 2001 From: John Whiles Date: Fri, 8 May 2026 17:05:58 +0100 Subject: [PATCH 08/15] reformat and rebuild readme --- README.md | 1 + .../src/steps/build-delegations.test.ts | 23 +++++++++++-------- .../src/steps/register-intents.test.ts | 4 +++- .../src/steps/register-intents.ts | 9 ++++---- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0ecb2bff93..fead1253fd 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,7 @@ linkStyle default opacity:0.5 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/src/steps/build-delegations.test.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts index 901fbd391d..56ac59d731 100644 --- a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -35,8 +35,7 @@ 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_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const MOCK_REDEEMER_ENFORCER = @@ -58,7 +57,11 @@ 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_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' }, ]; @@ -189,12 +192,14 @@ describe('buildDelegationStep', () => { 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, - ); + 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. 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 index 8522f1beef..c175108655 100644 --- a/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts @@ -326,7 +326,9 @@ describe('registerIntentsStep', () => { const [submitted] = mocks.createIntents.mock.calls[0]; expect(submitted).toHaveLength(2); expect( - submitted.map((intent: { delegationHash: Hex }) => intent.delegationHash), + submitted.map( + (intent: { delegationHash: Hex }) => intent.delegationHash, + ), ).toStrictEqual([MOCK_MUSD_DELEGATION_HASH, MOCK_VMUSD_DELEGATION_HASH]); }); diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.ts index 1ba6d5dd92..fe638ad7f3 100644 --- a/packages/money-account-upgrade-controller/src/steps/register-intents.ts +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.ts @@ -1,5 +1,8 @@ import type { DelegationResponse } from '@metamask/authenticated-user-storage'; -import type { IntentEntry, SendIntentParams } from '@metamask/chomp-api-service'; +import type { + IntentEntry, + SendIntentParams, +} from '@metamask/chomp-api-service'; import type { Hex } from '@metamask/utils'; import type { Step } from './step'; @@ -52,9 +55,7 @@ export const registerIntentsStep: Step = { const activeIntentHashes = new Set( existingIntents .filter((intent: IntentEntry) => intent.status === 'active') - .map((intent: IntentEntry) => - intent.delegationHash.toLowerCase(), - ), + .map((intent: IntentEntry) => intent.delegationHash.toLowerCase()), ); const needsIntent = (entry: DelegationResponse): boolean => From a34d713386d60ceb04741791e1c29ea234a51092 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 19:30:22 +0100 Subject: [PATCH 09/15] feat: treat 409 in eip-7702 step as being already-done --- .../CHANGELOG.md | 1 + .../src/steps/eip-7702-authorization.test.ts | 18 +++++++++ .../src/steps/eip-7702-authorization.ts | 38 ++++++++++++++----- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 8d24215fb6..1a32621878 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- EIP-7702 authorization step now treats a 409 response from `POST /v1/account-upgrade` as `already-done` instead of a fatal error, making the step retry-safe when a prior submission was accepted by CHOMP but has not yet been observed on-chain. ## [1.3.2] 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 6046e8d213..2325988521 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 @@ -327,6 +327,24 @@ describe('eip7702AuthorizationStep', () => { await expect(run(messenger)).rejects.toThrow('api failed'); }); + it('returns "already-done" when CHOMP responds 409 (authorization already submitted)', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue( + Object.assign(new Error('conflict'), { httpStatus: 409 }), + ); + + expect(await run(messenger)).toBe('already-done'); + }); + + it('propagates non-409 HttpError responses from createUpgrade', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue( + Object.assign(new Error('server error'), { httpStatus: 500 }), + ); + + await expect(run(messenger)).rejects.toThrow('server error'); + }); + it('throws when eth_getTransactionCount returns a non-hex response', async () => { const { messenger, mocks } = setup(); mocks.providerRequest.mockImplementation(async ({ method }) => { diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts index 11e73d5877..f494a67945 100644 --- a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts @@ -67,20 +67,40 @@ export const eip7702AuthorizationStep: Step = { const { r, s, v, yParity } = splitEip7702Signature(signature); - await messenger.call('ChompApiService:createUpgrade', { - r, - s, - v, - yParity, - address: delegatorImplAddress, - chainId, - nonce: add0x(nonce.toString(16)), - }); + try { + await messenger.call('ChompApiService:createUpgrade', { + r, + s, + v, + yParity, + address: delegatorImplAddress, + chainId, + nonce: add0x(nonce.toString(16)), + }); + } catch (error) { + // CHOMP returns 409 when an authorization for this address already + // exists with the same or higher nonce — typically on retry when a + // previous submission was accepted but hasn't yet been observed + // on-chain (so `fetchDelegationAddress` returned undefined above). + // Treat as already-done so the upgrade sequence is retry-safe. + if (isHttp409(error)) { + return 'already-done'; + } + throw error; + } return 'completed'; }, }; +function isHttp409(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const { httpStatus } = error as { httpStatus?: unknown }; + return httpStatus === 409; +} + /** * Splits a 65-byte ECDSA signature produced by * `KeyringController:signEip7702Authorization` into its `r`, `s`, `v` From d4b81d2c4fac03743bf9f162f18bd4d38307816f Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 20:13:06 +0100 Subject: [PATCH 10/15] feat: do not retry 409s in chomp-api-service --- packages/chomp-api-service/CHANGELOG.md | 4 + .../src/chomp-api-service.test.ts | 116 +++++++++++++++--- .../src/chomp-api-service.ts | 32 ++++- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index b54f3dc871..b37660b4fe 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `ChompApiService` no longer retries HTTP requests that fail with a 4xx response (other than 429), since those responses indicate the request itself is at fault and will not be resolved by re-issuing it. 5xx, 429, and non-HTTP errors (network/timeout) continue to be retried. Consumers can still override this by passing a `retryFilterPolicy` via `policyOptions`. ([#8621](https://github.com/MetaMask/core/pull/8621)) + ## [3.0.1] ### Changed diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts index 921a685c93..8241d5c8cd 100644 --- a/packages/chomp-api-service/src/chomp-api-service.test.ts +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils'; +import { DEFAULT_MAX_RETRIES, handleAll } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -255,10 +255,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .post('/v1/intent/verify-delegation') - .times(DEFAULT_MAX_RETRIES + 1) - .reply(400); + nock(BASE_URL).post('/v1/intent/verify-delegation').reply(400); const { service } = createService(); await expect(service.verifyDelegation(delegationParams)).rejects.toThrow( @@ -322,10 +319,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .post('/v1/intent') - .times(DEFAULT_MAX_RETRIES + 1) - .reply(409); + nock(BASE_URL).post('/v1/intent').reply(409); const { service } = createService(); await expect(service.createIntents(intentParams)).rejects.toThrow( @@ -432,10 +426,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .post('/v1/withdrawal') - .times(DEFAULT_MAX_RETRIES + 1) - .reply(400); + nock(BASE_URL).post('/v1/withdrawal').reply(400); const { service } = createService(); await expect(service.createWithdrawal(withdrawalParams)).rejects.toThrow( @@ -512,7 +503,6 @@ describe('ChompApiService', () => { nock(BASE_URL) .get('/v1/chomp') .query({ chainId: '0xa4b1' }) - .times(DEFAULT_MAX_RETRIES + 1) .reply(400); const { service } = createService(); @@ -533,6 +523,104 @@ describe('ChompApiService', () => { ); }); }); + + describe('retry policy', () => { + const upgradeParams = { + r: '0x1' as const, + s: '0x2' as const, + v: 27, + yParity: 0, + address: '0xabc' as const, + chainId: '1', + nonce: '0', + }; + + it('retries 5xx responses up to the default retry limit', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [500]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '500'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + + it.each([400, 401, 403, 404, 409, 422])( + 'does not retry %i responses', + async (status) => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [status]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + `POST /v1/account-upgrade failed with status '${status}'`, + ); + expect(attempts).toBe(1); + }, + ); + + it('retries 429 responses alongside 5xx (rate-limit is transient)', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [429]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '429'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + + it('retries non-HTTP errors (e.g. network failures)', async () => { + const scope = nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .replyWithError('network down'); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + 'network down', + ); + expect(scope.isDone()).toBe(true); + }); + + it('lets consumer-supplied policyOptions override the default retryFilterPolicy', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [409]; + }); + const { service } = createService({ + options: { policyOptions: { retryFilterPolicy: handleAll } }, + }); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '409'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + }); }); /** diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index 87a94291dd..b04c683041 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -5,7 +5,7 @@ import type { DataServiceInvalidateQueriesAction, } from '@metamask/base-data-service'; import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; -import { HttpError } from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import { array, @@ -223,6 +223,34 @@ const ServiceDetailsResponseStruct = type({ ), }); +// === RETRY POLICY === + +/** + * Determines whether an error from a CHOMP API call is worth retrying. + * + * 4xx responses (e.g. 409 "already exists", 400 validation, 401/403 auth) are + * caused by the request itself and will not be resolved by re-issuing the same + * request, so they bypass the retry loop. 429 is treated as transient and + * retried alongside 5xx server errors. Non-HTTP errors (network/timeout) fall + * through to the default "retry" behaviour. + * + * @param error - The error thrown by the query function. + * @returns `true` when the error is worth retrying. + */ +function isRetryableError(error: unknown): boolean { + if (error instanceof HttpError) { + if (error.httpStatus === 429) { + return true; + } + return error.httpStatus < 400 || error.httpStatus >= 500; + } + return true; +} + +const DEFAULT_POLICY_OPTIONS: CreateServicePolicyOptions = { + retryFilterPolicy: handleWhen(isRetryableError), +}; + // === SERVICE DEFINITION === /** @@ -262,7 +290,7 @@ export class ChompApiService extends BaseDataService< name: serviceName, messenger, queryClientConfig, - policyOptions, + policyOptions: { ...DEFAULT_POLICY_OPTIONS, ...policyOptions }, }); this.#baseUrl = baseUrl; From 8158cb45ff8be772d339ffbd322a39634d1ee674 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 20:14:39 +0100 Subject: [PATCH 11/15] chore: update changelog --- packages/money-account-upgrade-controller/CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 1a32621878..5160fc31b1 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -9,14 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- Add remaining steps in money account upgrade process ([#8621](https://github.com/MetaMask/core/pull/8621)) ### Changed +* Treat 409 as 'already-done' in EIP-7702 step ([#8621](https://github.com/MetaMask/core/pull/8621)) - **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)) From e19e38fd835364aa4c0f0234e6af2782147b8744 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 20:14:59 +0100 Subject: [PATCH 12/15] chore: format --- packages/chomp-api-service/src/chomp-api-service.test.ts | 5 +---- packages/money-account-upgrade-controller/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts index 8241d5c8cd..0cab2c7e02 100644 --- a/packages/chomp-api-service/src/chomp-api-service.test.ts +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -500,10 +500,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .get('/v1/chomp') - .query({ chainId: '0xa4b1' }) - .reply(400); + nock(BASE_URL).get('/v1/chomp').query({ chainId: '0xa4b1' }).reply(400); const { service } = createService(); await expect(service.getServiceDetails(['0xa4b1'])).rejects.toThrow( diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 5160fc31b1..f84570ff88 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* Treat 409 as 'already-done' in EIP-7702 step ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Treat 409 as 'already-done' in EIP-7702 step ([#8621](https://github.com/MetaMask/core/pull/8621)) - **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)) From 79b6635b81850389e13c022318aa6f601b5142d8 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 20:22:28 +0100 Subject: [PATCH 13/15] chore: bump @metamask/authenticated-user-storage to ^1.0.1 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/money-account-upgrade-controller/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 4b7851f698..e490599352 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -53,7 +53,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/authenticated-user-storage": "^1.0.0", + "@metamask/authenticated-user-storage": "^1.0.1", "@metamask/base-controller": "^9.1.0", "@metamask/chomp-api-service": "^3.0.1", "@metamask/delegation-controller": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 27a7e2db35..84b562a79b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2926,7 +2926,7 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@npm:^1.0.0, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": +"@metamask/authenticated-user-storage@npm:^1.0.1, @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: @@ -4540,7 +4540,7 @@ __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/authenticated-user-storage": "npm:^1.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.0.1" From f7a819656d297b2aee1b93cb549d2108df048c1d Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 20:26:16 +0100 Subject: [PATCH 14/15] test: cover non-object rejections in eip-7702 step's 409 handling Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/steps/eip-7702-authorization.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 2325988521..e1c179fc8f 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 @@ -345,6 +345,19 @@ describe('eip7702AuthorizationStep', () => { await expect(run(messenger)).rejects.toThrow('server error'); }); + it.each([ + ['a string', 'boom'], + ['null', null], + ])( + 'propagates non-object rejections from createUpgrade (%s)', + async (_label, rejection) => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue(rejection); + + await expect(run(messenger)).rejects.toBe(rejection); + }, + ); + it('throws when eth_getTransactionCount returns a non-hex response', async () => { const { messenger, mocks } = setup(); mocks.providerRequest.mockImplementation(async ({ method }) => { From d0eb979a1b252f3b857a1a95a3b73b499108e060 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 11 May 2026 20:42:45 +0100 Subject: [PATCH 15/15] chore: remove duplicate changelog entry --- packages/money-account-upgrade-controller/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index f84570ff88..1f06d4e759 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Treat 409 as 'already-done' in EIP-7702 step ([#8621](https://github.com/MetaMask/core/pull/8621)) - **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)) @@ -23,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) -- EIP-7702 authorization step now treats a 409 response from `POST /v1/account-upgrade` as `already-done` instead of a fatal error, making the step retry-safe when a prior submission was accepted by CHOMP but has not yet been observed on-chain. +- EIP-7702 authorization step now treats a 409 response from `POST /v1/account-upgrade` as `already-done` instead of a fatal error, making the step retry-safe when a prior submission was accepted by CHOMP but has not yet been observed on-chain. ([#8621](https://github.com/MetaMask/core/pull/8621)) ## [1.3.2]