diff --git a/README.md b/README.md index d2810cadd5..dd2325a539 100644 --- a/README.md +++ b/README.md @@ -380,8 +380,10 @@ linkStyle default opacity:0.5 money_account_controller --> base_controller; money_account_controller --> keyring_controller; money_account_controller --> messenger; + money_account_upgrade_controller --> authenticated_user_storage; money_account_upgrade_controller --> base_controller; money_account_upgrade_controller --> chomp_api_service; + money_account_upgrade_controller --> delegation_controller; money_account_upgrade_controller --> keyring_controller; money_account_upgrade_controller --> messenger; money_account_upgrade_controller --> network_controller; diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7c61892367..62d7af273e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -12,7 +12,7 @@ }, "packages/account-tree-controller/src/AccountTreeController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 8 + "count": 7 }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 diff --git a/package.json b/package.json index d82c93d5f4..feb9d66fd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "975.0.0", + "version": "977.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index b5e4965c01..8eef03ff2d 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountTreeController:accountGroup{Created,Updated,Removed}` events ([#8766](https://github.com/MetaMask/core/pull/8766)) + - None of these events fire during `init`/`reinit`, consumers should bootstrap from `:getState` or `:accountTreeChange`. + ### Changed - Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 71db24735f..dfdf7bfa5e 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4740,6 +4740,314 @@ describe('AccountTreeController', () => { expect(selectedAccountGroupChangeListener).not.toHaveBeenCalled(); }); + + it('does NOT emit accountGroupCreated or accountGroupUpdated during init', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + + expect(createdListener).not.toHaveBeenCalled(); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupCreated when a new group is added post-init', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsAdded', [ + { ...MOCK_HD_ACCOUNT_2 }, + ]); + + const newWalletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const newGroupId = toMultichainAccountGroupId( + newWalletId, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[newWalletId].groups[newGroupId]; + + expect(createdListener).toHaveBeenCalledTimes(1); + expect(createdListener).toHaveBeenCalledWith(expectedGroup); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when an account is added to an existing group', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsAdded', [ + { ...MOCK_TRX_ACCOUNT_1 }, + ]); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(createdListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when an account is removed but the group remains', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_TRX_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_TRX_ACCOUNT_1.id, + ]); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + }); + + it('does NOT emit accountGroupUpdated when a removed account causes the group to be pruned', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_1.id, + ]); + + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupRemoved when the last account of a group is removed', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const removedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupRemoved', + removedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const removedWalletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const removedGroupId = toMultichainAccountGroupId( + removedWalletId, + MOCK_SNAP_ACCOUNT_1.options.entropy.groupIndex, + ); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_1.id, + ]); + + expect(removedListener).toHaveBeenCalledTimes(1); + expect(removedListener).toHaveBeenCalledWith(removedGroupId); + }); + + it('does NOT emit accountGroupRemoved when the group still has accounts', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_TRX_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const removedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupRemoved', + removedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_TRX_ACCOUNT_1.id, + ]); + + expect(removedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when setAccountGroupName is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupName(groupId, 'Renamed Group'); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.name).toBe('Renamed Group'); + }); + + it('emits accountGroupUpdated when setAccountGroupPinned is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupPinned(groupId, true); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.pinned).toBe(true); + }); + + it('emits accountGroupUpdated when setAccountGroupHidden is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupHidden(groupId, true); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.hidden).toBe(true); + }); }); describe('syncWithUserStorage', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 211b16041f..5640662e9f 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -908,19 +908,32 @@ export class AccountTreeController extends BaseController< return; } + const createdGroups = new Map(); + const updatedGroups = new Map(); + this.update((state) => { for (const account of newAccounts) { - this.#insert(state.accountTree.wallets, account); + const { walletId, groupId, created } = this.#insert( + state.accountTree.wallets, + account, + ); - const context = this.#accountIdToContext.get(account.id); - if (context) { - const { walletId, groupId } = context; + if (created) { + createdGroups.set(groupId, walletId); + } else if (!createdGroups.has(groupId)) { + // ^ We check that the group has not been created in this same batch before adding it to the `updatedGroups` + // map, to avoid sending both created and updated events for the same group: + // - Account 1 + Account 2 + Account 3 + // - Account 1 and 3 belong to the same group + // - Account 1 will create the group + // - Account 3 will update the group (but we only want to send a created event, not an updated one) + updatedGroups.set(groupId, walletId); + } - const wallet = state.accountTree.wallets[walletId]; - if (wallet) { - this.#applyAccountWalletMetadata(state, walletId); - this.#applyAccountGroupMetadata(state, walletId, groupId); - } + const wallet = state.accountTree.wallets[walletId]; + if (wallet) { + this.#applyAccountWalletMetadata(state, walletId); + this.#applyAccountGroupMetadata(state, walletId, groupId); } } }); @@ -929,6 +942,13 @@ export class AccountTreeController extends BaseController< `${controllerName}:accountTreeChange`, this.state.accountTree, ); + + for (const [groupId, walletId] of createdGroups) { + this.#publishAccountGroupCreated(walletId, groupId); + } + for (const [groupId, walletId] of updatedGroups) { + this.#publishAccountGroupUpdated(walletId, groupId); + } } /** @@ -958,6 +978,8 @@ export class AccountTreeController extends BaseController< } const previousSelectedAccountGroup = this.state.selectedAccountGroup; + const updatedGroups = new Map(); + const removedGroups = new Set(); this.update((state) => { for (const { id: accountId, context } of knownAccounts) { @@ -981,6 +1003,12 @@ export class AccountTreeController extends BaseController< } if (accounts.length === 0) { this.#pruneEmptyGroupAndWallet(state, walletId, groupId); + + // If the group gets pruned, we should not consider it as updated. + updatedGroups.delete(groupId); + removedGroups.add(groupId); + } else { + updatedGroups.set(groupId, walletId); } } } @@ -996,6 +1024,13 @@ export class AccountTreeController extends BaseController< this.state.accountTree, ); + for (const [groupId, walletId] of updatedGroups) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + for (const groupId of removedGroups) { + this.#publishAccountGroupRemoved(groupId); + } + const newSelectedAccountGroup = this.state.selectedAccountGroup; if (newSelectedAccountGroup !== previousSelectedAccountGroup) { this.messenger.publish( @@ -1039,6 +1074,49 @@ export class AccountTreeController extends BaseController< return state; } + /** + * Publishes the `:accountGroupCreated` event for a newly added group. + * No-op if the group is not in state (defensive). + * + * @param walletId - The parent wallet ID. + * @param groupId - The newly created group's ID. + */ + #publishAccountGroupCreated( + walletId: AccountWalletId, + groupId: AccountGroupId, + ): void { + const group = this.state.accountTree.wallets[walletId]?.groups[groupId]; + if (group) { + this.messenger.publish(`${controllerName}:accountGroupCreated`, group); + } + } + + /** + * Publishes the `:accountGroupUpdated` event for an existing group. + * No-op if the group is not in state (e.g. it was pruned). + * + * @param walletId - The parent wallet ID. + * @param groupId - The updated group's ID. + */ + #publishAccountGroupUpdated( + walletId: AccountWalletId, + groupId: AccountGroupId, + ): void { + const group = this.state.accountTree.wallets[walletId]?.groups[groupId]; + if (group) { + this.messenger.publish(`${controllerName}:accountGroupUpdated`, group); + } + } + + /** + * Publishes the `:accountGroupRemoved` event for a pruned group. + * + * @param groupId - The removed group's ID. + */ + #publishAccountGroupRemoved(groupId: AccountGroupId): void { + this.messenger.publish(`${controllerName}:accountGroupRemoved`, groupId); + } + /** * Insert an account inside an account tree. * @@ -1048,11 +1126,12 @@ export class AccountTreeController extends BaseController< * * @param wallets - Account tree. * @param account - The account to be inserted. + * @returns The wallet ID, group ID, and whether the group has been created or not. */ #insert( wallets: AccountTreeControllerState['accountTree']['wallets'], account: InternalAccount, - ) { + ): { walletId: AccountWalletId; groupId: AccountGroupId; created: boolean } { const result = this.#getEntropyRule().match(account) ?? this.#getSnapRule().match(account) ?? @@ -1086,6 +1165,7 @@ export class AccountTreeController extends BaseController< let group = wallet.groups[groupId]; const { type, id } = account; const sortOrder = ACCOUNT_TYPE_TO_SORT_ORDER[type]; + const created = !group; if (!group) { log(`[${walletId}] Add new group: [${groupId}]`); @@ -1143,6 +1223,8 @@ export class AccountTreeController extends BaseController< groupId: group.id, sortOrder, }); + + return { walletId: wallet.id, groupId: group.id, created }; } /** @@ -1526,6 +1608,8 @@ export class AccountTreeController extends BaseController< finalName; }); + this.#publishAccountGroupUpdated(walletId, groupId); + // Trigger atomic sync for group rename (only for groups from entropy wallets) if (wallet.type === AccountWalletType.Entropy) { this.#backupAndSyncService.enqueueSingleGroupSync(groupId); @@ -1596,6 +1680,10 @@ export class AccountTreeController extends BaseController< } }); + if (walletId) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + // Trigger atomic sync for group pinning (only for groups from entropy wallets) if ( walletId && @@ -1638,6 +1726,10 @@ export class AccountTreeController extends BaseController< } }); + if (walletId) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + // Trigger atomic sync for group hiding (only for groups from entropy wallets) if ( walletId && diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index ab9fdcd59f..28b2f17f64 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -14,6 +14,9 @@ export type { AccountTreeControllerStateChangeEvent, AccountTreeControllerAccountTreeChangeEvent, AccountTreeControllerSelectedAccountGroupChangeEvent, + AccountTreeControllerAccountGroupCreatedEvent, + AccountTreeControllerAccountGroupUpdatedEvent, + AccountTreeControllerAccountGroupRemovedEvent, AccountTreeControllerEvents, AccountTreeControllerMessenger, } from './types'; diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 6b0b91d096..e7a6657139 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -123,6 +123,36 @@ export type AccountTreeControllerSelectedAccountGroupChangeEvent = { payload: [AccountGroupId | '', AccountGroupId | '']; }; +/** + * Represents the `AccountTreeController:accountGroupCreated` event. + * This event is emitted when a new account group is added to the tree + * after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupCreatedEvent = { + type: `${typeof controllerName}:accountGroupCreated`; + payload: [AccountGroupObject]; +}; + +/** + * Represents the `AccountTreeController:accountGroupUpdated` event. + * This event is emitted when an existing account group's metadata or + * membership changes after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupUpdatedEvent = { + type: `${typeof controllerName}:accountGroupUpdated`; + payload: [AccountGroupObject]; +}; + +/** + * Represents the `AccountTreeController:accountGroupRemoved` event. + * This event is emitted when an account group is pruned from the tree + * (its last account was removed) after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupRemovedEvent = { + type: `${typeof controllerName}:accountGroupRemoved`; + payload: [AccountGroupId]; +}; + export type AllowedEvents = | AccountsControllerAccountsAddedEvent | AccountsControllerAccountsRemovedEvent @@ -133,7 +163,10 @@ export type AllowedEvents = export type AccountTreeControllerEvents = | AccountTreeControllerStateChangeEvent | AccountTreeControllerAccountTreeChangeEvent - | AccountTreeControllerSelectedAccountGroupChangeEvent; + | AccountTreeControllerSelectedAccountGroupChangeEvent + | AccountTreeControllerAccountGroupCreatedEvent + | AccountTreeControllerAccountGroupUpdatedEvent + | AccountTreeControllerAccountGroupRemovedEvent; export type AccountTreeControllerMessenger = Messenger< typeof controllerName, diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 38b473c825..016e0fc8f9 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Optional `skipUUIDv4Check` on `AnalyticsPlatformAdapter` to allow non-UUIDv4 `analyticsId` strings when constructing `AnalyticsController` ([#8543](https://github.com/MetaMask/core/pull/8543)) + ### Changed +- Mark `analyticsId` as persisted (`persist: true`) in `AnalyticsController` state metadata so it is saved and restored with `optedIn` when using a persisted controller composition ([#8542](https://github.com/MetaMask/core/pull/8542)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) - Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index f4979175b7..0fb6d1b968 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -14,32 +14,16 @@ or The AnalyticsController provides a unified interface for tracking analytics events, identifying users, and managing analytics preferences. It delegates client platform-specific implementation to an `AnalyticsPlatformAdapter` and integrates with the MetaMask messenger system for inter-controller communication. -## Client Platform-Managed Storage - -> [!NOTE] -> "Client platform" means mobile or extension - -The controller does not persist state internally. The client platform is responsible for loading and persisting analytics settings. This design enables: - -- **Early access**: The client platform can read the `analyticsId` before the controller is initialized, useful for other controllers or early startup code -- **Resilience**: Storing analytics settings separately from main state protects them from state corruption, allowing analytics to continue working even when main state is corrupted - -Load settings from storage **before** initializing the controller, then subscribe to `AnalyticsController:stateChange` events to persist any state changes. - ## State | Field | Type | Description | Persisted | | ------------- | --------- | --------------------------------------------- | --------- | -| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | No | +| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes | | `optedIn` | `boolean` | User opt-in status | Yes | -### Why `analyticsId` Has No Default - -The `analyticsId` uniquely identifies the user. If the controller generated a new ID on each boot, the ID would be ineffective. The client platform must generate a UUID on first run, persist it, and provide it to the controller constructor. - ### Client Platform Responsibilities -1. **Generate UUID on first run**: Use `uuid` package or client platform equivalent +1. **Generate or migrate an initial `analyticsId`**: Use the `uuid` package or client platform equivalent for new installs, or migrate an existing MetaMetrics identifier when available. The controller validates this value as a UUIDv4, but does not create a default ID. 2. **Load state before controller init**: Read from storage, provide to constructor 3. **Subscribe to state changes**: Persist changes to isolated storage 4. **Persist to isolated storage**: Keep analytics settings separate from main state (protects against state corruption) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index e6c9a43a32..16744874a6 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1,3 +1,4 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; @@ -148,6 +149,88 @@ describe('AnalyticsController', () => { }); }); + describe('metadata', () => { + const metadataFixtureState: AnalyticsControllerState = { + optedIn: true, + analyticsId: '6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d', + }; + + it('includes expected state in debug snapshots', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "optedIn": true, + } + `); + }); + + it('includes expected state in state logs', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "optedIn": true, + } + `); + }); + + it('persists expected state', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "optedIn": true, + } + `); + }); + + it('exposes expected state to UI', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "optedIn": true, + } + `); + }); + }); + describe('isValidUUIDv4', () => { it('returns true for valid UUIDv4', () => { expect(isValidUUIDv4('550e8400-e29b-41d4-a716-446655440000')).toBe(true); @@ -377,6 +460,26 @@ describe('AnalyticsController', () => { }).toThrow('Invalid analyticsId'); }); + it('accepts non-UUID analyticsId when adapter skipUUIDv4Check is true', async () => { + const analyticsId = 'not-a-uuid'; + const platformAdapter = { + ...createMockAdapter(), + skipUUIDv4Check: true, + }; + const { controller } = await setupController({ + state: { + optedIn: false, + analyticsId, + }, + platformAdapter, + }); + + expect(controller.state.analyticsId).toBe(analyticsId); + expect(platformAdapter.onSetupCompleted).toHaveBeenCalledWith( + analyticsId, + ); + }); + it('accepts different valid UUIDv4 values', async () => { const analyticsId1 = '11111111-1111-4111-8111-111111111111'; const analyticsId2 = '22222222-2222-4222-9222-222222222222'; diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 352b170247..182386d7ba 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -65,10 +65,8 @@ export function getDefaultAnalyticsControllerState(): Omit< /** * The metadata for each property in {@link AnalyticsControllerState}. * - * Note: `optedIn` is persisted by the controller (`persist: true`). - * `analyticsId` is persisted by the platform (`persist: false`) and provided - * via initial state. The platform should subscribe to `stateChange` events - * to persist any state changes. + * Both `optedIn` and `analyticsId` are persisted (`persist: true`). + * The platform must supply a valid UUIDv4 `analyticsId` on first run. */ const analyticsControllerMetadata = { optedIn: { @@ -79,7 +77,7 @@ const analyticsControllerMetadata = { }, analyticsId: { includeInStateLogs: true, - persist: false, + persist: true, includeInDebugSnapshot: true, usedInUi: false, }, @@ -151,7 +149,8 @@ export type AnalyticsControllerMessenger = Messenger< export type AnalyticsControllerOptions = { /** * Initial controller state. Must include a valid UUIDv4 `analyticsId`. - * The platform is responsible for generating and persisting the analyticsId. + * The platform is responsible for generating the ID on first run. + * It is then persisted with controller state when using a persisted store. */ state: AnalyticsControllerState; /** @@ -181,9 +180,8 @@ export type AnalyticsControllerOptions = { * messenger system to allow other controllers and components to track analytics events. * It delegates platform-specific implementation to an {@link AnalyticsPlatformAdapter}. * - * Note: The controller persists `optedIn` internally. The `analyticsId` is persisted - * by the platform and must be provided via initial state. The platform should subscribe - * to `AnalyticsController:stateChange` events to persist any state changes. + * The controller persists `optedIn` and `analyticsId` when composed with a persisted + * store. The platform must supply a valid `analyticsId` on first launch. */ export class AnalyticsController extends BaseController< 'AnalyticsController', @@ -219,7 +217,10 @@ export class AnalyticsController extends BaseController< ...state, }; - validateAnalyticsControllerState(initialState); + validateAnalyticsControllerState( + initialState, + platformAdapter.skipUUIDv4Check === true, + ); super({ name: controllerName, diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 39d22ff592..78485ba6f4 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -33,6 +33,12 @@ export type AnalyticsTrackingEvent = { * Implementations should handle platform-specific details (Segment SDK, etc.) */ export type AnalyticsPlatformAdapter = { + /** + * When `true`, the controller accepts any non-empty `analyticsId` string + * instead of requiring UUIDv4 format. Defaults to validation against UUIDv4 when omitted or `false`. + */ + skipUUIDv4Check?: boolean; + /** * Track an analytics event. * diff --git a/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts b/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts index b36dee3816..2e1dea9c96 100644 --- a/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts +++ b/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts @@ -47,5 +47,36 @@ describe('analyticsControllerStateValidator', () => { expect(() => validateAnalyticsControllerState(state)).not.toThrow(); }); + + it('does not throw for non-UUID string when skipUUIDv4Check is true', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: 'not-a-uuid', + }; + + expect(() => validateAnalyticsControllerState(state, true)).not.toThrow(); + }); + + it('throws when analyticsId is not UUIDv4 and skipUUIDv4Check is false', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: 'not-a-uuid', + }; + + expect(() => validateAnalyticsControllerState(state, false)).toThrow( + 'Invalid analyticsId', + ); + }); + + it('throws when analyticsId is empty string even if skipUUIDv4Check is true', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: '', + }; + + expect(() => validateAnalyticsControllerState(state, true)).toThrow( + 'Invalid analyticsId', + ); + }); }); }); diff --git a/packages/analytics-controller/src/analyticsControllerStateValidator.ts b/packages/analytics-controller/src/analyticsControllerStateValidator.ts index 1713a9731b..f40773994d 100644 --- a/packages/analytics-controller/src/analyticsControllerStateValidator.ts +++ b/packages/analytics-controller/src/analyticsControllerStateValidator.ts @@ -24,12 +24,17 @@ export function isValidUUIDv4(value: string): boolean { * Validates that the analytics state has a valid UUIDv4 analyticsId. * * @param state - The analytics controller state to validate + * @param skipUUIDv4Check - When `true`, skips UUIDv4 format validation * @throws Error if analyticsId is missing or not a valid UUIDv4 */ export function validateAnalyticsControllerState( state: AnalyticsControllerState, + skipUUIDv4Check?: boolean, ): void { - if (!state.analyticsId || !isValidUUIDv4(state.analyticsId)) { + if ( + !state.analyticsId || + (skipUUIDv4Check !== true && !isValidUUIDv4(state.analyticsId)) + ) { throw new Error('Invalid analyticsId'); } } diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a77b975415..66e3eac048 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.1.0] + ### Changed -- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) - Update `RpcDataSource` to prevent native `getEthBalance` fetching for Tempo chains ([#8638](https://github.com/MetaMask/core/pull/8638)) +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) ## [7.0.1] @@ -468,7 +471,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.0...HEAD +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...@metamask/assets-controller@7.1.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.0...@metamask/assets-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.4.0...@metamask/assets-controller@7.0.0 [6.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...@metamask/assets-controller@6.4.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 6e5975d847..ab2bcf2731 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "7.0.1", + "version": "7.1.0", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -58,7 +58,7 @@ "@ethersproject/providers": "^5.7.0", "@metamask/account-tree-controller": "^7.3.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controllers": "^107.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", "@metamask/controller-utils": "^12.0.0", diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 1505772e1a..e06912fa6b 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [107.0.0] + +### Added + +- Export new type `TrendingTokensQueryParams` for extensible trending token query parameters ([#8729](https://github.com/MetaMask/core/pull/8729)) + ### Changed +- **BREAKING:** `getTrendingTokens` now accepts `sort` instead of `sortBy` to match the API parameter name ([#8729](https://github.com/MetaMask/core/pull/8729)) +- `getTrendingTokens` and `getTrendingTokensURL` now accept arbitrary query parameters via an index signature on `TrendingTokensQueryParams`, allowing new API parameters to pass through without a core release ([#8729](https://github.com/MetaMask/core/pull/8729)) - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) - Modify `SPOT_PRICES_SUPPORT_INFO` entries for Tempo chains (`0x1079` and `0xa5bf`) ([#8638](https://github.com/MetaMask/core/pull/8638)) @@ -3065,7 +3073,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@107.0.0...HEAD +[107.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...@metamask/assets-controllers@107.0.0 [106.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...@metamask/assets-controllers@106.0.1 [106.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...@metamask/assets-controllers@106.0.0 [105.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.0.0...@metamask/assets-controllers@105.1.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 3030339eca..4f99321565 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "106.0.1", + "version": "107.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 8bc2c17247..dda1702b93 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -292,6 +292,7 @@ export { createFormatters } from './utils/formatters'; export type { SortTrendingBy, TrendingAsset, + TrendingTokensQueryParams, TokenSearchItem, TokenAsset, TokenRwaData, diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 7ae819b9a8..e6362f154a 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -1139,7 +1139,7 @@ describe('Token service', () => { it('returns empty array if api returns non-array response', async () => { nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}&includeRwaData=true&usePriceApiData=true`, ) .reply(200, { error: 'Invalid response' }) .persist(); @@ -1151,7 +1151,7 @@ describe('Token service', () => { it('returns empty array if the fetch fails', async () => { nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}&includeRwaData=true&usePriceApiData=true`, ) .reply(500) .persist(); @@ -1162,7 +1162,7 @@ describe('Token service', () => { it('returns the list of trending tokens if the fetch succeeds', async () => { const testChainId = 'eip155:1'; - const sortBy: SortTrendingBy = 'm5_trending'; + const sort: SortTrendingBy = 'm5_trending'; const testMinLiquidity = 1000000; const testMinVolume24hUsd = 1000000; const testMaxVolume24hUsd = 1000000; @@ -1170,14 +1170,14 @@ describe('Token service', () => { const testMaxMarketCap = 1000000; nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=${sortBy}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}&includeRwaData=true&usePriceApiData=true`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=${sort}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}&includeRwaData=true&usePriceApiData=true`, ) .reply(200, sampleTrendingTokens) .persist(); const result = await getTrendingTokens({ chainIds: [testChainId], - sortBy, + sort, minLiquidity: testMinLiquidity, minVolume24hUsd: testMinVolume24hUsd, maxVolume24hUsd: testMaxVolume24hUsd, @@ -1292,7 +1292,7 @@ describe('Token service', () => { const result = await getTrendingTokens({ chainIds: [testChainId], - sortBy: 'h6_trending', + sort: 'h6_trending', minLiquidity: testMinLiquidity, minVolume24hUsd: testMinVolume, includeRwaData: false, @@ -1300,6 +1300,23 @@ describe('Token service', () => { }); expect(result).toStrictEqual(sampleTrendingTokensWithSecurityData); }); + + it('passes unknown query params through to the URL', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true&vsCurrency=eur`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + vsCurrency: 'eur', + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); }); describe('searchTokens with includeTokenSecurityData', () => { diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 02464f1556..1b49ccd656 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -138,24 +138,14 @@ function getTokenAssetsURL(options: { } /** - * Get the trending tokens URL for the given networks and search query. + * Shared query-parameter type for the v3 trending tokens endpoint. * - * @param options - Options for getting trending tokens. - * @param options.chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). - * @param options.sort - The sort field. - * @param options.minLiquidity - The minimum liquidity. - * @param options.minVolume24hUsd - The minimum volume 24h in USD. - * @param options.maxVolume24hUsd - The maximum volume 24h in USD. - * @param options.minMarketCap - The minimum market cap. - * @param options.maxMarketCap - The maximum market cap. - * @param options.excludeLabels - Array of labels to exclude (e.g., ['stable_coin', 'blue_chip']). - * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). - * @param options.usePriceApiData - Optional flag to use price API data in the results (defaults to false). - * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). - * @returns The trending tokens URL. + * Known parameters are explicitly typed for autocomplete and documentation. + * The index signature allows new API parameters to pass through without + * requiring a core release — callers can add any additional key/value and + * it will be forwarded as a query parameter. */ -function getTrendingTokensURL(options: { - chainIds: CaipChainId[]; +export type TrendingTokensQueryParams = { sort?: SortTrendingBy; minLiquidity?: number; minVolume24hUsd?: number; @@ -166,11 +156,21 @@ function getTrendingTokensURL(options: { includeRwaData?: boolean; usePriceApiData?: boolean; includeTokenSecurityData?: boolean; -}): string { + [key: string]: string | number | boolean | string[] | undefined; +}; + +/** + * Get the trending tokens URL for the given networks and search query. + * + * @param options - Options bag: `chainIds` (required) plus any query params. + * @returns The trending tokens URL. + */ +function getTrendingTokensURL( + options: { chainIds: CaipChainId[] } & TrendingTokensQueryParams, +): string { const encodedChainIds = options.chainIds .map((id) => encodeURIComponent(id)) .join(','); - // Add the rest of query params if they are defined const queryParams = new URLSearchParams(); const { chainIds, excludeLabels, ...rest } = options; Object.entries(rest).forEach(([key, value]) => { @@ -391,46 +391,20 @@ export type TrendingAsset = { /** * Get the trending tokens for the given chains. * - * @param options - Options for getting trending tokens. - * @param options.chainIds - The chains to get the trending tokens for. - * @param options.sortBy - The sort by field. - * @param options.minLiquidity - The minimum liquidity. - * @param options.minVolume24hUsd - The minimum volume 24h in USD. - * @param options.maxVolume24hUsd - The maximum volume 24h in USD. - * @param options.minMarketCap - The minimum market cap. - * @param options.maxMarketCap - The maximum market cap. - * @param options.excludeLabels - Array of labels to exclude (e.g., ['stable_coin', 'blue_chip']). - * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to true). - * @param options.usePriceApiData - Optional flag to use price API data in the results (defaults to true). - * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). + * Accepts all known query parameters plus any additional ones via the + * index signature on {@link TrendingTokensQueryParams}. New API parameters + * can be passed without updating this function. + * + * @param options - Options bag: `chainIds` (required) plus any query params + * supported by the v3 trending endpoint. * @returns The trending tokens. * @throws Will throw if the request fails. */ -export async function getTrendingTokens({ - chainIds, - sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - excludeLabels, - includeRwaData = true, - usePriceApiData = true, - includeTokenSecurityData, -}: { - chainIds: CaipChainId[]; - sortBy?: SortTrendingBy; - minLiquidity?: number; - minVolume24hUsd?: number; - maxVolume24hUsd?: number; - minMarketCap?: number; - maxMarketCap?: number; - excludeLabels?: string[]; - includeRwaData?: boolean; - usePriceApiData?: boolean; - includeTokenSecurityData?: boolean; -}): Promise { +export async function getTrendingTokens( + options: { chainIds: CaipChainId[] } & TrendingTokensQueryParams, +): Promise { + const { chainIds, ...rest } = options; + if (chainIds.length === 0) { console.error('No chains provided'); return []; @@ -438,16 +412,9 @@ export async function getTrendingTokens({ const trendingTokensURL = getTrendingTokensURL({ chainIds, - sort: sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - excludeLabels, - includeRwaData, - usePriceApiData, - includeTokenSecurityData, + ...rest, + includeRwaData: rest.includeRwaData ?? true, + usePriceApiData: rest.usePriceApiData ?? true, }); try { diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index ec8f455eaf..5ab089cb42 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [72.0.3] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) ## [72.0.2] @@ -1458,7 +1462,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.3...HEAD +[72.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...@metamask/bridge-controller@72.0.3 [72.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.1...@metamask/bridge-controller@72.0.2 [72.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.0...@metamask/bridge-controller@72.0.1 [72.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...@metamask/bridge-controller@72.0.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 10b6b22511..bc065c5084 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "72.0.2", + "version": "72.0.3", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -58,8 +58,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controller": "^7.0.1", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controller": "^7.1.0", + "@metamask/assets-controllers": "^107.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a4a502ba71..7d2e375e42 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [71.1.3] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.0.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) ## [71.1.2] @@ -1178,7 +1181,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.3...HEAD +[71.1.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...@metamask/bridge-status-controller@71.1.3 [71.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.1...@metamask/bridge-status-controller@71.1.2 [71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.0...@metamask/bridge-status-controller@71.1.1 [71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.0.0...@metamask/bridge-status-controller@71.1.0 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index aedf53d5dc..0788e65aeb 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-status-controller", - "version": "71.1.2", + "version": "71.1.3", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -54,7 +54,7 @@ "dependencies": { "@metamask/accounts-controller": "^38.1.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.0.2", + "@metamask/bridge-controller": "^72.0.3", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index b54f3dc871..fdd9182667 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0] + +### 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 @@ -33,7 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `ChompApiService` ([#8413](https://github.com/MetaMask/core/pull/8413)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.1.0...HEAD +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.1...@metamask/chomp-api-service@3.1.0 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.0...@metamask/chomp-api-service@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@2.0.0...@metamask/chomp-api-service@3.0.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@1.0.0...@metamask/chomp-api-service@2.0.0 diff --git a/packages/chomp-api-service/package.json b/packages/chomp-api-service/package.json index 92185a9ed9..1b388da2c1 100644 --- a/packages/chomp-api-service/package.json +++ b/packages/chomp-api-service/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/chomp-api-service", - "version": "3.0.1", + "version": "3.1.0", "description": "Data service for the Chomp API", "keywords": [ "Ethereum", 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..0cab2c7e02 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( @@ -509,11 +500,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .get('/v1/chomp') - .query({ chainId: '0xa4b1' }) - .times(DEFAULT_MAX_RETRIES + 1) - .reply(400); + nock(BASE_URL).get('/v1/chomp').query({ chainId: '0xa4b1' }).reply(400); const { service } = createService(); await expect(service.getServiceDetails(['0xa4b1'])).rejects.toThrow( @@ -533,6 +520,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; diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index d3b4943766..d5c375aaaf 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,9 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] + +### Added + +- Add remaining steps in money account upgrade process ([#8621](https://github.com/MetaMask/core/pull/8621)) + ### Changed +- **BREAKING:** The controller messenger now requires access to six additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:getIntentsByAddress`, `ChompApiService:createIntents`, and `DelegationController:signDelegation`. Delegation signing is now delegated to `@metamask/delegation-controller` rather than calling `KeyringController:signTypedMessage` directly; consumers must instantiate `DelegationController` and update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `init()` now takes a `{ chainId, boringVaultAddress }` object instead of an `InitConfig`. The EIP-7702 delegator implementation and caveat enforcer addresses are resolved from `@metamask/delegation-deployments` for the target chain; `init()` throws if the chain is not supported by Delegation Framework 1.3.0. The `InitConfig` type is no longer exported. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- 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)) +- Bump `@metamask/chomp-api-service` from `^3.0.1` to `^3.1.0` ([#8769](https://github.com/MetaMask/core/pull/8769)) + +### 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. ([#8621](https://github.com/MetaMask/core/pull/8621)) ## [1.3.2] @@ -64,7 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MoneyAccountUpgradeController` with `upgradeAccount` method ([#8426](https://github.com/MetaMask/core/pull/8426)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.0...HEAD +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.2...@metamask/money-account-upgrade-controller@2.0.0 [1.3.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.1...@metamask/money-account-upgrade-controller@1.3.2 [1.3.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.0...@metamask/money-account-upgrade-controller@1.3.1 [1.3.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.2.0...@metamask/money-account-upgrade-controller@1.3.0 diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js index ca08413339..f5ba61687a 100644 --- a/packages/money-account-upgrade-controller/jest.config.js +++ b/packages/money-account-upgrade-controller/jest.config.js @@ -11,10 +11,7 @@ const baseConfig = require('../../jest.config.packages'); const displayName = path.basename(__dirname); module.exports = merge(baseConfig, { - // The display name when running multiple projects displayName, - - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { branches: 100, @@ -23,4 +20,5 @@ module.exports = merge(baseConfig, { statements: 100, }, }, + testEnvironment: '/jest.environment.js', }); diff --git a/packages/money-account-upgrade-controller/jest.environment.js b/packages/money-account-upgrade-controller/jest.environment.js new file mode 100644 index 0000000000..a2ab37baeb --- /dev/null +++ b/packages/money-account-upgrade-controller/jest.environment.js @@ -0,0 +1,18 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * Some transitive dependencies rely on the Web Crypto API, which is not + * exposed as a global by jest-environment-node. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 0e3b4c1fe4..a2a7851848 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/money-account-upgrade-controller", - "version": "1.3.2", + "version": "2.0.0", "description": "MetaMask Money account upgrade controller", "keywords": [ "Ethereum", @@ -53,8 +53,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/authenticated-user-storage": "^1.0.1", "@metamask/base-controller": "^9.1.0", - "@metamask/chomp-api-service": "^3.0.1", + "@metamask/chomp-api-service": "^3.1.0", + "@metamask/delegation-controller": "^3.0.0", + "@metamask/delegation-core": "^2.0.0", + "@metamask/delegation-deployments": "^1.3.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^31.1.0", @@ -66,6 +70,7 @@ "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index 8504e2f001..6ce78bc3c6 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -1,51 +1,52 @@ +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { MoneyAccountUpgradeControllerMessenger } from '.'; import { MoneyAccountUpgradeController } from '.'; -import type { UpgradeConfig } from './types'; -const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_CHAIN_ID = '0x1' as Hex; // mainnet, supported in delegation-deployments@1.3.0 +const UNSUPPORTED_CHAIN_ID = '0x539' as Hex; // 1337 — local dev, not in registry const MOCK_ACCOUNT_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; - -const MOCK_CONFIG: UpgradeConfig = { - delegateAddress: '0x1111111111111111111111111111111111111111' as Hex, - delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, - musdTokenAddress: '0x3333333333333333333333333333333333333333' as Hex, - vedaVaultAdapterAddress: '0x4444444444444444444444444444444444444444' as Hex, - erc20TransferAmountEnforcer: - '0x5555555555555555555555555555555555555555' as Hex, - redeemerEnforcer: '0x6666666666666666666666666666666666666666' as Hex, - valueLteEnforcer: '0x7777777777777777777777777777777777777777' as Hex, -}; - -const MOCK_INIT_CONFIG = { - delegatorImplAddress: MOCK_CONFIG.delegatorImplAddress, - musdTokenAddress: MOCK_CONFIG.musdTokenAddress, - redeemerEnforcer: MOCK_CONFIG.redeemerEnforcer, - valueLteEnforcer: MOCK_CONFIG.valueLteEnforcer, -}; +const MOCK_BORING_VAULT_ADDRESS = + '0xA20f97813014129E7609171d2D3AA3da5206259e' as Hex; + +// CHOMP-API-derived values. +const MOCK_DELEGATE_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD_TOKEN_ADDRESS = + '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VEDA_VAULT_ADAPTER_ADDRESS = + '0x4444444444444444444444444444444444444444' as Hex; + +// Delegation Framework deployment for mainnet @ 1.3.0 — the controller resolves +// these from `@metamask/delegation-deployments` rather than accepting them via +// `init()`. We re-read from the same source here so the test does not drift if +// the deployment registry is bumped. +const MAINNET_CONTRACTS = + DELEGATOR_CONTRACTS['1.3.0'][hexToNumber(MOCK_CHAIN_ID)]; const MOCK_SERVICE_DETAILS_RESPONSE = { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: { vedaProtocol: { supportedTokens: [ { - tokenAddress: MOCK_CONFIG.erc20TransferAmountEnforcer, + tokenAddress: MOCK_MUSD_TOKEN_ADDRESS, tokenDecimals: 18, }, ], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'] as const, }, }, @@ -68,6 +69,12 @@ type Mocks = { findNetworkClientIdByChainId: jest.Mock; getNetworkClientById: jest.Mock; providerRequest: jest.Mock; + listDelegations: jest.Mock; + createDelegation: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; }; function setup(): { @@ -104,7 +111,7 @@ function setup(): { }), createUpgrade: jest.fn().mockResolvedValue({ signerAddress: MOCK_ACCOUNT_ADDRESS, - address: MOCK_CONFIG.delegatorImplAddress, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, chainId: MOCK_CHAIN_ID, nonce: '0x0', status: 'pending', @@ -118,6 +125,12 @@ function setup(): { provider: { request: providerRequest }, }), providerRequest, + listDelegations: jest.fn().mockResolvedValue([]), + createDelegation: jest.fn().mockResolvedValue(undefined), + signDelegation: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), }; const rootMessenger = new Messenger({ @@ -152,6 +165,30 @@ function setup(): { 'NetworkController:getNetworkClientById', mocks.getNetworkClientById, ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ namespace: 'MoneyAccountUpgradeController', @@ -167,6 +204,12 @@ function setup(): { 'KeyringController:signEip7702Authorization', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', ], events: [], messenger, @@ -192,11 +235,50 @@ describe('MoneyAccountUpgradeController', () => { it('fetches service details and builds config', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect(mocks.getServiceDetails).toHaveBeenCalledWith([MOCK_CHAIN_ID]); }); + it('throws when the chain has no Delegation Framework deployment', async () => { + const { controller, mocks } = setup(); + + await expect( + controller.init({ + chainId: UNSUPPORTED_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `Delegation Framework 1.3.0 is not deployed on chain ${UNSUPPORTED_CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + + it('uses the supplied boring vault address as the withdrawal-side delegation token', async () => { + const { controller, mocks } = setup(); + + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + // Both delegations were signed; the boring-vault address shows up in the + // ABI-encoded ERC20TransferAmount caveat terms of one of them. + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + const allCaveatTerms = mocks.verifyDelegation.mock.calls + .flatMap(([{ signedDelegation }]) => signedDelegation.caveats) + .map((caveat) => caveat.terms.toLowerCase()); + expect( + allCaveatTerms.some((terms) => + terms.includes(MOCK_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), + ), + ).toBe(true); + }); + it('throws when the chain is not found in service details', async () => { const { controller, mocks } = setup(); @@ -206,7 +288,10 @@ describe('MoneyAccountUpgradeController', () => { }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `Chain ${MOCK_CHAIN_ID} not found in service details response`, ); @@ -219,14 +304,17 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: {}, }, }, }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `vedaProtocol not found for chain ${MOCK_CHAIN_ID} in service details response`, ); @@ -239,11 +327,11 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: { vedaProtocol: { supportedTokens: [], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'], }, }, @@ -252,7 +340,10 @@ describe('MoneyAccountUpgradeController', () => { }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, ); @@ -277,7 +368,10 @@ describe('MoneyAccountUpgradeController', () => { chains: {}, }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow('Chain 0x1 not found in service details response'); await expect( @@ -287,9 +381,12 @@ describe('MoneyAccountUpgradeController', () => { ); }); - it('runs each step for the given address', async () => { + it('runs each step against the deployment-derived contract addresses', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); @@ -302,12 +399,12 @@ describe('MoneyAccountUpgradeController', () => { expect(mocks.signEip7702Authorization).toHaveBeenCalledWith( expect.objectContaining({ from: MOCK_ACCOUNT_ADDRESS, - contractAddress: MOCK_CONFIG.delegatorImplAddress, + contractAddress: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, }), ); expect(mocks.createUpgrade).toHaveBeenCalledWith( expect.objectContaining({ - address: MOCK_CONFIG.delegatorImplAddress, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, chainId: MOCK_CHAIN_ID, nonce: '0x0', }), @@ -316,7 +413,10 @@ describe('MoneyAccountUpgradeController', () => { it('is callable via the messenger', async () => { const { controller, rootMessenger } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect( await rootMessenger.call( @@ -328,7 +428,10 @@ describe('MoneyAccountUpgradeController', () => { it('propagates errors thrown by a step', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); await expect( diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 113fca4260..574a78f400 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -1,3 +1,7 @@ +import type { + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceListDelegationsAction, +} from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, ControllerStateChangedEvent, @@ -6,9 +10,14 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateIntentsAction, ChompApiServiceCreateUpgradeAction, + ChompApiServiceGetIntentsByAddressAction, ChompApiServiceGetServiceDetailsAction, + ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; +import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { KeyringControllerSignEip7702AuthorizationAction, KeyringControllerSignPersonalMessageAction, @@ -18,13 +27,22 @@ import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; +import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountUpgradeController-method-action-types'; import { associateAddressStep } from './steps/associate-address'; +import { buildDelegationStep } from './steps/build-delegations'; import { eip7702AuthorizationStep } from './steps/eip-7702-authorization'; +import { registerIntentsStep } from './steps/register-intents'; import type { Step } from './steps/step'; -import type { InitConfig } from './types'; +import type { UpgradeConfig } from './types'; + +/** + * The Delegation Framework deployment version we resolve contract addresses + * against in `@metamask/delegation-deployments`. + */ +const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; export const controllerName = 'MoneyAccountUpgradeController'; @@ -46,9 +64,15 @@ export type MoneyAccountUpgradeControllerActions = | MoneyAccountUpgradeControllerMethodActions; type AllowedActions = + | AuthenticatedUserStorageServiceCreateDelegationAction + | AuthenticatedUserStorageServiceListDelegationsAction | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateIntentsAction | ChompApiServiceCreateUpgradeAction + | ChompApiServiceGetIntentsByAddressAction | ChompApiServiceGetServiceDetailsAction + | ChompApiServiceVerifyDelegationAction + | DelegationControllerSignDelegationAction | KeyringControllerSignEip7702AuthorizationAction | KeyringControllerSignPersonalMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction @@ -79,9 +103,14 @@ export class MoneyAccountUpgradeController extends BaseController< MoneyAccountUpgradeControllerState, MoneyAccountUpgradeControllerMessenger > { - #config?: { chainId: Hex; delegatorImplAddress: Hex }; + #config?: UpgradeConfig & { chainId: Hex }; - readonly #steps: Step[] = [associateAddressStep, eip7702AuthorizationStep]; + readonly #steps: Step[] = [ + associateAddressStep, + eip7702AuthorizationStep, + buildDelegationStep, + registerIntentsStep, + ]; /** * Constructor for the MoneyAccountUpgradeController. @@ -109,12 +138,31 @@ export class MoneyAccountUpgradeController extends BaseController< /** * Fetches service details and validates the controller can operate on the - * given chain. + * given chain. Resolves the Delegation Framework contract addresses for the + * chain from `@metamask/delegation-deployments`. * - * @param chainId - The chain to initialize for. - * @param initConfig - Contract addresses not available from the service details API. + * @param params - The parameters for initialization. + * @param params.chainId - The chain to initialize for. + * @param params.boringVaultAddress - The Veda boring vault contract + * (vmUSD) for the given chain. Used as the withdrawal-side delegation + * token. Supplied by the consumer until the CHOMP service-details API + * exposes it. */ - async init(chainId: Hex, initConfig: InitConfig): Promise { + async init({ + chainId, + boringVaultAddress, + }: { + chainId: Hex; + boringVaultAddress: Hex; + }): Promise { + const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][hexToNumber(chainId)]; + if (!contracts) { + throw new Error( + `Delegation Framework ${DELEGATION_FRAMEWORK_VERSION} is not deployed on chain ${chainId}`, + ); + } + const response = await this.messenger.call( 'ChompApiService:getServiceDetails', [chainId], @@ -140,7 +188,14 @@ export class MoneyAccountUpgradeController extends BaseController< this.#config = { chainId, - delegatorImplAddress: initConfig.delegatorImplAddress, + delegateAddress: chain.autoDepositDelegate, + musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, + boringVaultAddress, + vedaVaultAdapterAddress: vedaProtocol.adapterAddress, + delegatorImplAddress: contracts.EIP7702StatelessDeleGatorImpl, + erc20TransferAmountEnforcer: contracts.ERC20TransferAmountEnforcer, + redeemerEnforcer: contracts.RedeemerEnforcer, + valueLteEnforcer: contracts.ValueLteEnforcer, }; } diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts index ffa40fc101..26d32b8711 100644 --- a/packages/money-account-upgrade-controller/src/index.ts +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -1,4 +1,4 @@ -export type { InitConfig, UpgradeConfig } from './types'; +export type { UpgradeConfig } from './types'; export { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController'; export type { MoneyAccountUpgradeControllerState, diff --git a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts index b141dc0ef4..da1b8491e3 100644 --- a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts @@ -11,7 +11,15 @@ import { associateAddressStep } from './associate-address'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); @@ -64,6 +72,24 @@ function setup(): { return { messenger, mocks }; } +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + describe('associateAddressStep', () => { beforeEach(() => { jest.useFakeTimers(); @@ -81,12 +107,7 @@ describe('associateAddressStep', () => { it('signs the CHOMP Authentication message with the given address', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + await run(messenger); expect(mocks.signPersonalMessage).toHaveBeenCalledWith({ data: `CHOMP Authentication ${MOCK_NOW}`, @@ -97,12 +118,7 @@ describe('associateAddressStep', () => { it('submits the signature, timestamp, and address to the CHOMP API', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + await run(messenger); expect(mocks.associateAddress).toHaveBeenCalledWith({ signature: MOCK_SIGNATURE, @@ -114,12 +130,7 @@ describe('associateAddressStep', () => { it('returns "completed" when CHOMP creates the association', async () => { const { messenger } = setup(); - const result = await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + const result = await run(messenger); expect(result).toBe('completed'); }); @@ -131,12 +142,7 @@ describe('associateAddressStep', () => { status: 'active', }); - const result = await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + const result = await run(messenger); expect(result).toBe('already-done'); }); @@ -145,14 +151,7 @@ describe('associateAddressStep', () => { const { messenger, mocks } = setup(); mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); - await expect( - associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }), - ).rejects.toThrow('signing failed'); + await expect(run(messenger)).rejects.toThrow('signing failed'); expect(mocks.associateAddress).not.toHaveBeenCalled(); }); @@ -160,13 +159,6 @@ describe('associateAddressStep', () => { const { messenger, mocks } = setup(); mocks.associateAddress.mockRejectedValue(new Error('api failed')); - await expect( - associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }), - ).rejects.toThrow('api failed'); + await expect(run(messenger)).rejects.toThrow('api failed'); }); }); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts new file mode 100644 index 0000000000..dc383b4c1d --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -0,0 +1,579 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { buildDelegationStep } from './build-delegations'; + +jest.mock('@metamask/delegation-core', () => ({ + ROOT_AUTHORITY: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + createERC20TransferAmountTerms: jest.fn(), + createRedeemerTerms: jest.fn(), + createValueLteTerms: jest.fn(), + hashDelegation: jest.fn(), +})); + +const mockCreateErc20Terms = jest.mocked(createERC20TransferAmountTerms); +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); +const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); +const mockHashDelegation = jest.mocked(hashDelegation); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; +const MOCK_SIGNATURE: Hex = `0x${'cd'.repeat(65)}`; + +const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; +const MOCK_MUSD_ERC20_TERMS: Hex = '0xa2'; +const MOCK_VMUSD_ERC20_TERMS: Hex = '0xa4'; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; + +type ExpectedCaveat = { enforcer: Hex; terms: Hex; args: '0x' }; +const expectedCaveats = (erc20Terms: Hex): ExpectedCaveat[] => [ + { + enforcer: MOCK_VALUE_LTE_ENFORCER, + terms: MOCK_VALUE_LTE_TERMS, + args: '0x', + }, + { enforcer: MOCK_ERC20_ENFORCER, terms: erc20Terms, args: '0x' }, + { enforcer: MOCK_REDEEMER_ENFORCER, terms: MOCK_REDEEMER_TERMS, args: '0x' }, +]; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry, + * defaulting every identifying field to the deposit-side delegation, and + * including a redeemer caveat that points at the Veda vault adapter. 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. + * @param overrides.caveats - The caveats attached to the delegation. Defaults + * to a single redeemer caveat targeting the Veda vault adapter. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + caveats?: { enforcer: Hex; terms: Hex; args: Hex }[]; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: ROOT_AUTHORITY as Hex, + caveats: overrides.caveats ?? [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: MOCK_REDEEMER_TERMS, + args: '0x', + }, + ], + salt: `0x${'42'.repeat(32)}`, + signature: '0x' as Hex, + }, + metadata: { + delegationHash: `0x${'ab'.repeat(32)}`, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: '0x00', + tokenSymbol: 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: 'lend', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + createDelegation: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest.fn().mockResolvedValue([]), + signDelegation: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + createDelegation: jest.fn().mockResolvedValue(undefined), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return buildDelegationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('buildDelegationStep', () => { + beforeEach(() => { + // The term creators are overloaded over output encoding; the runtime path + // picks the hex overload, but `jest.mocked()` picks the bytes overload, so + // cast through `never` to satisfy both. + mockCreateValueLteTerms.mockReturnValue(MOCK_VALUE_LTE_TERMS as never); + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + // Return a different ERC20 terms blob per token so tests can tell which + // delegation was signed when. + mockCreateErc20Terms.mockImplementation((({ + tokenAddress, + }: { + tokenAddress: Hex; + }) => + tokenAddress === MOCK_MUSD + ? MOCK_MUSD_ERC20_TERMS + : MOCK_VMUSD_ERC20_TERMS) as never); + // Distinguish the two delegations by call order — the run loop signs + // mUSD first, then vmUSD, so the first hashDelegation call corresponds to + // mUSD. + mockHashDelegation + .mockReturnValueOnce(MOCK_MUSD_DELEGATION_HASH as never) + .mockReturnValueOnce(MOCK_VMUSD_DELEGATION_HASH as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "build-delegation"', () => { + expect(buildDelegationStep.name).toBe('build-delegation'); + }); + + describe('when neither delegation exists in storage', () => { + it('signs and submits both delegations, deposit (mUSD) before withdrawal (vmUSD)', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(2); + + const signedTokens = mocks.signDelegation.mock.calls.map( + ([{ delegation }]) => delegation.caveats[1].terms, + ); + expect(signedTokens).toStrictEqual([ + MOCK_MUSD_ERC20_TERMS, + MOCK_VMUSD_ERC20_TERMS, + ]); + }); + + it('encodes each caveat against the right enforcer addresses for each token', async () => { + const { messenger } = setup(); + + await run(messenger); + + // valueLte and redeemer share configuration across both delegations. + expect(mockCreateValueLteTerms).toHaveBeenCalledWith({ maxValue: 0n }); + expect(mockCreateRedeemerTerms).toHaveBeenCalledWith({ + redeemers: [MOCK_VAULT_ADAPTER], + }); + // erc20TransferAmount is per-token. + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_MUSD, + maxAmount: 2n ** 256n - 1n, + }); + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_BORING_VAULT, + maxAmount: 2n ** 256n - 1n, + }); + }); + + it('hands each unsigned delegation to DelegationController:signDelegation, scoped to the chain, with a fresh 32-byte salt', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.signDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, delegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(delegation.delegate).toBe(MOCK_DELEGATE); + expect(delegation.delegator).toBe(MOCK_ADDRESS); + expect(delegation.authority).toBe(ROOT_AUTHORITY); + expect(delegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(delegation).not.toHaveProperty('signature'); + } + + expect(first.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + // Salts are independent per delegation. + expect(first.delegation.salt).not.toBe(second.delegation.salt); + }); + + it('submits each signed delegation to ChompApiService:verifyDelegation', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.verifyDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, signedDelegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(signedDelegation.delegate).toBe(MOCK_DELEGATE); + expect(signedDelegation.delegator).toBe(MOCK_ADDRESS); + expect(signedDelegation.authority).toBe(ROOT_AUTHORITY); + expect(signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(signedDelegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + } + + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + }); + + it('persists each delegation via AuthenticatedUserStorageService:createDelegation, with deposit/withdrawal metadata', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(2); + const [first, second] = mocks.createDelegation.mock.calls.map( + ([submission]) => submission, + ); + + // Each submission carries the same signed-delegation as the + // corresponding verifyDelegation call. + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(first.signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.signature).toBe(MOCK_SIGNATURE); + + expect(first.metadata).toStrictEqual({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }); + expect(second.metadata).toStrictEqual({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }); + }); + + it('hashes each signed delegation (with bigint salt) before persisting it', async () => { + const { messenger } = setup(); + + await run(messenger); + + expect(mockHashDelegation).toHaveBeenCalledTimes(2); + // Each hashDelegation call should receive a delegation whose salt is a + // bigint (delegation-core's expectation), not a hex string. + for (const [delegationStruct] of mockHashDelegation.mock.calls) { + expect(typeof delegationStruct.salt).toBe('bigint'); + expect(delegationStruct.signature).toBe(MOCK_SIGNATURE); + } + }); + }); + + describe('when only one delegation already exists', () => { + it('signs, submits, and persists only the missing withdrawal delegation when the deposit one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_VMUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_BORING_VAULT); + expect(submission.metadata.type).toBe('cash-withdrawal'); + }); + + it('signs, submits, and persists only the missing deposit delegation when the withdrawal one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_MUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_MUSD); + expect(submission.metadata.type).toBe('cash-deposit'); + }); + }); + + describe('when both delegations already exist', () => { + it('returns "already-done" without signing, submitting, or persisting', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('matches addresses, chainId, and tokenAddress case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD.toUpperCase() as Hex, + }), + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + + it('ignores entries that differ on any identifying field', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // Same token but wrong delegator/delegate/chain. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegator: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegate: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + chainIdHex: OTHER_CHAIN_ID, + }), + // Unrelated token. + makeDelegationResponse({ tokenAddress: OTHER_TOKEN }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + + it('ignores entries that do not carry a redeemer caveat targeting the Veda vault adapter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // No caveats at all. + makeDelegationResponse({ tokenAddress: MOCK_MUSD, caveats: [] }), + // Right enforcer, wrong terms (different redeemer encoded). + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + caveats: [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: '0xdeadbeef', + args: '0x', + }, + ], + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + }); + + describe('when CHOMP rejects a delegation', () => { + it('throws with the joined error list', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ + valid: false, + errors: ['caveat mismatch', 'unknown enforcer'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: caveat mismatch, unknown enforcer', + ); + }); + + it('throws with a default message when CHOMP returns no errors', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ valid: false }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: unknown error', + ); + }); + + it('does not attempt the second delegation, and does not persist, if the first one is rejected', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValueOnce({ + valid: false, + errors: ['nope'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: nope', + ); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not sign or submit anything', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from signing and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.signDelegation.mockRejectedValue(new Error('signing failed')); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from verifyDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from createDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.createDelegation.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts new file mode 100644 index 0000000000..3edf32fe29 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -0,0 +1,204 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { add0x, bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { + equalsIgnoreCase, + makeHasVedaRedeemerCaveat, +} from './delegation-matchers'; +import type { Step } from './step'; + +const MAX_UINT256 = 2n ** 256n - 1n; +const MAX_UINT256_HEX: Hex = add0x(MAX_UINT256.toString(16)); + +/** + * Builds, signs, verifies (with CHOMP), and persists a single auto-deposit + * delegation for the given token. Both the deposit (mUSD) and withdrawal + * (vmUSD / boring vault) delegations share this shape; only the token + * address, symbol, and metadata `type` differ. + * + * @param params - The parameters for building the delegation. + * @param params.messenger - The messenger to call signing/verifying actions on. + * @param params.address - The delegator (the Money Account being upgraded). + * @param params.chainId - The chain to scope the delegation to. + * @param params.delegateAddress - CHOMP's delegate. + * @param params.tokenAddress - The token the delegation authorises transfers of. + * @param params.tokenSymbol - Symbol stored in the delegation metadata (e.g. "mUSD"). + * @param params.delegationType - Storage metadata `type` field; matches CHOMP's intent type. + * @param params.vedaVaultAdapterAddress - The redeemer (Veda vault adapter). + * @param params.erc20TransferAmountEnforcer - The ERC20TransferAmountEnforcer contract. + * @param params.redeemerEnforcer - The RedeemerEnforcer contract. + * @param params.valueLteEnforcer - The ValueLteEnforcer contract. + */ +async function signAndStoreDelegation(params: { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; + chainId: Hex; + delegateAddress: Hex; + tokenAddress: Hex; + tokenSymbol: string; + delegationType: 'cash-deposit' | 'cash-withdrawal'; + vedaVaultAdapterAddress: Hex; + erc20TransferAmountEnforcer: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; +}): Promise { + const { + messenger, + address, + chainId, + delegateAddress, + tokenAddress, + tokenSymbol, + delegationType, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + } = params; + + const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const salt = bytesToHex(saltBytes); + + const delegation = { + delegate: delegateAddress, + delegator: address, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x' as Hex, + }, + { + enforcer: erc20TransferAmountEnforcer, + terms: createERC20TransferAmountTerms({ + tokenAddress, + maxAmount: MAX_UINT256, + }), + args: '0x' as Hex, + }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), + args: '0x' as Hex, + }, + ], + salt, + }; + + const signature = (await messenger.call( + 'DelegationController:signDelegation', + { delegation, chainId }, + )) as Hex; + + const signedDelegation = { ...delegation, signature }; + + const result = await messenger.call('ChompApiService:verifyDelegation', { + signedDelegation, + chainId, + }); + + if (!result.valid) { + throw new Error( + `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, + ); + } + + const delegationHash = hashDelegation({ + ...delegation, + salt: BigInt(salt), + signature, + }); + + await messenger.call('AuthenticatedUserStorageService:createDelegation', { + signedDelegation, + metadata: { + delegationHash, + chainIdHex: chainId, + allowance: MAX_UINT256_HEX, + tokenSymbol, + tokenAddress, + type: delegationType, + }, + }); +} + +export const buildDelegationStep: Step = { + name: 'build-delegation', + async run({ + messenger, + address, + chainId, + boringVaultAddress, + delegateAddress, + erc20TransferAmountEnforcer, + musdTokenAddress, + redeemerEnforcer, + valueLteEnforcer, + vedaVaultAdapterAddress, + }) { + const existingDelegations = await messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + + const hasVedaRedeemerCaveat = makeHasVedaRedeemerCaveat( + redeemerEnforcer, + vedaVaultAdapterAddress, + ); + + 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) && + hasVedaRedeemerCaveat(entry); + + // The deposit delegation authorises transfers of mUSD (delegator → vault); + // the withdrawal delegation authorises transfers of vmUSD (vault share + // token → adapter, which redeems back to mUSD). + const delegations = [ + { + tokenAddress: musdTokenAddress, + tokenSymbol: 'mUSD', + delegationType: 'cash-deposit' as const, + }, + { + tokenAddress: boringVaultAddress, + tokenSymbol: 'vmUSD', + delegationType: 'cash-withdrawal' as const, + }, + ]; + + let didWork = false; + for (const config of delegations) { + if (existingDelegations.some(matches(config.tokenAddress))) { + continue; + } + await signAndStoreDelegation({ + messenger, + address, + chainId, + delegateAddress, + ...config, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + }); + didWork = true; + } + + return didWork ? 'completed' : 'already-done'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts b/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts new file mode 100644 index 0000000000..0c78973d52 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts @@ -0,0 +1,32 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { createRedeemerTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +export const equalsIgnoreCase = (a: Hex, b: Hex): boolean => + a.toLowerCase() === b.toLowerCase(); + +/** + * Builds a predicate that matches stored delegations carrying a redeemer + * caveat targeting the Veda vault adapter — i.e. delegations we wrote for + * auto-deposit / auto-withdrawal. The expected terms blob is computed once + * and reused across calls. + * + * @param redeemerEnforcer - The RedeemerEnforcer contract address. + * @param vedaVaultAdapterAddress - The Veda vault adapter address that must + * be encoded as the sole redeemer. + * @returns A predicate over `DelegationResponse`. + */ +export const makeHasVedaRedeemerCaveat = ( + redeemerEnforcer: Hex, + vedaVaultAdapterAddress: Hex, +): ((entry: DelegationResponse) => boolean) => { + const expectedRedeemerTerms = createRedeemerTerms({ + redeemers: [vedaVaultAdapterAddress], + }); + return (entry) => + entry.signedDelegation.caveats.some( + (caveat) => + equalsIgnoreCase(caveat.enforcer, redeemerEnforcer) && + equalsIgnoreCase(caveat.terms, expectedRedeemerTerms), + ); +}; 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..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 @@ -12,7 +12,15 @@ import { eip7702AuthorizationStep } from './eip-7702-authorization'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) — non-trivial decimal const MOCK_CHAIN_ID_DECIMAL = parseInt(MOCK_CHAIN_ID, 16); +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; const MOCK_THIRD_PARTY_IMPL = '0x9999999999999999999999999999999999999999' as Hex; const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; @@ -142,7 +150,14 @@ async function run( messenger, address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, }); } @@ -312,6 +327,37 @@ 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.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 }) => { 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` 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..6a807cfe75 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts @@ -0,0 +1,509 @@ +import type { + DelegationResponse, + DelegationMetadata, +} from '@metamask/authenticated-user-storage'; +import type { IntentEntry } from '@metamask/chomp-api-service'; +import { createRedeemerTerms } from '@metamask/delegation-core'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { registerIntentsStep } from './register-intents'; + +jest.mock('@metamask/delegation-core', () => ({ + createRedeemerTerms: jest.fn(), +})); + +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); + +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)}`; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry. + * Defaults match the deposit-side delegation written by the build-delegation + * step, including a redeemer caveat that points at the Veda vault adapter. + * 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. + * @param overrides.caveats - The caveats attached to the delegation. Defaults + * to a single redeemer caveat targeting the Veda vault adapter. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + tokenSymbol?: string; + delegationHash?: Hex; + type?: DelegationMetadata['type']; + caveats?: { enforcer: Hex; terms: Hex; args: Hex }[]; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: `0x${'ff'.repeat(32)}`, + caveats: overrides.caveats ?? [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: MOCK_REDEEMER_TERMS, + args: '0x', + }, + ], + 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', () => { + beforeEach(() => { + // The terms factory is 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. + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + 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(); + }); + + it('ignores delegations whose caveats do not include a redeemer caveat targeting the Veda vault adapter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // No caveats at all. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + caveats: [], + }), + // Right enforcer, wrong terms (different redeemer encoded). + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + tokenSymbol: 'vmUSD', + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + type: 'cash-withdrawal', + caveats: [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: '0xdeadbeef', + args: '0x', + }, + ], + }), + ]); + + 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..8dd3d769ec --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.ts @@ -0,0 +1,101 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import type { + IntentEntry, + SendIntentParams, +} from '@metamask/chomp-api-service'; + +import { + equalsIgnoreCase, + makeHasVedaRedeemerCaveat, +} from './delegation-matchers'; +import type { Step } from './step'; + +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, + redeemerEnforcer, + vedaVaultAdapterAddress, + }) { + 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 hasVedaRedeemerCaveat = makeHasVedaRedeemerCaveat( + redeemerEnforcer, + vedaVaultAdapterAddress, + ); + + const needsIntent = (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + hasVedaRedeemerCaveat(entry) && + !activeIntentHashes.has(entry.metadata.delegationHash.toLowerCase()); + + const toIntent = (entry: DelegationResponse): SendIntentParams => ({ + account: address, + delegationHash: entry.metadata.delegationHash, + chainId, + metadata: { + allowance: entry.metadata.allowance, + tokenSymbol: entry.metadata.tokenSymbol, + tokenAddress: entry.metadata.tokenAddress, + type: parseIntentMetadataType(entry.metadata.type), + }, + }); + + const intents = delegations.filter(needsIntent).map(toIntent); + + if (intents.length === 0) { + return 'already-done'; + } + + await messenger.call('ChompApiService:createIntents', intents); + return 'completed'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index fa164d3354..9537119d8a 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -9,7 +9,14 @@ export type StepContext = { messenger: MoneyAccountUpgradeControllerMessenger; address: Hex; chainId: Hex; + boringVaultAddress: Hex; + delegateAddress: Hex; delegatorImplAddress: Hex; + erc20TransferAmountEnforcer: Hex; + musdTokenAddress: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; + vedaVaultAdapterAddress: Hex; }; /** diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts index c6a18dc179..db8db0ab26 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -1,18 +1,25 @@ import type { Hex } from '@metamask/utils'; /** - * Contract addresses and configuration required to perform the - * Money Account upgrade sequence. + * Configuration required to perform the Money Account upgrade sequence. + * + * `delegateAddress`, `musdTokenAddress`, and `vedaVaultAdapterAddress` come + * from the CHOMP service details API. `delegatorImplAddress` and the caveat + * enforcer addresses are resolved from `@metamask/delegation-deployments` for + * the target chain. (DelegationManager resolution is delegated to + * `@metamask/delegation-controller`, which handles delegation signing.) */ export type UpgradeConfig = { /** CHOMP's delegate address — receives the delegation. */ delegateAddress: Hex; - /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ - delegatorImplAddress: Hex; - /** The mUSD token contract address. */ + /** The mUSD token contract address (deposit-side delegation token). */ musdTokenAddress: Hex; + /** The Veda boring vault contract address (withdrawal-side delegation token, vmUSD). */ + boringVaultAddress: Hex; /** The Veda vault adapter contract address. */ vedaVaultAdapterAddress: Hex; + /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ + delegatorImplAddress: Hex; /** Address of the ERC20TransferAmountEnforcer caveat enforcer. */ erc20TransferAmountEnforcer: Hex; /** Address of the RedeemerEnforcer caveat enforcer. */ @@ -20,15 +27,3 @@ export type UpgradeConfig = { /** Address of the ValueLteEnforcer caveat enforcer. */ valueLteEnforcer: Hex; }; - -/** - * Configuration values passed to {@link MoneyAccountUpgradeController.init} - * that cannot be derived from the service details API. - */ -export type InitConfig = Pick< - UpgradeConfig, - | 'delegatorImplAddress' - | 'musdTokenAddress' - | 'redeemerEnforcer' - | 'valueLteEnforcer' ->; diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json index b69bb81cca..033cb7d8b0 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -6,8 +6,10 @@ "rootDir": "./src" }, "references": [ + { "path": "../authenticated-user-storage/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../chomp-api-service/tsconfig.build.json" }, + { "path": "../delegation-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json index ffcde5ec67..7993854f44 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -4,8 +4,10 @@ "baseUrl": "./" }, "references": [ + { "path": "../authenticated-user-storage" }, { "path": "../base-controller" }, { "path": "../chomp-api-service" }, + { "path": "../delegation-controller" }, { "path": "../keyring-controller" }, { "path": "../messenger" }, { "path": "../network-controller" }, diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2ee6bdaba2..73ce879b0d 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8759](https://github.com/MetaMask/core/pull/8759)) + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- `estimateGasBatch` now falls back to the sum of per-tx `gas` values in the EIP-7702 path when node simulation fails, instead of returning the block-gas-limit fallback ([#8735](https://github.com/MetaMask/core/pull/8735)) + - Real 7702 simulation is still preferred when it succeeds (the bundled call has no per-tx intrinsic gas cost so the estimate is typically tighter than summing per-tx limits). + - Required for callers that submit batches whose individual sub-calls cannot be simulated standalone, for example predict-withdraw, where the batch's first sub-call (`Safe.execTransaction`) provides source-token balance to subsequent sub-calls (approve + swap), and simulating the relay-only sub-calls in isolation reverts because the EOA has no balance until the Safe sub-call has run. ## [65.3.0] diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 0f07ae1443..541dd360d1 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -845,6 +845,11 @@ export enum TransactionType { */ predictAcrossDeposit = 'predictAcrossDeposit', + /** + * Withdraw funds for Across quote via Predict. + */ + predictAcrossWithdraw = 'predictAcrossWithdraw', + /** * Buy a position via Predict. * diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index 89af467dfd..b17f6e07df 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -23,6 +23,7 @@ import { addGasBuffer, estimateGas, estimateGasBatch, + getProvidedBatchGasLimits, updateGas, FIXED_GAS, DEFAULT_GAS_MULTIPLIER, @@ -1325,6 +1326,116 @@ describe('gas', () => { }); }); + it('prefers 7702 simulated gas over provided gas when simulation succeeds', async () => { + // The bundled 7702 call has no per-tx intrinsic gas cost so the + // simulated estimate is typically lower than the sum of provided per-tx + // limits — prefer it when available. + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasResponse: toHex(GAS_MOCK), + }); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: GAS_MOCK, + gasLimits: [GAS_MOCK], + }); + }); + + it('falls back to provided gas in 7702 path when simulation fails', async () => { + // Callers that submit batches whose individual sub-calls cannot be + // simulated standalone (e.g. predict-withdraw, where the batch's first + // sub-call provides token balance to the rest) rely on this fallback — + // otherwise simulation reverts and falls back to the block gas limit. + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasError: new Error('execution reverted'), + }); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: 521000, + gasLimits: [521000], + }); + }); + + it('preserves requiresAuthorizationList when 7702 fallback fires for upgrade-required account', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: false, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasError: new Error('execution reverted'), + }); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: 521000, + gasLimits: [521000], + requiresAuthorizationList: true, + }); + }); + it('throws error when upgrade contract address not found', async () => { const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ { @@ -1490,6 +1601,48 @@ describe('gas', () => { }); }); + describe('getProvidedBatchGasLimits', () => { + it('returns parsed limits + sum when every transaction has a gas value', () => { + expect( + getProvidedBatchGasLimits(BATCH_TX_PARAMS_WITH_GAS_MOCK), + ).toStrictEqual({ + gasLimits: [21000, 500000], + totalGasLimit: 521000, + }); + }); + + it('returns undefined when none of the transactions have gas', () => { + expect(getProvidedBatchGasLimits(BATCH_TX_PARAMS_MOCK)).toBeUndefined(); + }); + + it('returns undefined when only some transactions have gas', () => { + const mixed = [BATCH_TX_PARAMS_WITH_GAS_MOCK[0], BATCH_TX_PARAMS_MOCK[0]]; + expect(getProvidedBatchGasLimits(mixed)).toBeUndefined(); + }); + + it('parses hex gas values correctly', () => { + const txWithHexGas = [ + { ...BATCH_TX_PARAMS_MOCK[0], gas: '0x5208' as Hex }, + { ...BATCH_TX_PARAMS_MOCK[1], gas: '0x7a120' as Hex }, + ]; + expect(getProvidedBatchGasLimits(txWithHexGas)).toStrictEqual({ + gasLimits: [21000, 500000], + totalGasLimit: 521000, + }); + }); + + it('returns zero-length result for an empty batch', () => { + // `every` on empty array returns true, so the function returns a valid + // (but empty) result rather than `undefined`. Callers of `estimateGasBatch` + // always pass at least one transaction, so this is documenting current + // behaviour rather than a guarantee. + expect(getProvidedBatchGasLimits([])).toStrictEqual({ + gasLimits: [], + totalGasLimit: 0, + }); + }); + }); + describe('simulateGasBatch', () => { it('returns the total gas limit as a hex string', async () => { simulateTransactionsMock.mockResolvedValueOnce( diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index 4bf791ccc0..9cf9e7bbf0 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -208,6 +208,39 @@ export async function estimateGas({ }; } +/** + * Sum caller-provided gas limits across a batch. + * + * If every transaction in the batch already has a `gas` value, returns the + * parsed per-tx limits and their sum. Otherwise returns `undefined`. + * + * Used by `estimateGasBatch`: + * - non-7702 path: short-circuits simulation entirely when present. + * - EIP-7702 path: used as a fallback when simulation fails — required for + * callers that submit batches whose individual sub-calls cannot be simulated + * standalone (e.g. predict-withdraw, where the batch's first sub-call + * provides source-token balance to subsequent sub-calls). When 7702 + * simulation succeeds it is preferred since the bundled call has no per-tx + * intrinsic gas cost and produces a tighter estimate. + * + * @param transactions - Batch transactions to inspect. + * @returns Parsed gas limits and total when every transaction has gas; otherwise `undefined`. + */ +export function getProvidedBatchGasLimits( + transactions: BatchTransactionParams[], +): { gasLimits: number[]; totalGasLimit: number } | undefined { + if (!transactions.every((transaction) => transaction.gas !== undefined)) { + return undefined; + } + + const gasLimits = transactions.map((transaction) => + new BigNumber(transaction.gas as Hex).toNumber(), + ); + const totalGasLimit = gasLimits.reduce((acc, gasLimit) => acc + gasLimit, 0); + + return { gasLimits, totalGasLimit }; +} + export async function estimateGasBatch({ from, getSimulationConfig, @@ -245,6 +278,8 @@ export async function estimateGasBatch({ } if (chainResult) { + const providedBatchGasLimits = getProvidedBatchGasLimits(transactions); + const authorizationList = isUpgradeRequired ? [{ address: chainResult.upgradeContractAddress as Hex }] : undefined; @@ -260,7 +295,12 @@ export async function estimateGasBatch({ type, }; - const { estimatedGas: gasLimitHex } = await estimateGas({ + // Prefer real EIP-7702 simulation when it succeeds — the bundled call has + // no per-tx intrinsic gas cost so the estimate is typically lower than + // summing per-tx provided limits. Fall back to the provided sum when the + // node-level simulation fails (e.g. predict-withdraw, where the batch's + // first sub-call provides source-token balance to subsequent sub-calls). + const { estimatedGas: gasLimitHex, simulationFails } = await estimateGas({ isSimulationEnabled: true, getSimulationConfig, messenger, @@ -268,6 +308,19 @@ export async function estimateGasBatch({ txParams: params, }); + if (simulationFails && providedBatchGasLimits) { + log( + 'EIP-7702 estimation failed, using batch parameter gas limits', + providedBatchGasLimits, + simulationFails, + ); + return { + gasLimits: [providedBatchGasLimits.totalGasLimit], + ...(isUpgradeRequired ? { requiresAuthorizationList: true } : {}), + totalGasLimit: providedBatchGasLimits.totalGasLimit, + }; + } + const totalGasLimit = new BigNumber(gasLimitHex).toNumber(); log('Estimated EIP-7702 gas limit', totalGasLimit); @@ -279,20 +332,10 @@ export async function estimateGasBatch({ }; } - const allTransactionsHaveGas = transactions.every( - (transaction) => transaction.gas !== undefined, - ); - - if (allTransactionsHaveGas) { - const gasLimits = transactions.map((transaction) => - new BigNumber(transaction.gas as Hex).toNumber(), - ); - - const total = gasLimits.reduce((acc, gasLimit) => acc + gasLimit, 0); - - log('Using batch parameter gas limits', { gasLimits, total }); - - return { totalGasLimit: total, gasLimits }; + const providedBatchGasLimits = getProvidedBatchGasLimits(transactions); + if (providedBatchGasLimits) { + log('Using batch parameter gas limits', providedBatchGasLimits); + return providedBatchGasLimits; } const { gasLimits: gasLimitsHex } = await simulateGasBatch({ diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 78213bb5a2..edae0d5fbb 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,9 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.3.0] + +### Added + +- Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) +- Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.0.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/bridge-status-controller` from `^71.1.2` to `^71.1.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) + +### Fixed + +- Predict same-chain withdraw quote no longer falls back to block-gas-limit (~30M+) on swap-only Relay routes ([#8735](https://github.com/MetaMask/core/pull/8735)) + - `fromOverride = Safe proxy` is now gated on the route having a `deposit` step. Same-chain destinations route through DEX swap aggregators that reject contract callers (anti-MEV `msg.sender == tx.origin` checks etc.) — for those, the relay params' EOA `from` is used so simulation succeeds. + - Gas-fee-token lookup still uses the Safe proxy for ALL Predict withdraws (gated on `isPredictWithdraw && refundTo`), preserving the gasless flow for users who hold pUSD in the Safe but no native POL on the EOA. +- Fix post-quote relay submission when `accountOverride` is set by replacing the prepended original transaction with a delegation transaction so the override account can submit it ([#8615](https://github.com/MetaMask/core/pull/8615)) ## [22.2.0] @@ -838,7 +856,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.3.0...HEAD +[22.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...@metamask/transaction-pay-controller@22.3.0 [22.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.1.0...@metamask/transaction-pay-controller@22.2.0 [22.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.2...@metamask/transaction-pay-controller@22.1.0 [22.0.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.1...@metamask/transaction-pay-controller@22.0.2 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index c69f20d773..9e620f5e1f 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "22.2.0", + "version": "22.3.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -57,11 +57,11 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^7.0.1", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controller": "^7.1.0", + "@metamask/assets-controllers": "^107.0.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.0.2", - "@metamask/bridge-status-controller": "^71.1.2", + "@metamask/bridge-controller": "^72.0.3", + "@metamask/bridge-status-controller": "^71.1.3", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/messenger": "^1.2.0", diff --git a/packages/transaction-pay-controller/src/constants.test.ts b/packages/transaction-pay-controller/src/constants.test.ts new file mode 100644 index 0000000000..5bb589f50c --- /dev/null +++ b/packages/transaction-pay-controller/src/constants.test.ts @@ -0,0 +1,27 @@ +import { + CHAIN_ID_POLYGON, + POLYGON_PUSD_ADDRESS, + POLYGON_USDCE_ADDRESS, + STABLECOINS, +} from './constants'; + +describe('STABLECOINS', () => { + it('includes both Polygon USDC.e and Polymarket pUSD as Polygon stablecoins', () => { + // pUSD is treated as a USD-pegged stablecoin so post-quote display logic + // uses currencyOut.amountFormatted (1:1 USD) instead of going through + // the USD-rate API. Without pUSD in this list, predict-withdraw quote + // displays would round-trip through fiat conversion needlessly. + const polygonStablecoins = STABLECOINS[CHAIN_ID_POLYGON]; + + expect(polygonStablecoins).toContain(POLYGON_USDCE_ADDRESS.toLowerCase()); + expect(polygonStablecoins).toContain(POLYGON_PUSD_ADDRESS.toLowerCase()); + }); + + it('lower-cases all stablecoin entries for case-insensitive lookup', () => { + for (const [, addresses] of Object.entries(STABLECOINS)) { + for (const address of addresses) { + expect(address).toBe(address.toLowerCase()); + } + } + }); +}); diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index c740bdbd6d..52e39b0efb 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -15,6 +15,9 @@ export const ARBITRUM_USDC_ADDRESS = export const POLYGON_USDCE_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Hex; +export const POLYGON_PUSD_ADDRESS = + '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; + export const HYPERCORE_USDC_ADDRESS = '0x00000000000000000000000000000000'; export const HYPERCORE_USDC_DECIMALS = 8; @@ -38,7 +41,10 @@ export const STABLECOINS: Record = { '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC '0xa219439258ca9da29e9cc4ce5596924745e12b93', // USDT ], - [CHAIN_ID_POLYGON]: [POLYGON_USDCE_ADDRESS.toLowerCase() as Hex], + [CHAIN_ID_POLYGON]: [ + POLYGON_USDCE_ADDRESS.toLowerCase() as Hex, + POLYGON_PUSD_ADDRESS.toLowerCase() as Hex, + ], [CHAIN_ID_HYPERCORE]: [HYPERCORE_USDC_ADDRESS], // USDC }; diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index efe6eb6234..6539a87c93 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -197,6 +197,60 @@ describe('AcrossStrategy', () => { ).toBe(true); }); + it('supports post-quote predict withdraw requests with source-chain authorization lists', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + data: '0x12345678' as Hex, + to: '0xdef' as Hex, + }, + } as TransactionMeta, + requests: [ + { + from: '0xabc' as Hex, + isPostQuote: true, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(true); + }); + + it('does not support post-quote requests outside predict withdraw', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + requests: [ + { + from: '0xabc' as Hex, + isPostQuote: true, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(false); + }); + it('returns false for unsupported perps deposits', () => { const strategy = new AcrossStrategy(); expect( diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index c54cde6187..fc141acfa0 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -8,9 +8,11 @@ import type { TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { getAcrossDestination } from './across-actions'; import { getAcrossQuotes } from './across-quotes'; import { submitAcrossQuotes } from './across-submit'; +import { hasUnsupportedTransactionAuthorizationList } from './authorization-list'; import { isSupportedAcrossPerpsDepositRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; import type { AcrossQuote } from './types'; @@ -52,15 +54,20 @@ export class AcrossStrategy implements PayStrategy { } } - // Across cannot submit EIP-7702 authorization lists. This pre-quote check - // catches transactions where the authorization list is already present. - // First-time 7702 upgrades discovered during gas planning are handled in - // `checkQuoteSupport` below. - if (request.transaction.txParams?.authorizationList?.length) { + if ( + hasUnsupportedTransactionAuthorizationList( + request.transaction, + actionableRequests, + ) + ) { return false; } return actionableRequests.every((singleRequest) => { + if (singleRequest.isPostQuote) { + return isPredictWithdrawTransaction(request.transaction); + } + try { getAcrossDestination(request.transaction, singleRequest); return true; diff --git a/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts b/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts new file mode 100644 index 0000000000..c157098458 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts @@ -0,0 +1,30 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { QuoteRequest } from '../../types'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; + +/** + * Check whether an authorization list on the original transaction is unsupported by Across. + * + * Predict withdraw post-quote requests do not use Across destination actions; + * the original withdrawal is submitted by MetaMask on the source chain before + * the Across deposit leg. That keeps a source-chain authorization list out of + * Across' post-swap action payload. + * + * @param transaction - Original transaction metadata. + * @param requests - Across quote requests. + * @returns `true` if the authorization list should block Across. + */ +export function hasUnsupportedTransactionAuthorizationList( + transaction: TransactionMeta, + requests: QuoteRequest[], +): boolean { + if (!transaction.txParams?.authorizationList?.length) { + return false; + } + + return ( + !isPredictWithdrawTransaction(transaction) || + requests.some((request) => request.isPostQuote !== true) + ); +} diff --git a/packages/transaction-pay-controller/src/strategy/across/requests.ts b/packages/transaction-pay-controller/src/strategy/across/requests.ts index 77b967af56..f662dc7ae6 100644 --- a/packages/transaction-pay-controller/src/strategy/across/requests.ts +++ b/packages/transaction-pay-controller/src/strategy/across/requests.ts @@ -3,6 +3,7 @@ import type { QuoteRequest } from '../../types'; export function isAcrossQuoteRequest(request: QuoteRequest): boolean { return ( request.isMaxAmount === true || + request.isPostQuote === true || (request.targetAmountMinimum !== undefined && request.targetAmountMinimum !== '0') ); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index f5d7c72a2f..337b829e1d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -83,7 +83,7 @@ const QUOTE_REQUEST_MOCK: QuoteRequest = { }; const STEP_MOCK: RelayTransactionStep = { - id: 'swap', + id: 'deposit', requestId: '0x1', kind: 'transaction', items: [ @@ -1538,6 +1538,93 @@ describe('Relay Quotes Utils', () => { ); }); + it('uses original (EOA) from for predictWithdraw post-quote when route has no deposit step', async () => { + // Same-chain swap routes (e.g. Polygon pUSD -> Polygon USDC) only emit + // `approve` + `swap` steps. Simulating from the Safe proxy reverts in + // the swap step because DEX aggregators reject contract callers, so the + // override must be skipped and the relay params' EOA `from` used instead. + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps = [ + { ...STEP_MOCK, id: 'approve' }, + { ...STEP_MOCK, id: 'swap' }, + ]; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 100000, + gasLimits: [50000, 50000], + }); + + const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + refundTo: proxyAddress, + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + }); + + it('still uses Safe proxy for gas-fee-token lookup on swap-only predictWithdraw routes', async () => { + // The gas-fee-token lookup must always use the Safe proxy for Predict + // withdraws (because the source token lives in the Safe, not the EOA), + // even when the gas-estimation path falls back to the EOA `from`. + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps = [ + { ...STEP_MOCK, id: 'approve' }, + { ...STEP_MOCK, id: 'swap' }, + ]; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 100000, + gasLimits: [50000, 50000], + }); + + getTokenBalanceMock.mockReturnValue('0'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + refundTo: proxyAddress, + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ from: proxyAddress }), + ); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + it('sets isSourceGasFeeToken for predictWithdraw post-quote when insufficient native balance', async () => { successfulFetchMock.mockResolvedValue({ ok: true, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 14b95c089d..7d639eea2d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -669,7 +669,17 @@ async function calculateSourceNetworkCost( const isPredictWithdraw = request.isPostQuote && isPredictWithdrawTransaction(transaction); - const fromOverride = isPredictWithdraw ? request.refundTo : undefined; + // `fromOverride = Safe proxy` is only valid for deposit-style Relay routes + // where the deposit contract reads the user's source-token balance directly. + // Same-chain destinations route through DEX swap aggregators that frequently + // reject contract callers (anti-MEV `msg.sender == tx.origin` checks, + // ERC777-style callback interfaces, native wrap/unwrap requiring caller + // native balance). Simulating those from the Safe proxy reverts and breaks + // gas estimation. For swap-only routes, fall back to the relay params' + // EOA `from` so simulation succeeds. + const hasDepositStep = quote.steps.some((step) => step.id === 'deposit'); + const useFromOverride = isPredictWithdraw && hasDepositStep; + const fromOverride = useFromOverride ? request.refundTo : undefined; const relayOnlyGas = await calculateSourceNetworkGasLimit( relayParams, @@ -745,6 +755,13 @@ async function calculateSourceNetworkCost( max: max.raw, }); + // Gas-fee-token lookup must use the Safe proxy for ALL Predict withdraws, + // not only deposit-style routes. The user's source token (pUSD) lives in + // the Safe; the EOA is empty until the Safe.execTransaction sub-call runs + // mid-batch. Querying the EOA for gas-fee-token availability would always + // return nothing and force users to hold POL. + // (`useFromOverride` only governs the gas-estimation `from` address, where + // swap-style routes need EOA because DEX routers reject contract callers.) if (isPredictWithdraw && request.refundTo) { log('Using proxy address for predict withdraw gas station simulation', { proxyAddress: request.refundTo, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index f93a005c20..eb47e5a6a0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1013,6 +1013,73 @@ describe('Relay Submit Utils', () => { ); }); + describe('with accountOverride', () => { + const ACCOUNT_OVERRIDE_MOCK = '0xaccountOverride' as Hex; + const DELEGATION_TO_MOCK = '0xdelegationManager' as Hex; + const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; + const DELEGATION_VALUE_MOCK = '0x0' as Hex; + + beforeEach(() => { + request.quotes[0].request.from = ACCOUNT_OVERRIDE_MOCK; + getDelegationTransactionMock.mockResolvedValue({ + data: DELEGATION_DATA_MOCK, + to: DELEGATION_TO_MOCK, + value: DELEGATION_VALUE_MOCK, + }); + }); + + it('passes the original transaction through to getDelegationTransaction', async () => { + await submitRelayQuotes(request); + + expect(getDelegationTransactionMock).toHaveBeenCalledTimes(1); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + id: ORIGINAL_TRANSACTION_ID_MOCK, + txParams: expect.objectContaining({ + from: FROM_MOCK, + to: '0xrecipient', + data: '0xorigdata', + value: '0x100', + }), + }), + }); + }); + + it('uses the delegation result as the first batch tx', async () => { + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: ACCOUNT_OVERRIDE_MOCK, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + data: DELEGATION_DATA_MOCK, + to: DELEGATION_TO_MOCK, + value: DELEGATION_VALUE_MOCK, + }), + type: TransactionType.simpleSend, + }), + expect.objectContaining({ + params: expect.objectContaining({ + data: '0x1234', + to: '0xfedcb', + }), + type: TransactionType.relayDeposit, + }), + ], + }), + ); + }); + }); + + it('does not call getDelegationTransaction when accountOverride is not set', async () => { + await submitRelayQuotes(request); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + }); + it('activates 7702 mode with single combined post-quote gas limit', async () => { request.quotes[0].original.metamask.gasLimits = [203093]; request.quotes[0].original.metamask.is7702 = true; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 0082b6af66..53165cb4af 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -343,20 +343,29 @@ async function submitTransactions( // For post-quote flows, prepend the original transaction so it gets // included in the batch alongside the relay deposit(s). // This always results in multiple params, so it takes the batch path. + // When an accountOverride is set (detected by `from` divergence between the + // quote and the original tx), the override account does not directly hold + // the funds for the original call, so the prepended tx is replaced with a + // delegation tx that redeems the original call on its behalf. const { isPostQuote } = quote.request; - - const allParams = - isPostQuote && transaction.txParams.to - ? [ - { - data: transaction.txParams.data as Hex | undefined, - from: transaction.txParams.from, - to: transaction.txParams.to, - value: transaction.txParams.value as Hex | undefined, - } as TransactionParams, - ...normalizedParams, - ] - : normalizedParams; + const hasAccountOverride = + quote.request.from.toLowerCase() !== + (transaction.txParams.from as Hex).toLowerCase(); + + let allParams = normalizedParams; + + if (isPostQuote && transaction.txParams.to) { + const prependedParams = hasAccountOverride + ? await buildDelegatedOriginalParams(transaction, messenger) + : ({ + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams); + + allParams = [prependedParams, ...normalizedParams]; + } if (quote.original.metamask.isExecute) { return await submitViaRelayExecute( @@ -376,6 +385,38 @@ async function submitTransactions( ); } +/** + * Build TransactionParams for a delegation that redeems the original + * post-quote transaction on behalf of the override account. Used when the + * override account cannot execute the original call directly. + * + * The original tx is already on the correct chain and from the money + * account, so it can be passed through to `getDelegationTransaction` + * unchanged. + * + * @param transaction - Original transaction meta to be redeemed. + * @param messenger - Controller messenger. + * @returns Transaction params for the delegation tx. + */ +async function buildDelegatedOriginalParams( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction }, + ); + + log('Delegation result for prepended original tx', delegation); + + return { + data: delegation.data, + from: transaction.txParams.from as Hex, + to: delegation.to, + value: delegation.value, + }; +} + /** * Submit source transactions via Relay's /execute endpoint. * diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index fbee108053..f1e26a291f 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -123,8 +123,8 @@ export type TransactionConfig = { isPostQuote?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. Use this for post-quote flows where the user's funds originate * from a smart contract account (e.g. Predict Safe proxy) so that refunds * go back to that account rather than the EOA. @@ -224,8 +224,8 @@ export type TransactionData = { isHyperliquidSource?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. */ refundTo?: Hex; @@ -403,8 +403,8 @@ export type QuoteRequest = { isHyperliquidSource?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. */ refundTo?: Hex; diff --git a/yarn.lock b/yarn.lock index 584a18ddba..44e996f678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,7 +2768,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^7.0.1, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^7.1.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -2777,7 +2777,7 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.3.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controllers": "npm:^107.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" @@ -2815,7 +2815,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^106.0.1, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^107.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2926,7 +2926,7 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": +"@metamask/authenticated-user-storage@npm:^1.0.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: @@ -3015,7 +3015,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^72.0.2, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^72.0.3, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -3025,8 +3025,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controller": "npm:^7.0.1" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controller": "npm:^7.1.0" + "@metamask/assets-controllers": "npm:^107.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.0.0" @@ -3062,14 +3062,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-status-controller@npm:^71.1.2, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^71.1.3, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: "@metamask/accounts-controller": "npm:^38.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.0.2" + "@metamask/bridge-controller": "npm:^72.0.3" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/keyring-controller": "npm:^25.5.0" @@ -3148,7 +3148,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chomp-api-service@npm:^3.0.1, @metamask/chomp-api-service@workspace:packages/chomp-api-service": +"@metamask/chomp-api-service@npm:^3.1.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": version: 0.0.0-use.local resolution: "@metamask/chomp-api-service@workspace:packages/chomp-api-service" dependencies: @@ -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: @@ -4540,9 +4540,13 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller" dependencies: + "@metamask/authenticated-user-storage": "npm:^1.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/chomp-api-service": "npm:^3.0.1" + "@metamask/chomp-api-service": "npm:^3.1.0" + "@metamask/delegation-controller": "npm:^3.0.0" + "@metamask/delegation-core": "npm:^2.0.0" + "@metamask/delegation-deployments": "npm:^1.3.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^31.1.0" @@ -4551,6 +4555,7 @@ __metadata: "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" @@ -5763,12 +5768,12 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^7.0.1" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controller": "npm:^7.1.0" + "@metamask/assets-controllers": "npm:^107.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.0.2" - "@metamask/bridge-status-controller": "npm:^71.1.2" + "@metamask/bridge-controller": "npm:^72.0.3" + "@metamask/bridge-status-controller": "npm:^71.1.3" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/messenger": "npm:^1.2.0" @@ -7444,7 +7449,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: @@ -7459,6 +7464,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:^1.2.3": + version: 1.2.4 + resolution: "abitype@npm:1.2.4" + peerDependencies: + typescript: ">=5.0.4" + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + checksum: 10/500b317a53b34cb6ffe3e4f090e135972b43cd2fbdfebe64fc497dfd8515d9117919e5f88f0aaede332d29a21c1826be64a3ffa620b0b91c16e8b560b6635714 + languageName: node + linkType: hard + "abort-controller@npm:^3.0.0": version: 3.0.0 resolution: "abort-controller@npm:3.0.0" @@ -12880,9 +12900,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" @@ -12897,7 +12917,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/077509b841658693a411df505d0bdbbee2d68734aa19736ccff5a6087c119c4aebc1d8d8c2039ca9f16ae7430cb44812e4c182f858cab67c9a755dd0e9914178 + checksum: 10/96526073193f3a6dd2ccd21bcc255e82c7226d6de63fa17a2021c75232fdc9bc969e75e2cbc0c8d5163d88c575e08dc4c75dec7333b1727f080585f07fc6c1ed languageName: node linkType: hard @@ -15048,8 +15068,8 @@ __metadata: linkType: hard "viem@npm:^2.36.0": - version: 2.46.2 - resolution: "viem@npm:2.46.2" + version: 2.48.4 + resolution: "viem@npm:2.48.4" dependencies: "@noble/curves": "npm:1.9.1" "@noble/hashes": "npm:1.8.0" @@ -15057,14 +15077,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